ph-runtime-guest 0.2.0__tar.gz → 0.3.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.
Files changed (25) hide show
  1. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/PKG-INFO +1 -1
  2. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/pyproject.toml +1 -1
  3. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/channel.py +57 -4
  4. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/limits.py +74 -4
  5. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/protocol.py +65 -6
  6. ph_runtime_guest-0.3.0/src/ph_runtime/runner.py +867 -0
  7. ph_runtime_guest-0.3.0/tests/test_channel_limit.py +62 -0
  8. ph_runtime_guest-0.3.0/tests/test_cpu_budget.py +84 -0
  9. ph_runtime_guest-0.3.0/tests/test_run_settlement.py +212 -0
  10. ph_runtime_guest-0.2.0/src/ph_runtime/runner.py +0 -483
  11. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/.gitignore +0 -0
  12. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/LICENSE +0 -0
  13. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/README.md +0 -0
  14. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/__init__.py +0 -0
  15. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/__main__.py +0 -0
  16. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/_json.py +0 -0
  17. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/cell.py +0 -0
  18. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/errors.py +0 -0
  19. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/lifecycle.py +0 -0
  20. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/proxies.py +0 -0
  21. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/py.typed +0 -0
  22. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/skill.py +0 -0
  23. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/src/ph_runtime/snapshot.py +0 -0
  24. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.0}/tests/test_cell_bindings.py +0 -0
  25. {ph_runtime_guest-0.2.0 → ph_runtime_guest-0.3.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.2.0
3
+ Version: 0.3.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.2.0"
7
+ version = "0.3.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=MAX_FRAME_BYTES)
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=repr).encode("utf-8") + b"\n")
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
 
@@ -24,7 +31,23 @@ import math
24
31
  import signal
25
32
  from typing import Any
26
33
 
27
- __all__ = ["CpuBudgetExceeded", "apply_limits", "arm_cpu_budget", "cpu_seconds_used"]
34
+ __all__ = [
35
+ "CPU_BUDGET_MESSAGE",
36
+ "CpuBudgetExceeded",
37
+ "apply_limits",
38
+ "arm_cpu_budget",
39
+ "cpu_seconds_used",
40
+ "relax_cpu_budget",
41
+ ]
42
+
43
+
44
+ CPU_BUDGET_MESSAGE = "this cell used its CPU budget"
45
+ """What both routes out of a budget breach say.
46
+
47
+ The handler raises on one path and cancels the run on the other, and
48
+ `_on_cpu_budget`'s docstring promises they end in the same `cpu` error. Written
49
+ out at each of them, that promise was two string literals keeping each other
50
+ honest."""
28
51
 
29
52
 
30
53
  class CpuBudgetExceeded(BaseException):
@@ -82,18 +105,41 @@ def apply_limits(*, address_space_bytes: int) -> dict[str, Any]:
82
105
  return applied
83
106
 
84
107
 
85
- def _on_sigxcpu(_signum: int, _frame: object) -> None:
86
- raise CpuBudgetExceeded("this cell used its CPU budget")
108
+ def _uncaught_budget() -> bool:
109
+ """Whether a `SIGXCPU` would kill this process rather than reach a handler.
110
+
111
+ **Never arm a limit whose breach nothing here will catch** (M1). `RLIMIT_CPU`
112
+ is process-wide and `SIGXCPU`'s default disposition is to terminate, so an
113
+ arm made where no handler is installed points a timer at whatever process
114
+ this happens to be — and `Runner` is built in-process by this package's own
115
+ tests, where that process is pytest. It had been pointed for a while: the
116
+ per-run arm left the limit at `used + cpuSeconds` on the test runner, which
117
+ the suite reached on a long run and died of, reading as a run that finished
118
+ and printed no summary.
119
+
120
+ Asked of `signal` rather than remembered by the caller, because a flag on
121
+ the one caller that exists today is a rule every later caller has to be told
122
+ about — and this also covers a handler reset after install, or an
123
+ `install_signal_handlers` that partially failed.
124
+
125
+ `getattr` because `SIGXCPU` is POSIX; where it does not exist there is no
126
+ budget to arm and nothing to catch.
127
+ """
128
+ sigxcpu = getattr(signal, "SIGXCPU", None)
129
+ if sigxcpu is None: # pragma: no cover - Windows
130
+ return True
131
+ return signal.getsignal(sigxcpu) in (signal.SIG_DFL, signal.SIG_IGN)
87
132
 
88
133
 
89
134
  def arm_cpu_budget(cpu_seconds: int) -> None:
90
135
  """Give the *next* run `cpu_seconds` of CPU, from whatever is spent so far."""
91
136
  if resource is None or cpu_seconds <= 0: # pragma: no cover
92
137
  return
93
- signal.signal(signal.SIGXCPU, _on_sigxcpu)
94
138
  # `ceil`, not `int`: flooring the CPU already spent hands the next cell less
95
139
  # than `cpu_seconds` — a bomb that burned 1.9s floors to 1, so a budget of 1
96
140
  # leaves 0.1s and the *next* trivial cell dies on the previous one's spend.
141
+ if _uncaught_budget():
142
+ return
97
143
  used = math.ceil(cpu_seconds_used())
98
144
  soft = used + cpu_seconds
99
145
  _, hard = resource.getrlimit(resource.RLIMIT_CPU)
@@ -101,3 +147,27 @@ def arm_cpu_budget(cpu_seconds: int) -> None:
101
147
  soft = min(soft, hard)
102
148
  with contextlib.suppress(ValueError, OSError): # pragma: no cover
103
149
  resource.setrlimit(resource.RLIMIT_CPU, (soft, hard))
150
+
151
+
152
+ def relax_cpu_budget() -> None:
153
+ """Put the soft limit back to the hard one. **Called first, from the handler.**
154
+
155
+ `RLIMIT_CPU` is not a one-shot. Linux delivers `SIGXCPU` when the soft limit
156
+ is first crossed and then **again every CPU-second** for as long as the
157
+ process stays over it — and the process stays over it, because the limit is
158
+ cumulative and nothing gives CPU back. So one budget breach became a signal
159
+ per second for the rest of the run's teardown: one landed in the cell and
160
+ unwound it as intended, and the next landed in `_snapshot` or `_settle`,
161
+ escaped the `try` that was supposed to report the failure, and the `done`
162
+ frame was never sent. The host has no wall clock of its own on a run, so it
163
+ waited for a frame that was never coming.
164
+
165
+ Disarming at the first delivery is what makes the budget the one-shot the
166
+ rest of the design assumes. The next `arm_cpu_budget` re-arms it, which is
167
+ where a per-run budget comes from in the first place.
168
+ """
169
+ if resource is None: # pragma: no cover
170
+ return
171
+ _, hard = resource.getrlimit(resource.RLIMIT_CPU)
172
+ with contextlib.suppress(ValueError, OSError): # pragma: no cover
173
+ 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 = 1
59
- """One, and it stays there until there is something to be compatible with.
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({"boot-ack", "call", "log", "display", "snapshot", "done", "fault"})
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
- "restore": (frozenset({"type", "id", "variables"}), frozenset()),
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