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 +23 -0
- ph_runtime/__main__.py +10 -0
- ph_runtime/_json.py +41 -0
- ph_runtime/cell.py +193 -0
- ph_runtime/channel.py +89 -0
- ph_runtime/errors.py +37 -0
- ph_runtime/lifecycle.py +89 -0
- ph_runtime/limits.py +103 -0
- ph_runtime/protocol.py +147 -0
- ph_runtime/proxies.py +106 -0
- ph_runtime/py.typed +0 -0
- ph_runtime/runner.py +483 -0
- ph_runtime/skill.py +74 -0
- ph_runtime/snapshot.py +223 -0
- ph_runtime_guest-0.1.0.dist-info/METADATA +205 -0
- ph_runtime_guest-0.1.0.dist-info/RECORD +18 -0
- ph_runtime_guest-0.1.0.dist-info/WHEEL +4 -0
- ph_runtime_guest-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
"""
|
ph_runtime/lifecycle.py
ADDED
|
@@ -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))
|