ph-runtime-guest 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/PKG-INFO +1 -1
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/pyproject.toml +1 -1
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/channel.py +57 -4
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/limits.py +75 -6
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/protocol.py +65 -6
- ph_runtime_guest-0.4.0/src/ph_runtime/runner.py +866 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/snapshot.py +2 -1
- ph_runtime_guest-0.4.0/tests/test_channel_limit.py +62 -0
- ph_runtime_guest-0.4.0/tests/test_cpu_budget.py +84 -0
- ph_runtime_guest-0.4.0/tests/test_run_settlement.py +212 -0
- ph_runtime_guest-0.2.0/src/ph_runtime/runner.py +0 -483
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/.gitignore +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/LICENSE +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/README.md +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/__init__.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/__main__.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/_json.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/cell.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/errors.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/lifecycle.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/proxies.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/py.typed +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/src/ph_runtime/skill.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/tests/test_cell_bindings.py +0 -0
- {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.4.0}/tests/test_snapshot_paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ph-runtime-guest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: The guest half of pH's code runtime: the fd-3 protocol, the run loop, and the binding proxies a cell awaits.
|
|
5
5
|
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
6
|
Project-URL: Repository, https://github.com/chastabor/pH
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ph-runtime-guest"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "The guest half of pH's code runtime: the fd-3 protocol, the run loop, and the binding proxies a cell awaits."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -17,13 +17,66 @@ import contextlib
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
19
|
import socket
|
|
20
|
+
from datetime import date, datetime
|
|
21
|
+
from datetime import time as time_of_day
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from pathlib import PurePath
|
|
20
24
|
from typing import Any
|
|
21
25
|
|
|
22
|
-
from .protocol import FD_ENV, PROTOCOL_FD
|
|
26
|
+
from .protocol import FD_ENV, FRAME_BYTES_ENV, PROTOCOL_FD
|
|
23
27
|
|
|
24
|
-
__all__ = ["MAX_FRAME_BYTES", "Channel"]
|
|
28
|
+
__all__ = ["MAX_FRAME_BYTES", "Channel", "frame_limit", "jsonable"]
|
|
25
29
|
|
|
26
30
|
MAX_FRAME_BYTES = 64 * 1024 * 1024
|
|
31
|
+
"""The read limit when the host names none — see `FRAME_BYTES_ENV` (F8)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def frame_limit() -> int:
|
|
35
|
+
"""The largest frame this guest will read: the host's number, else the default.
|
|
36
|
+
|
|
37
|
+
The host sizes it from `maxSnapshotBytes`, because one snapshotted variable
|
|
38
|
+
must fit one frame in *both* directions — a fixed 64 MiB here lost the
|
|
39
|
+
namespace on restore as soon as a deployment raised the limit past ~48 MiB,
|
|
40
|
+
base64 being four bytes per three (F8). A value that is not a positive
|
|
41
|
+
integer is ignored rather than trusted: a zero limit would refuse every frame.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
named = int(os.environ.get(FRAME_BYTES_ENV, ""))
|
|
45
|
+
except ValueError:
|
|
46
|
+
return MAX_FRAME_BYTES
|
|
47
|
+
return named if named > 0 else MAX_FRAME_BYTES
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def jsonable(value: object) -> object:
|
|
51
|
+
"""One non-JSON value as something the host can use (F7).
|
|
52
|
+
|
|
53
|
+
`default=repr` was the whole rule, and it is silently lossy for the two
|
|
54
|
+
types a cell passes most: `tools.read(Path("notes.md"))` arrived as the
|
|
55
|
+
string `"PosixPath('notes.md')"`, and a `datetime` as
|
|
56
|
+
`"datetime.datetime(2026, 9, 20, 0, 0)"`. Neither is an error anywhere — the
|
|
57
|
+
tool receives a string where the model wrote a value, and either refuses it
|
|
58
|
+
with a message about a path that does not exist or, worse, uses it.
|
|
59
|
+
|
|
60
|
+
The obvious spellings for the types that have one, and `repr` for the rest:
|
|
61
|
+
a value with no JSON form is a value the tool's own schema is going to
|
|
62
|
+
reject, and `repr` is what makes *that* refusal readable. Deliberately not a
|
|
63
|
+
guest-side raise — the guest does not know the tool's schema, and refusing
|
|
64
|
+
an argument a tool would have accepted is the worse error.
|
|
65
|
+
|
|
66
|
+
It lives here, on the channel, because the lossiness is a property of *the
|
|
67
|
+
wire* and not of one frame: an argument, a cell's value and every other
|
|
68
|
+
frame leave through `send`, and a `Path` that survives as one and not the
|
|
69
|
+
others is the same bug reported twice.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(value, PurePath):
|
|
72
|
+
return str(value)
|
|
73
|
+
if isinstance(value, datetime | date | time_of_day):
|
|
74
|
+
return value.isoformat()
|
|
75
|
+
if isinstance(value, set | frozenset | tuple):
|
|
76
|
+
return list(value)
|
|
77
|
+
if isinstance(value, Decimal):
|
|
78
|
+
return str(value)
|
|
79
|
+
return repr(value)
|
|
27
80
|
|
|
28
81
|
|
|
29
82
|
class Channel:
|
|
@@ -45,7 +98,7 @@ class Channel:
|
|
|
45
98
|
fd = int(os.environ.get(FD_ENV, PROTOCOL_FD))
|
|
46
99
|
sock = socket.socket(fileno=fd)
|
|
47
100
|
sock.setblocking(False)
|
|
48
|
-
reader, writer = await asyncio.open_connection(sock=sock, limit=
|
|
101
|
+
reader, writer = await asyncio.open_connection(sock=sock, limit=frame_limit())
|
|
49
102
|
return cls(reader, writer)
|
|
50
103
|
|
|
51
104
|
async def receive(self) -> dict[str, Any] | None:
|
|
@@ -77,7 +130,7 @@ class Channel:
|
|
|
77
130
|
# A dead host is not this process's problem to report: the
|
|
78
131
|
# die-with-parent mechanism is what ends the guest (F3).
|
|
79
132
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError, RuntimeError):
|
|
80
|
-
self._writer.write(json.dumps(frame, default=
|
|
133
|
+
self._writer.write(json.dumps(frame, default=jsonable).encode("utf-8") + b"\n")
|
|
81
134
|
|
|
82
135
|
async def drain(self) -> None:
|
|
83
136
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError, RuntimeError):
|
|
@@ -14,6 +14,13 @@ Exceeding it raises `CpuBudgetExceeded`, which derives from `BaseException` on
|
|
|
14
14
|
purpose: like a denial (C3), a budget is not the program's to catch. A cell that
|
|
15
15
|
could `except Exception` its way past the limit would make the limit advisory.
|
|
16
16
|
|
|
17
|
+
**Who raises it is the runner's decision, not this module's.** `SIGXCPU` can only
|
|
18
|
+
be turned into an exception safely when the main thread is executing the cell —
|
|
19
|
+
otherwise it lands in whatever library frame the interpreter happens to be in,
|
|
20
|
+
which for a guest that is mostly waiting means `asyncio`'s own internals. So this
|
|
21
|
+
module arms and disarms the limit, and `Runner` installs the handler that knows
|
|
22
|
+
where the cell is.
|
|
23
|
+
|
|
17
24
|
@module ph_runtime.limits
|
|
18
25
|
"""
|
|
19
26
|
|
|
@@ -22,9 +29,26 @@ from __future__ import annotations
|
|
|
22
29
|
import contextlib
|
|
23
30
|
import math
|
|
24
31
|
import signal
|
|
32
|
+
import time
|
|
25
33
|
from typing import Any
|
|
26
34
|
|
|
27
|
-
__all__ = [
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CPU_BUDGET_MESSAGE",
|
|
37
|
+
"CpuBudgetExceeded",
|
|
38
|
+
"apply_limits",
|
|
39
|
+
"arm_cpu_budget",
|
|
40
|
+
"cpu_seconds_used",
|
|
41
|
+
"relax_cpu_budget",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CPU_BUDGET_MESSAGE = "this cell used its CPU budget"
|
|
46
|
+
"""What both routes out of a budget breach say.
|
|
47
|
+
|
|
48
|
+
The handler raises on one path and cancels the run on the other, and
|
|
49
|
+
`_on_cpu_budget`'s docstring promises they end in the same `cpu` error. Written
|
|
50
|
+
out at each of them, that promise was two string literals keeping each other
|
|
51
|
+
honest."""
|
|
28
52
|
|
|
29
53
|
|
|
30
54
|
class CpuBudgetExceeded(BaseException):
|
|
@@ -40,8 +64,6 @@ except ImportError: # pragma: no cover — Windows has no `resource` module
|
|
|
40
64
|
def cpu_seconds_used() -> float:
|
|
41
65
|
"""CPU seconds this process has consumed, user + system."""
|
|
42
66
|
if resource is None: # pragma: no cover
|
|
43
|
-
import time
|
|
44
|
-
|
|
45
67
|
return time.process_time()
|
|
46
68
|
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
47
69
|
return float(usage.ru_utime + usage.ru_stime)
|
|
@@ -82,18 +104,41 @@ def apply_limits(*, address_space_bytes: int) -> dict[str, Any]:
|
|
|
82
104
|
return applied
|
|
83
105
|
|
|
84
106
|
|
|
85
|
-
def
|
|
86
|
-
|
|
107
|
+
def _uncaught_budget() -> bool:
|
|
108
|
+
"""Whether a `SIGXCPU` would kill this process rather than reach a handler.
|
|
109
|
+
|
|
110
|
+
**Never arm a limit whose breach nothing here will catch** (M1). `RLIMIT_CPU`
|
|
111
|
+
is process-wide and `SIGXCPU`'s default disposition is to terminate, so an
|
|
112
|
+
arm made where no handler is installed points a timer at whatever process
|
|
113
|
+
this happens to be — and `Runner` is built in-process by this package's own
|
|
114
|
+
tests, where that process is pytest. It had been pointed for a while: the
|
|
115
|
+
per-run arm left the limit at `used + cpuSeconds` on the test runner, which
|
|
116
|
+
the suite reached on a long run and died of, reading as a run that finished
|
|
117
|
+
and printed no summary.
|
|
118
|
+
|
|
119
|
+
Asked of `signal` rather than remembered by the caller, because a flag on
|
|
120
|
+
the one caller that exists today is a rule every later caller has to be told
|
|
121
|
+
about — and this also covers a handler reset after install, or an
|
|
122
|
+
`install_signal_handlers` that partially failed.
|
|
123
|
+
|
|
124
|
+
`getattr` because `SIGXCPU` is POSIX; where it does not exist there is no
|
|
125
|
+
budget to arm and nothing to catch.
|
|
126
|
+
"""
|
|
127
|
+
sigxcpu = getattr(signal, "SIGXCPU", None)
|
|
128
|
+
if sigxcpu is None: # pragma: no cover - Windows
|
|
129
|
+
return True
|
|
130
|
+
return signal.getsignal(sigxcpu) in (signal.SIG_DFL, signal.SIG_IGN)
|
|
87
131
|
|
|
88
132
|
|
|
89
133
|
def arm_cpu_budget(cpu_seconds: int) -> None:
|
|
90
134
|
"""Give the *next* run `cpu_seconds` of CPU, from whatever is spent so far."""
|
|
91
135
|
if resource is None or cpu_seconds <= 0: # pragma: no cover
|
|
92
136
|
return
|
|
93
|
-
signal.signal(signal.SIGXCPU, _on_sigxcpu)
|
|
94
137
|
# `ceil`, not `int`: flooring the CPU already spent hands the next cell less
|
|
95
138
|
# than `cpu_seconds` — a bomb that burned 1.9s floors to 1, so a budget of 1
|
|
96
139
|
# leaves 0.1s and the *next* trivial cell dies on the previous one's spend.
|
|
140
|
+
if _uncaught_budget():
|
|
141
|
+
return
|
|
97
142
|
used = math.ceil(cpu_seconds_used())
|
|
98
143
|
soft = used + cpu_seconds
|
|
99
144
|
_, hard = resource.getrlimit(resource.RLIMIT_CPU)
|
|
@@ -101,3 +146,27 @@ def arm_cpu_budget(cpu_seconds: int) -> None:
|
|
|
101
146
|
soft = min(soft, hard)
|
|
102
147
|
with contextlib.suppress(ValueError, OSError): # pragma: no cover
|
|
103
148
|
resource.setrlimit(resource.RLIMIT_CPU, (soft, hard))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def relax_cpu_budget() -> None:
|
|
152
|
+
"""Put the soft limit back to the hard one. **Called first, from the handler.**
|
|
153
|
+
|
|
154
|
+
`RLIMIT_CPU` is not a one-shot. Linux delivers `SIGXCPU` when the soft limit
|
|
155
|
+
is first crossed and then **again every CPU-second** for as long as the
|
|
156
|
+
process stays over it — and the process stays over it, because the limit is
|
|
157
|
+
cumulative and nothing gives CPU back. So one budget breach became a signal
|
|
158
|
+
per second for the rest of the run's teardown: one landed in the cell and
|
|
159
|
+
unwound it as intended, and the next landed in `_snapshot` or `_settle`,
|
|
160
|
+
escaped the `try` that was supposed to report the failure, and the `done`
|
|
161
|
+
frame was never sent. The host has no wall clock of its own on a run, so it
|
|
162
|
+
waited for a frame that was never coming.
|
|
163
|
+
|
|
164
|
+
Disarming at the first delivery is what makes the budget the one-shot the
|
|
165
|
+
rest of the design assumes. The next `arm_cpu_budget` re-arms it, which is
|
|
166
|
+
where a per-run budget comes from in the first place.
|
|
167
|
+
"""
|
|
168
|
+
if resource is None: # pragma: no cover
|
|
169
|
+
return
|
|
170
|
+
_, hard = resource.getrlimit(resource.RLIMIT_CPU)
|
|
171
|
+
with contextlib.suppress(ValueError, OSError): # pragma: no cover
|
|
172
|
+
resource.setrlimit(resource.RLIMIT_CPU, (hard, hard))
|
|
@@ -44,6 +44,7 @@ from ._json import as_str
|
|
|
44
44
|
|
|
45
45
|
__all__ = [
|
|
46
46
|
"FD_ENV",
|
|
47
|
+
"FRAME_BYTES_ENV",
|
|
47
48
|
"FRAME_FIELDS",
|
|
48
49
|
"GUEST_FRAMES",
|
|
49
50
|
"HOST_FRAMES",
|
|
@@ -55,8 +56,33 @@ __all__ = [
|
|
|
55
56
|
"truncation_marker",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
|
-
PROTOCOL_VERSION: Final =
|
|
59
|
-
"""
|
|
59
|
+
PROTOCOL_VERSION: Final = 4
|
|
60
|
+
"""Four, since `restore` was split into one frame per variable (O3).
|
|
61
|
+
|
|
62
|
+
`more` is required — `encode` drops only `None`, so a `bool` default is always
|
|
63
|
+
sent — and a guest that does not read it answers `done` on the first batch,
|
|
64
|
+
leaving the host settled with the namespace half restored and nothing said.
|
|
65
|
+
A required field added, which is the rule the number exists for.
|
|
66
|
+
|
|
67
|
+
Three, since `boot` gained a required `idleCpuSeconds` (M1).
|
|
68
|
+
|
|
69
|
+
The rule that moved it to two, applied again: a guest that cannot read the field
|
|
70
|
+
has no budget between runs, and a guest that reads a frame without it raises
|
|
71
|
+
before `boot-ack`. Neither side can ignore the difference, so the pairing is
|
|
72
|
+
refused at boot — and `venv._marker`, which keys on this number and carries no
|
|
73
|
+
content digest, rebuilds a warm `$PH_CACHE/runtime-venv` instead of booting last
|
|
74
|
+
week's guest against today's host. `ping`/`pong` needed no bump on their own:
|
|
75
|
+
an older pairing simply never sends or answers one.
|
|
76
|
+
|
|
77
|
+
Two, since `call` gained a required `run` (F4).
|
|
78
|
+
|
|
79
|
+
Moved by exactly the rule below: a **required field added**, which a mismatched
|
|
80
|
+
pairing would misread rather than ignore. A guest that does not send `run` has
|
|
81
|
+
its every binding call refused by a host that requires it — silently, as "not
|
|
82
|
+
available" — so the pairing has to be refused at `boot-ack` instead, and a warm
|
|
83
|
+
venv from before the change has to rebuild. That is what the number is for.
|
|
84
|
+
|
|
85
|
+
The original note, still the rule:
|
|
60
86
|
|
|
61
87
|
A version gate exists for two builds that have to understand each other, and
|
|
62
88
|
before a first release there are not two: the host and the guest ship together
|
|
@@ -92,10 +118,21 @@ number it has in the *parent*, and re-numbering it to 3 in the child would need
|
|
|
92
118
|
a `preexec_fn` — which is unsafe in a threaded parent. So the host passes the
|
|
93
119
|
number instead of moving the descriptor."""
|
|
94
120
|
|
|
121
|
+
FRAME_BYTES_ENV: Final = "PH_RUNTIME_FRAME_BYTES"
|
|
122
|
+
"""The largest frame the guest's reader will assemble, sized by the host (F8).
|
|
123
|
+
|
|
124
|
+
The host derives it from `maxSnapshotBytes` — its own `frame_cap` — so both
|
|
125
|
+
readers are sized by one expression. In the environment rather than in `boot`
|
|
126
|
+
because the reader's limit is fixed when the connection opens, before any frame
|
|
127
|
+
has been read; `FD_ENV` crosses the same way for the same reason. Absent, the
|
|
128
|
+
guest keeps `channel.MAX_FRAME_BYTES`, which is what an older host expects."""
|
|
129
|
+
|
|
95
130
|
NAMESPACE_ENV: Final = "PH_NAMESPACE_ID"
|
|
96
131
|
|
|
97
|
-
HOST_FRAMES: Final = frozenset({"boot", "run", "reply", "restore", "cancel", "shutdown"})
|
|
98
|
-
GUEST_FRAMES: Final = frozenset(
|
|
132
|
+
HOST_FRAMES: Final = frozenset({"boot", "run", "reply", "restore", "cancel", "shutdown", "ping"})
|
|
133
|
+
GUEST_FRAMES: Final = frozenset(
|
|
134
|
+
{"boot-ack", "call", "log", "display", "snapshot", "done", "fault", "pong"}
|
|
135
|
+
)
|
|
99
136
|
|
|
100
137
|
UNPRODUCED_FRAMES: Final = frozenset({"display"})
|
|
101
138
|
"""Frames defined here that nothing emits yet.
|
|
@@ -119,6 +156,9 @@ FRAME_FIELDS: Final[dict[str, tuple[frozenset[str], frozenset[str]]]] = {
|
|
|
119
156
|
"type",
|
|
120
157
|
"protocol",
|
|
121
158
|
"cpuSeconds",
|
|
159
|
+
# See `Runner._end_runaway` (M1): what a namespace may burn while
|
|
160
|
+
# no cell is running, which is the window nothing else bounds.
|
|
161
|
+
"idleCpuSeconds",
|
|
122
162
|
"addressSpaceBytes",
|
|
123
163
|
"maxLogBytes",
|
|
124
164
|
"maxValueBytes",
|
|
@@ -136,16 +176,35 @@ FRAME_FIELDS: Final[dict[str, tuple[frozenset[str], frozenset[str]]]] = {
|
|
|
136
176
|
# a budget), so the proxy raises what the program is not offered a chance to
|
|
137
177
|
# catch — and the host aborts the run regardless of whether it tries.
|
|
138
178
|
"reply": (frozenset({"type", "id", "ok"}), frozenset({"value", "message", "name", "fatal"})),
|
|
139
|
-
|
|
179
|
+
# **One frame per variable, like `snapshot` coming back** (O3).
|
|
180
|
+
# `maxSnapshotBytes` bounds each *value*; a frame carrying the whole
|
|
181
|
+
# namespace was bounded by nothing, and the guest's reader is sized before
|
|
182
|
+
# any frame arrives (`FRAME_BYTES_ENV`, F8) — so one value per frame is what
|
|
183
|
+
# that size can promise to hold. `more` is the batch marker: the guest
|
|
184
|
+
# accumulates and answers on the frame without it.
|
|
185
|
+
"restore": (frozenset({"type", "id", "variables", "more"}), frozenset()),
|
|
140
186
|
"cancel": (frozenset({"type"}), frozenset({"id"})),
|
|
141
187
|
"shutdown": (frozenset({"type"}), frozenset()),
|
|
142
188
|
"boot-ack": (frozenset({"type", "protocol", "python", "limits"}), frozenset()),
|
|
143
|
-
"call": (frozenset({"type", "id", "global", "name", "args"}), frozenset()),
|
|
189
|
+
"call": (frozenset({"type", "id", "run", "global", "name", "args"}), frozenset()),
|
|
144
190
|
"log": (frozenset({"type", "stream", "text"}), frozenset({"truncated"})),
|
|
145
191
|
"display": (frozenset({"type", "mime", "data"}), frozenset({"meta"})),
|
|
146
192
|
"snapshot": (frozenset({"type", "id", "variables"}), frozenset()),
|
|
147
193
|
"done": (frozenset({"type", "id"}), frozenset({"value", "error", "truncated"})),
|
|
148
194
|
"fault": (frozenset({"type", "message"}), frozenset()),
|
|
195
|
+
# **The loop's own clock** (M2). `ping` is answered by the guest's reader
|
|
196
|
+
# task, so the round trip measures how far behind the guest's event loop
|
|
197
|
+
# is — a cell burning CPU starves that task and the answer is late or
|
|
198
|
+
# absent, which is a *load* reading and not a death certificate.
|
|
199
|
+
#
|
|
200
|
+
# `run` is the host's open run, and it rides on the *question* rather
|
|
201
|
+
# than the answer. The host waits for `done` with no wall clock of its
|
|
202
|
+
# own, so it asks the one party that knows: a guest neither running that
|
|
203
|
+
# run nor owing it a `done` settles it on the spot, through the same
|
|
204
|
+
# `_send_done` every other terminal path takes. The repair travels as an
|
|
205
|
+
# ordinary `done`, which leaves the pong carrying nothing but the clock.
|
|
206
|
+
"ping": (frozenset({"type", "id", "run"}), frozenset()),
|
|
207
|
+
"pong": (frozenset({"type", "id"}), frozenset()),
|
|
149
208
|
}
|
|
150
209
|
"""frame type → (required fields, optional fields).
|
|
151
210
|
|