ph-runtime-guest 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """The guest half of pH's Python code runtime.
2
+
3
+ Runs inside `$PH_CACHE/runtime-venv`, in a subprocess the host spawns per agent,
4
+ and reaches the host over one framed channel on fd 3. It imports neither
5
+ `ph-core` nor `ph-rlm`: the process boundary exists so that model code cannot
6
+ reach the harness, and importing the harness would put it back inside.
7
+
8
+ @module ph_runtime
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .errors import RunStopped, ToolFailed
14
+ from .protocol import PROTOCOL_FD, PROTOCOL_VERSION
15
+ from .skill import wrap_skill_module
16
+
17
+ __all__ = [
18
+ "PROTOCOL_FD",
19
+ "PROTOCOL_VERSION",
20
+ "RunStopped",
21
+ "ToolFailed",
22
+ "wrap_skill_module",
23
+ ]
ph_runtime/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """`python -m ph_runtime` — how the host spawns the guest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .runner import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
ph_runtime/_json.py ADDED
@@ -0,0 +1,41 @@
1
+ """The narrowings `ph_runtime` needs from `ph.json` — a copy, pinned by a test.
2
+
3
+ This package ships into the guest venv with `dill` as its only dependency, so it
4
+ cannot import `ph.json`: that module lives in the ph-core wheel, and depending on
5
+ the wheel is the thing this package exists not to do.
6
+
7
+ **Copied by hand, compared character for character by
8
+ `test_protocol_mirror.py`.** What keeps the two in step is the test rather than
9
+ anyone's memory — that file opens by recording that *"a third copy that no test
10
+ compared had already drifted"*. When it fails, copy the definition across again;
11
+ when the guest comes to need a second narrowing, paste that one in and the test
12
+ picks it up by name.
13
+
14
+ The arrangement `truncation_marker` already has in `ph_runtime.protocol` — the
15
+ other thing this package copies rather than imports — though that one is held to
16
+ ph-core's by its output and this by its source, because a marker is a sentence
17
+ built from two numbers and a narrowing is its text.
18
+
19
+ Private, and re-exported by `ph_runtime.protocol` for the names the guest uses:
20
+ this is ph-core's code, not the guest's API.
21
+
22
+ @module ph_runtime._json
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+
28
+ def as_str(value: object, default: str = "") -> str:
29
+ """A JSON string, or `default` — the fourth of the family, and the copied one.
30
+
31
+ One policy, the one `as_int`'s docstring argues for: a mis-shaped field
32
+ answers with the empty value rather than raising, because a reader of a log
33
+ some other build wrote must lose a row and not a session.
34
+
35
+ **Not `str(value)`**, which is what a reader writes without this and is worse
36
+ than useless: `str(None)` is `"None"` and `str(3)` is `"3"`, so a field that
37
+ is absent or of the wrong type comes back as a plausible-looking answer that
38
+ no assertion catches. Narrowing says "this was not a string" by giving back
39
+ nothing, which is the same thing `as_obj` and `as_seq` say.
40
+ """
41
+ return value if isinstance(value, str) else default
ph_runtime/cell.py ADDED
@@ -0,0 +1,193 @@
1
+ """Compiling one cell so that `await`, `return` and persistence all hold.
2
+
3
+ Three requirements pull against each other, and the way they are reconciled is
4
+ the only interesting thing in this module.
5
+
6
+ 1. **`await` at the top level.** The program is a coroutine on the child's one
7
+ loop, so there is no `nest_asyncio` question and no second loop to re-enter.
8
+ 2. **`return` at the top level.** A bare `return` is a syntax error in module
9
+ code even with `PyCF_ALLOW_TOP_LEVEL_AWAIT`, so the body is wrapped in an
10
+ `async def` — which is also what makes (1) fall out for free.
11
+ 3. **Names persist across cells.** But a name assigned inside a function is
12
+ *local* to it, so the naive wrapping loses every variable the cell defined —
13
+ which would quietly undo the whole point of a persistent namespace.
14
+
15
+ So the wrapper declares every name the cell binds at its top level as `global`.
16
+ That is what this module computes: the bound-name set, from the AST, including
17
+ the cases that are easy to forget — `import`, `with ... as`, `for`, `except ...
18
+ as`, walrus, `del`, and function and class definitions.
19
+
20
+ A trailing expression becomes the cell's value, as it would in a REPL, so
21
+ `stdout / stderr / result / traceback` reads the way prime-agent's did.
22
+
23
+ @module ph_runtime.cell
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+ from typing import Any
30
+
31
+ __all__ = [
32
+ "CELL_FILENAME",
33
+ "CELL_FUNCTION",
34
+ "MAGIC_HINT",
35
+ "MAGIC_PREFIXES",
36
+ "bound_names",
37
+ "compile_cell",
38
+ ]
39
+
40
+ CELL_FUNCTION = "__ph_cell__"
41
+
42
+ CELL_FILENAME = "<cell>"
43
+ """The compiled name. Also the marker a traceback is trimmed to, so the model
44
+ sees its own frames and not the runner's."""
45
+
46
+ MAGIC_PREFIXES = ("%%", "%", "!")
47
+ """Every IPython escape, exported because three layers describe this rule.
48
+
49
+ The guest refuses them, the RLM doctrine tells the model so, and the conformance
50
+ suite checks the two agree. A hole left in one prefix is the hole, so the list
51
+ has one home rather than three that drift."""
52
+
53
+ MAGIC_HINT = (
54
+ "IPython magics are not available: pH runs plain Python, so there is no "
55
+ "`%%bash` to bypass the tool pipeline. Use `await tools.bash(command=...)` "
56
+ "for a shell, and ordinary Python for the rest."
57
+ )
58
+ """Attached to the `SyntaxError` a magic produces (D2).
59
+
60
+ The magic was the bypass — one shell command per cell that no `tools/pre-execute`
61
+ listener, no approval and no sandbox `confine()` ever saw. Removing the
62
+ mechanism closes the hole, so the error explains the governed route rather than
63
+ apologising for a missing feature.
64
+ """
65
+
66
+
67
+ def bound_names(body: list[ast.stmt]) -> set[str]:
68
+ """Every name the cell's *top level* binds, so the wrapper can globalize it.
69
+
70
+ Only the top level: a name bound inside a nested function or comprehension
71
+ is local to it in module code too, so declaring it global would change the
72
+ program's meaning rather than preserve it.
73
+ """
74
+ found: set[str] = set()
75
+
76
+ def add_target(node: ast.expr) -> None:
77
+ if isinstance(node, ast.Name):
78
+ found.add(node.id)
79
+ elif isinstance(node, ast.Starred):
80
+ add_target(node.value)
81
+ elif isinstance(node, (ast.Tuple, ast.List)):
82
+ for element in node.elts:
83
+ add_target(element)
84
+
85
+ def walk_expression(node: ast.expr) -> None:
86
+ # A walrus binds in the enclosing scope, so `if (n := len(x)) > 3:` at
87
+ # the top level defines `n` for later cells.
88
+ for child in ast.walk(node):
89
+ if isinstance(child, ast.NamedExpr):
90
+ add_target(child.target)
91
+
92
+ for statement in body:
93
+ if isinstance(statement, (ast.Assign,)):
94
+ for target in statement.targets:
95
+ add_target(target)
96
+ walk_expression(statement.value)
97
+ elif isinstance(statement, (ast.AugAssign, ast.AnnAssign)):
98
+ add_target(statement.target)
99
+ if statement.value is not None:
100
+ walk_expression(statement.value)
101
+ elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
102
+ found.add(statement.name)
103
+ elif isinstance(statement, (ast.Import, ast.ImportFrom)):
104
+ for alias in statement.names:
105
+ if alias.name == "*":
106
+ continue
107
+ found.add(alias.asname or alias.name.split(".")[0])
108
+ elif isinstance(statement, (ast.For, ast.AsyncFor)):
109
+ add_target(statement.target)
110
+ found |= bound_names(statement.body) | bound_names(statement.orelse)
111
+ elif isinstance(statement, (ast.While, ast.If)):
112
+ walk_expression(statement.test)
113
+ found |= bound_names(statement.body) | bound_names(statement.orelse)
114
+ elif isinstance(statement, (ast.With, ast.AsyncWith)):
115
+ for item in statement.items:
116
+ if item.optional_vars is not None:
117
+ add_target(item.optional_vars)
118
+ found |= bound_names(statement.body)
119
+ elif isinstance(statement, ast.Try):
120
+ found |= bound_names(statement.body) | bound_names(statement.orelse)
121
+ found |= bound_names(statement.finalbody)
122
+ for handler in statement.handlers:
123
+ if handler.name:
124
+ found.add(handler.name)
125
+ found |= bound_names(handler.body)
126
+ elif isinstance(statement, ast.Match):
127
+ for case in statement.cases:
128
+ found |= bound_names(case.body)
129
+ for child in ast.walk(case.pattern):
130
+ if isinstance(child, ast.MatchAs | ast.MatchStar) and child.name:
131
+ found.add(child.name)
132
+ elif isinstance(child, ast.MatchMapping) and child.rest:
133
+ found.add(child.rest)
134
+ elif isinstance(statement, ast.Delete):
135
+ # `del x` needs `global x` too, or it raises for a name that is in
136
+ # globals but not local.
137
+ for target in statement.targets:
138
+ add_target(target)
139
+ elif isinstance(statement, (ast.Expr, ast.Return)) and statement.value is not None:
140
+ walk_expression(statement.value)
141
+
142
+ return found
143
+
144
+
145
+ def compile_cell(program: str, filename: str = CELL_FILENAME) -> Any: # noqa: ANN401
146
+ """Compile `program` into a module that defines `CELL_FUNCTION`.
147
+
148
+ :raises SyntaxError: the program does not parse. A magic gets `MAGIC_HINT`
149
+ appended, because that is the one syntax error with a governed answer.
150
+ """
151
+ try:
152
+ tree = ast.parse(program)
153
+ except SyntaxError as error:
154
+ if _looks_like_magic(program):
155
+ raise SyntaxError(f"{error.msg}. {MAGIC_HINT}") from error
156
+ raise
157
+
158
+ body = list(tree.body)
159
+ if body and isinstance(body[-1], ast.Expr):
160
+ last = body[-1]
161
+ body[-1] = ast.copy_location(ast.Return(value=last.value), last)
162
+
163
+ declarations: list[ast.stmt] = []
164
+ names = sorted(bound_names(list(tree.body)))
165
+ if names:
166
+ declarations.append(ast.Global(names=names))
167
+ if not body:
168
+ body = [ast.Pass()]
169
+
170
+ wrapper = ast.AsyncFunctionDef(
171
+ name=CELL_FUNCTION,
172
+ args=ast.arguments(
173
+ posonlyargs=[],
174
+ args=[],
175
+ vararg=None,
176
+ kwonlyargs=[],
177
+ kw_defaults=[],
178
+ kwarg=None,
179
+ defaults=[],
180
+ ),
181
+ body=declarations + body,
182
+ decorator_list=[],
183
+ returns=None,
184
+ type_comment=None,
185
+ type_params=[],
186
+ )
187
+ module = ast.Module(body=[wrapper], type_ignores=[])
188
+ ast.fix_missing_locations(module)
189
+ return compile(module, filename, "exec")
190
+
191
+
192
+ def _looks_like_magic(program: str) -> bool:
193
+ return program.lstrip().startswith(MAGIC_PREFIXES)
ph_runtime/channel.py ADDED
@@ -0,0 +1,89 @@
1
+ """Newline-delimited JSON over one duplex descriptor.
2
+
3
+ `json.dumps` never emits a literal newline (it escapes them inside strings), so
4
+ a line is exactly a frame and no length prefix or escaping layer is needed.
5
+
6
+ The read limit is generous because two frames are legitimately large: a program
7
+ the model wrote, and a `snapshot` carrying `dill` payloads. It is a cap on one
8
+ line, not an allocation.
9
+
10
+ @module ph_runtime.channel
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import socket
20
+ from typing import Any
21
+
22
+ from .protocol import FD_ENV, PROTOCOL_FD
23
+
24
+ __all__ = ["MAX_FRAME_BYTES", "Channel"]
25
+
26
+ MAX_FRAME_BYTES = 64 * 1024 * 1024
27
+
28
+
29
+ class Channel:
30
+ """The framed channel, and the only way out of this process to the host."""
31
+
32
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
33
+ self._reader = reader
34
+ self._writer = writer
35
+
36
+ @classmethod
37
+ async def open(cls, fd: int | None = None) -> Channel:
38
+ """Attach to the inherited descriptor.
39
+
40
+ Wrapped in a `socket` object rather than opened as a file because the
41
+ host hands over one end of a `socketpair`: a pipe would be one-way, and
42
+ the guest has to both answer the host and call it.
43
+ """
44
+ if fd is None:
45
+ fd = int(os.environ.get(FD_ENV, PROTOCOL_FD))
46
+ sock = socket.socket(fileno=fd)
47
+ sock.setblocking(False)
48
+ reader, writer = await asyncio.open_connection(sock=sock, limit=MAX_FRAME_BYTES)
49
+ return cls(reader, writer)
50
+
51
+ async def receive(self) -> dict[str, Any] | None:
52
+ """The next frame, or `None` when the host has gone.
53
+
54
+ A line that will not parse is skipped rather than fatal: the host is
55
+ trusted for *content*, but a truncated write at shutdown should end the
56
+ session quietly, not with a traceback into the log.
57
+ """
58
+ while True:
59
+ try:
60
+ line = await self._reader.readline()
61
+ except (asyncio.IncompleteReadError, ConnectionResetError, ValueError, OSError):
62
+ return None
63
+ if not line:
64
+ return None
65
+ text = line.strip()
66
+ if not text:
67
+ continue
68
+ try:
69
+ frame = json.loads(text)
70
+ except ValueError:
71
+ continue
72
+ if isinstance(frame, dict):
73
+ return frame
74
+
75
+ def send(self, frame: dict[str, Any]) -> None:
76
+ """Queue one frame. Synchronous, so `print` inside a cell can call it."""
77
+ # A dead host is not this process's problem to report: the
78
+ # die-with-parent mechanism is what ends the guest (F3).
79
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError, RuntimeError):
80
+ self._writer.write(json.dumps(frame, default=repr).encode("utf-8") + b"\n")
81
+
82
+ async def drain(self) -> None:
83
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError, RuntimeError):
84
+ await self._writer.drain()
85
+
86
+ async def aclose(self) -> None:
87
+ await self.drain()
88
+ with contextlib.suppress(RuntimeError):
89
+ self._writer.close()
ph_runtime/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """The two failures a cell can see from a governed call, and the line between them.
2
+
3
+ `ToolFailed` is the program's to handle — a timeout, a bad argument, a file that
4
+ was not there. Catching it and trying something else is exactly right.
5
+
6
+ `RunStopped` is not. It derives from `BaseException` so that `except Exception`
7
+ does not swallow it, and the reason is C3: a program that can catch a refusal can
8
+ route around it — retry with a different path, fall back to `subprocess`. The
9
+ refusal ends the run, the model sees it in context, and partial state is bounded
10
+ to one cell. A budget (C4) ends the run for the same reason: governance is per
11
+ call, but attention is per turn.
12
+
13
+ @module ph_runtime.errors
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __all__ = ["RunStopped", "ToolFailed"]
19
+
20
+
21
+ class ToolFailed(Exception):
22
+ """A governed call failed. Yours to handle."""
23
+
24
+ def __init__(self, name: str, message: str) -> None:
25
+ super().__init__(f"{name} failed: {message}")
26
+ self.tool_name = name
27
+
28
+
29
+ class RunStopped(BaseException):
30
+ """The run is over — refused, or out of budget. Not yours to handle.
31
+
32
+ `BaseException` so `except Exception` does not swallow it — but note where
33
+ the *enforcement* is: a cell can still write `except BaseException`, so what
34
+ actually ends a refused run is the host firing its abort ladder (C3). This
35
+ class is the courtesy that lets a well-behaved cell unwind with a readable
36
+ message first.
37
+ """
@@ -0,0 +1,89 @@
1
+ """Dying with the parent, per platform (F3).
2
+
3
+ The OS does not do what one would hope. A parent's death does **not** kill its
4
+ children: POSIX re-parents them to PID 1, and `atexit` never runs under
5
+ `SIGKILL` — so a host that is hard-killed leaves a Python process holding the
6
+ model's namespace and whatever it was doing. Each platform needs its own
7
+ mechanism, and only one of the three lives in the guest's gift:
8
+
9
+ * **Linux** — `prctl(PR_SET_PDEATHSIG, SIGKILL)`, set here because it is a
10
+ property of *this* process. It is armed relative to the parent that was
11
+ current when it was set.
12
+ * **macOS** — no equivalent exists, so a daemon thread watches `os.getppid()`
13
+ and `os._exit`s when it changes. This is what prime-agent's fork-server does,
14
+ for this reason.
15
+ * **Windows** — the host's job: a Job Object with `KILL_ON_JOB_CLOSE`. Nothing
16
+ to do here, and it is the tidiest of the three.
17
+
18
+ @module ph_runtime.lifecycle
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ctypes
24
+ import os
25
+ import signal
26
+ import sys
27
+ import threading
28
+ import time
29
+
30
+ __all__ = ["POLL_SECONDS", "die_with_parent"]
31
+
32
+ POLL_SECONDS = 1.0
33
+ _PR_SET_PDEATHSIG = 1
34
+
35
+
36
+ def die_with_parent() -> str:
37
+ """Arrange to not outlive the host. Returns the mechanism that took effect.
38
+
39
+ **There is deliberately no "am I already an orphan" check here**, and removing
40
+ one is what let the guest run confined at all.
41
+
42
+ It read `os.getppid() == 1` and `os._exit(0)`, guarding the window between the
43
+ fork and `prctl`: a host that died in it would never send the signal. The guard
44
+ was unnecessary and, under a PID namespace, always wrong.
45
+
46
+ *Unnecessary*, because of where this is called from. `_serve` reads the boot
47
+ frame **before** calling this, and returns on `None` — so reaching this line
48
+ means a frame was just read from the host, which is proof it was alive after
49
+ the spawn. A host that dies after that closes the socket, `Channel.receive`
50
+ returns `None`, and `serve` returns; that is the same mechanism relied on for
51
+ every later death, and `channel.send`'s own comment already points at it.
52
+
53
+ *Wrong*, because inside `bwrap --unshare-pid` this process's parent **is** PID
54
+ 1 — the sandbox's init — which is the healthy arrangement rather than evidence
55
+ of an orphan. So the check fired on every confined start and the guest exited
56
+ silently, with no stderr, before sending `boot-ack`; the host could only report
57
+ that the runtime "exited before reporting ready". Dying with the host is the
58
+ sandbox's job there and it is already arranged: `bwrap` holds
59
+ `--die-with-parent` against the host, and the kernel tears down every process
60
+ in the namespace when its init goes.
61
+ """
62
+ if sys.platform.startswith("linux"):
63
+ if _set_pdeathsig():
64
+ return "pdeathsig"
65
+ if sys.platform == "win32": # pragma: no cover — the host owns the Job Object
66
+ return "job-object"
67
+ _watch_parent()
68
+ return "getppid-poll"
69
+
70
+
71
+ def _set_pdeathsig() -> bool:
72
+ try:
73
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
74
+ applied: int = libc.prctl(_PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0)
75
+ except (OSError, AttributeError): # pragma: no cover
76
+ return False
77
+ return applied == 0
78
+
79
+
80
+ def _watch_parent() -> None:
81
+ original = os.getppid()
82
+
83
+ def watch() -> None: # pragma: no cover — timing-dependent
84
+ while True:
85
+ time.sleep(POLL_SECONDS)
86
+ if os.getppid() != original:
87
+ os._exit(0)
88
+
89
+ threading.Thread(target=watch, name="ph-parent-watch", daemon=True).start()
ph_runtime/limits.py ADDED
@@ -0,0 +1,103 @@
1
+ """Resource limits, applied in the child before it reports ready (D3).
2
+
3
+ Two of the three are straightforward. The CPU limit is not, and the reason is
4
+ worth stating because it changes what the number *means*:
5
+
6
+ **`RLIMIT_CPU` is cumulative over the process, and this process is persistent.**
7
+ Setting it once to `cpu_seconds` would give the whole kernel one budget for its
8
+ whole life — so the fortieth cell in a session would die on a limit the first
9
+ cell nearly spent. Re-arming it at each run, from the CPU already consumed,
10
+ turns the cumulative counter into a per-cell budget, which is what the caller
11
+ means by `cpu_seconds` and what the gate tests.
12
+
13
+ Exceeding it raises `CpuBudgetExceeded`, which derives from `BaseException` on
14
+ purpose: like a denial (C3), a budget is not the program's to catch. A cell that
15
+ could `except Exception` its way past the limit would make the limit advisory.
16
+
17
+ @module ph_runtime.limits
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import math
24
+ import signal
25
+ from typing import Any
26
+
27
+ __all__ = ["CpuBudgetExceeded", "apply_limits", "arm_cpu_budget", "cpu_seconds_used"]
28
+
29
+
30
+ class CpuBudgetExceeded(BaseException):
31
+ """The cell used its CPU budget. Not an `Exception`: not catchable by policy."""
32
+
33
+
34
+ try:
35
+ import resource
36
+ except ImportError: # pragma: no cover — Windows has no `resource` module
37
+ resource = None # type: ignore[assignment]
38
+
39
+
40
+ def cpu_seconds_used() -> float:
41
+ """CPU seconds this process has consumed, user + system."""
42
+ if resource is None: # pragma: no cover
43
+ import time
44
+
45
+ return time.process_time()
46
+ usage = resource.getrusage(resource.RUSAGE_SELF)
47
+ return float(usage.ru_utime + usage.ru_stime)
48
+
49
+
50
+ def apply_limits(*, address_space_bytes: int) -> dict[str, Any]:
51
+ """Apply the process-lifetime limits and report what took effect.
52
+
53
+ Reported rather than assumed: a hard limit lower than the request cannot be
54
+ raised back, so the host logs the number in force instead of the number it
55
+ asked for.
56
+ """
57
+ applied: dict[str, Any] = {"addressSpaceBytes": None, "cpu": "per-run"}
58
+ if resource is None: # pragma: no cover — Windows uses a Job Object instead
59
+ applied["addressSpaceBytes"] = "job-object"
60
+ return applied
61
+ if address_space_bytes > 0:
62
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
63
+ target = (
64
+ address_space_bytes
65
+ if hard == resource.RLIM_INFINITY
66
+ else min(address_space_bytes, hard)
67
+ )
68
+ try:
69
+ resource.setrlimit(resource.RLIMIT_AS, (target, hard))
70
+ applied["addressSpaceBytes"] = target
71
+ except (ValueError, OSError):
72
+ # The limit is *not* in force, and the report must say so in a number
73
+ # the host can read. macOS refuses `RLIMIT_AS` outright (`ValueError:
74
+ # current limit exceeds maximum limit`), and this branch used to report
75
+ # `soft` — which there is `RLIM_INFINITY`, 2**63-1, above the codec's
76
+ # lossless-integer bound. The host dropped the whole `boot-ack` as
77
+ # unreadable and waited out `boot_timeout` on every kernel start
78
+ # (measured 2026-09-07: 60 s of silence, then "did not report ready").
79
+ # `None` is what the report already means by "no limit"; a finite soft
80
+ # limit that was already in force is still the number in force.
81
+ applied["addressSpaceBytes"] = None if soft == resource.RLIM_INFINITY else soft
82
+ return applied
83
+
84
+
85
+ def _on_sigxcpu(_signum: int, _frame: object) -> None:
86
+ raise CpuBudgetExceeded("this cell used its CPU budget")
87
+
88
+
89
+ def arm_cpu_budget(cpu_seconds: int) -> None:
90
+ """Give the *next* run `cpu_seconds` of CPU, from whatever is spent so far."""
91
+ if resource is None or cpu_seconds <= 0: # pragma: no cover
92
+ return
93
+ signal.signal(signal.SIGXCPU, _on_sigxcpu)
94
+ # `ceil`, not `int`: flooring the CPU already spent hands the next cell less
95
+ # than `cpu_seconds` — a bomb that burned 1.9s floors to 1, so a budget of 1
96
+ # leaves 0.1s and the *next* trivial cell dies on the previous one's spend.
97
+ used = math.ceil(cpu_seconds_used())
98
+ soft = used + cpu_seconds
99
+ _, hard = resource.getrlimit(resource.RLIMIT_CPU)
100
+ if hard != resource.RLIM_INFINITY:
101
+ soft = min(soft, hard)
102
+ with contextlib.suppress(ValueError, OSError): # pragma: no cover
103
+ resource.setrlimit(resource.RLIMIT_CPU, (soft, hard))