diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 7df8a846c8..39e4643d5a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md -2026-07-31-code-runtime-python-settlement-fixes.md: fbe5b1587400a3f4181ec4d6a8062ec65d128719 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: f57c33133824ee6bd51e093a897cef998a9ea82f +2026-07-31-code-runtime-python-settlement-fixes.md: d4415f1458eea1de8e0e9a02f24ca838f31bf392 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: ca50aadd6874ed62a4b57eea145b01d55dfe1bc0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index fbe5b15874..d4415f1458 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md @@ -74,7 +74,7 @@ One residual write-path copy is fixed alongside, independent of the config gate: ### The completion value and error are pre-encoded at their validation point -In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py), `_done_with_value` now returns the whole terminal frame as a PRE-ENCODED JSON string on the success path: the admitted value is serialized once here, at the validation point inside `_run`'s `try`, as `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`. The program can keep mutating a returned list/dict from a daemon thread or signal handler after it returns, so a second traversal held at a later point would be a TOCTOU — a concurrent mutation into a non-JSON type would let that later encode throw outside the settlement handler and downgrade a settled run host-side to `worker-exit`. Serializing once, inside the `try` that wraps this call, closes the window: if a concurrent mutation makes the encode throw, the exception handler classifies it as an `exception`, and once the string is produced the frame is written verbatim with no further touching of the live value. `_run` binds the `_done_with_value` ENTRY NAME into a local (`done_with_value_bound`) before the program runs, and `_done_with_value` itself binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments — so a `__main__` rebind of the entry name or those two names after model execution cannot rewrite a legitimate success into an `exception`. The log ledgers (host `logBudget` and child `_remaining`) start ONE byte below the budget, reserving the serialized outer-array envelope (two brackets and n-1 commas over n entries' separators), so a result that exactly exhausts the ledger still serializes within the configured cap; the truncation marker remains envelope, not payload. The constructor rejects a `maxLogBytes` below 62 (the smallest budget that can serialize its own marker plus the envelope); `maxValueBytes` keeps only the positive-integer requirement, since a completion can be a single byte and the done-frame envelope is seam protocol cost. A rebind of a transitive dep the encoder reaches (e.g. `_dump_scalar`/`_dump_string`/`json`/`io` — a non-exhaustive set) can still, which is registered as an accepted residual in the package README. +In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py), `_done_with_value` now returns the whole terminal frame as a PRE-ENCODED JSON string on the success path: the admitted value is serialized once here, at the validation point inside `_run`'s `try`, as `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`. The program can keep mutating a returned list/dict from a daemon thread or signal handler after it returns, so a second traversal held at a later point would be a TOCTOU — a concurrent mutation into a non-JSON type would let that later encode throw outside the settlement handler and downgrade a settled run host-side to `worker-exit`. Serializing once, inside the `try` that wraps this call, closes the window: if a concurrent mutation makes the encode throw, the exception handler classifies it as an `exception`, and once the string is produced the frame is written verbatim with no further touching of the live value. `_run` binds the `_done_with_value` ENTRY NAME into a local (`done_with_value_bound`) before the program runs, and `_done_with_value` itself binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments — so a `__main__` rebind of the entry name or those two names after model execution cannot rewrite a legitimate success into an `exception`. The log ledgers (host `logBudget` and child `_remaining`) start ONE byte below the budget, reserving the serialized outer-array envelope (two brackets and n-1 commas over n entries' separators), so a result that exactly exhausts the ledger still serializes within the configured cap; the truncation marker remains envelope, not payload. The constructor rejects a `maxLogBytes` below 64 (the smallest budget with one byte of room for the truncation marker's own serialized form); `maxValueBytes` keeps only the positive-integer requirement, since a completion can be a single byte and the done-frame envelope is seam protocol cost. The marker remains envelope, so a truncated run with admitted entries serializes to at most `maxLogBytes + marker + envelope` (recorded in the package README). A rebind of a transitive dep the encoder reaches (e.g. `_dump_scalar`/`_dump_string`/`json`/`io` — a non-exhaustive set) can still, which is registered as an accepted residual in the package README. `send_done` (a local function inside `_run`) writes the pre-encoded string through a BOUND `channel.write_encoded`, and encodes a dict error frame through a bound `_encode_json_plain` before writing it — it never calls `channel.send_sync`, whose body re-resolves `self.write_encoded` and the module-level `_encode_json_plain` at call time. `_encode_json_plain` and `channel.write_encoded` are bound into locals before the program runs, for the same reason `flush_out`/`flush_err`/`safe_model_traceback` are: the program runs as `__main__`, so `import __main__; __main__.ProtocolChannel.send_sync = boom` or `__main__._encode_json_plain = boom` would otherwise re-resolve the send/encode to a rebranded callable at call time and, when that replacement raises, skip the `done` frame and downgrade a settled verdict to a host-side `worker-exit`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index f57c331338..ca50aadd68 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md @@ -74,7 +74,7 @@ Status: implemented ### 完成值与错误在其校验点处预编码 -在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,`_done_with_value` 现在会在成功路径上把整个终止帧作为一个已预编码的 JSON 字符串返回:被准入的值在这里、在 `_run` 的 `try` 之内的校验点处恰好序列化一次,即 `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`。程序在返回之后仍可能从 daemon 线程或信号处理器继续变异它返回的 list/dict,因此在一个更晚的点上做第二次遍历会构成一次 TOCTOU——如果一次变异让一个并发变异的后续编码在结算处理器之外抛出,就会把一次已结算的运行在宿主侧降级成 `worker-exit`。在这里、在包裹该调用的 `try` 之内恰好序列化一次,就关上了这个窗口:如果一次并发变异导致编码抛出,异常处理器会把它如实分类为 `exception`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_run` 在程序运行前把 `_done_with_value` 的入口名绑成局部(`done_with_value_bound`),而 `_done_with_value` 自身把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数——因此模型执行后对入口名或这两个名字的 `__main__` 重绑无法把一个合法成功改写为 `exception`;但编码器到达的一个传递依赖(例如 `_dump_scalar`/`_dump_string`/`json`/`io`——非穷举清单)重绑仍可,这在包 README 中被登记为已接受残余。 +在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,`_done_with_value` 现在会在成功路径上把整个终止帧作为一个已预编码的 JSON 字符串返回:被准入的值在这里、在 `_run` 的 `try` 之内的校验点处恰好序列化一次,即 `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`。程序在返回之后仍可能从 daemon 线程或信号处理器继续变异它返回的 list/dict,因此在一个更晚的点上做第二次遍历会构成一次 TOCTOU——如果一次变异让一个并发变异的后续编码在结算处理器之外抛出,就会把一次已结算的运行在宿主侧降级成 `worker-exit`。在这里、在包裹该调用的 `try` 之内恰好序列化一次,就关上了这个窗口:如果一次并发变异导致编码抛出,异常处理器会把它如实分类为 `exception`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_run` 在程序运行前把 `_done_with_value` 的入口名绑成局部(`done_with_value_bound`),而 `_done_with_value` 自身把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数——因此模型执行后对入口名或这两个名字的 `__main__` 重绑无法把一个合法成功改写为 `exception`。日志账本(宿主 `logBudget` 与子进程 `_remaining`)从预算低 1 字节起算,预留序列化外层数组的外壳(两条括号与 n-1 个逗号,覆盖 n 条目的分隔符),因此恰好耗尽账本的结果序列化后仍在配置上限之内。构造器拒绝低于 64 的 `maxLogBytes`(能为截断标记自身序列化形式留出一字节余量的最小预算);`maxValueBytes` 只保留正整数要求,因为完成值可以只有一字节、且 done 帧外壳是 seam 协议成本。标记仍是 envelope,因此带已放行条目的截断运行序列化后至多为 `maxLogBytes + marker + envelope`(已记录在包 README)。但编码器到达的一个传递依赖(例如 `_dump_scalar`/`_dump_string`/`json`/`io`——非穷举清单)重绑仍可,这在包 README 中被登记为已接受残余。 `send_done`(`_run` 内部的一个局部函数)通过绑定的 `channel.write_encoded` 写出已预编码的字符串,并在写之前用绑定的 `_encode_json_plain` 编码一个 dict 错误帧——它绝不经 `channel.send_sync`,因为后者的函数体会在调用时刻重新解析 `self.write_encoded` 和模块级的 `_encode_json_plain`。`_encode_json_plain` 与 `channel.write_encoded` 在程序运行前就被绑定进局部变量,理由与 `flush_out`/`flush_err`/`safe_model_traceback` 被绑定相同:程序以 `__main__` 运行,因此 `import __main__; __main__.ProtocolChannel.send_sync = boom` 或 `__main__._encode_json_plain = boom` 本会在调用时刻把发送/编码重新解析成被替换的可调用对象,当该替换抛出时跳过 `done` 帧、把已结算的结论降级成宿主侧的 `worker-exit`。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 21b25bde88..28bd40823e 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-python/README.md -README.md: 18c784394705e7fc10f956bcbf7e576d6a0b4c83 -README.zh.md: 64424b7d2de8b96452469c3e65e47ed3d9a91adb +README.md: 25211993e1ebac7b940d4f373391819c15cab5c0 +README.zh.md: ca260b8c8d543545ce927f2c48e4676b34622786 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 18c7843947..25211993e1 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) CPython-subprocess implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam. Companion to [`@deepseek-ai/dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md); trades the Node worker thread for a fresh `python3` subprocess so model code is Python instead of TypeScript. -The package owns the wire protocol for that seam: the host-side frame codec and the Python-side mirror of the same message vocabulary. On top of that protocol it ships `PythonCodeRuntime` (the plugin's default export), which registers as `codeRuntime` with `language: 'python'` and `isolation: 'process'`. Each `run()` spawns a fresh `python3 -I` process, sends a boot frame and the program over fd 3, and resolves a `CodeRunResult` for every program outcome — `run()` rejects only for seam misuse, such as a malformed binding namespace or a call on a runtime whose fiber was already disposed. Configuration is rejected earlier, when the plugin loads: a non-Unix platform, a non-positive or non-integer budget, a timer value `setTimeout` would clamp, a budget larger than one fd-3 frame can carry, and an `addressSpaceMb`/output-budget pair whose worst-case peak would breach `RLIMIT_AS` all throw from the constructor, so a misconfiguration fails at assembly rather than on a later run. The child runs the program as the body of an async function, so top-level `await` and `return` both work; binding calls travel back over fd 3 as JSON-lines. Containment (not a security boundary — model code has bash-equivalent trust) comes from an empty environment, `RLIMIT_CPU`/`RLIMIT_AS`, a wall-clock ceiling, and a `SIGTERM`→grace→`SIGKILL` teardown on the child's process group. +The package owns the wire protocol for that seam: the host-side frame codec and the Python-side mirror of the same message vocabulary. On top of that protocol it ships `PythonCodeRuntime` (the plugin's default export), which registers as `codeRuntime` with `language: 'python'` and `isolation: 'process'`. Each `run()` spawns a fresh `python3 -I` process, sends a boot frame and the program over fd 3, and resolves a `CodeRunResult` for every program outcome — `run()` rejects only for seam misuse, such as a malformed binding namespace or a call on a runtime whose fiber was already disposed. Configuration is rejected earlier, when the plugin loads: a non-Unix platform, a non-positive or non-integer budget, a `maxLogBytes` below the truncation-marker floor (64), a timer value `setTimeout` would clamp, a budget larger than one fd-3 frame can carry, and an `addressSpaceMb`/output-budget pair whose worst-case peak would breach `RLIMIT_AS` all throw from the constructor, so a misconfiguration fails at assembly rather than on a later run. The child runs the program as the body of an async function, so top-level `await` and `return` both work; binding calls travel back over fd 3 as JSON-lines. Containment (not a security boundary — model code has bash-equivalent trust) comes from an empty environment, `RLIMIT_CPU`/`RLIMIT_AS`, a wall-clock ceiling, and a `SIGTERM`→grace→`SIGKILL` teardown on the child's process group. ## Wire protocol @@ -37,6 +37,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **The cross-language guard covers executed values and frame field sets, not field types** — `tests/protocol-mirror.e2e.ts` compares `PROTOCOL_FD`, the log truncation marker, and each `TypedDict`'s required and optional fields against a real `python3`. Comparing field types across TypeScript and Python has no mechanical equivalent here, so review plus the backend's real-subprocess suite owns type-level drift. - **`RLIMIT_AS` is not enforced on macOS** — the dyld shared cache mapped into every process at exec exceeds any practical address-space cap, and the kernel rejects the `setrlimit` call, so `addressSpaceMb` is skipped there. `cpuSeconds` and `maxWallMs` still bound every run. - **PID-reuse protection is inert on macOS** — `readProcessStart` reads `/proc//stat`, which Darwin does not provide, so the identity re-check that guards `killGroup` against signalling a recycled pgid always passes there; the guard degrades to the pre-existing behavior rather than paying a `ps` fork on a teardown path. The process-group teardown and the `closeDeadline` bound still contain the run. +- **A truncated log's serialized array runs to `maxLogBytes` plus the marker.** The truncation marker is envelope, not payload — it rides uncharged so it can always be emitted — and the outer-array envelope is reserved one byte in the ledger. A truncated run with admitted entries therefore serializes its `logs` array to at most `maxLogBytes + marker + 1`; the marker alone fits any admissible budget (the 64-byte floor guarantees it). - **A descendant that calls `setsid()` / `start_new_session=True` escapes teardown.** Termination signals the child's process group with `kill(-pid)`; a descendant that moves itself into a fresh session is no longer in that group and no signal reaches it. If it also releases the inherited stdout/stderr/fd-3 pipes, the leader's `close` still settles the run, and after the `closeDeadline` bound the fiber goes quiescent while that orphan keeps running. This is the containment boundary, not a security one — model code has bash-equivalent trust, and a bash tool can `setsid` away just the same. Reaching such an orphan would require tracking every descendant pid (as the bash-local backend's process-inspector does) and is deferred; the process-group teardown reaps everything that stays in the group. - **A combined log-and-value peak is not modelled by the load gate.** Each budget is checked against `addressSpaceMb` on its own. A model daemon thread that keeps writing while the completion value is metered and framed can refill the log pending toward `maxLogBytes` during that window, so the two peaks add in a way no gate admits or rejects. A gate over `(maxLogBytes + maxValueBytes)` was considered and deferred: its discriminating case cannot be scheduled deterministically under `RLIMIT_AS`, so the gate would only prove its own arithmetic. When the combined peak is reached the run dies as `worker-exit` -- containment holds and only the failure classification is degraded. - **A 1-second dual-limit `ulimit -t 1` CPU overrun is reported as `worker-exit`, not a timeout.** When the host starts under a hard CPU limit equal to the soft (`ulimit -t N` sets both) and that limit is 1, `_clamped` cannot lower the soft to 0, so the kernel SIGKILLs the busy loop in the same tick and SIGXCPU is never delivered. The host classifies a CPU overrun only on `signal === 'SIGXCPU'`, so the overrun is reported as `worker-exit`. For a dual limit of 2 or more the soft is lowered by one unit, SIGXCPU fires, and the run is a timeout. Containment holds in both cases; only the classification is degraded. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 64424b7d2d..ca260b8c8d 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.zh.md) seam 的 CPython 子进程实现。与 [`@deepseek-ai/dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.zh.md) 配套;以全新的 `python3` 子进程取代 Node worker 线程,让模型代码从 TypeScript 换成 Python。 -本包持有该 seam 的 wire protocol:host 侧的帧编解码,以及 Python 侧对同一套消息词汇的镜像。在该协议之上,本包交付 `PythonCodeRuntime`(插件的默认导出),它以 `language: 'python'`、`isolation: 'process'` 注册为 `codeRuntime`。每次 `run()` 启动一个全新的 `python3 -I` 进程,通过 fd 3 发送 boot 帧和程序,并为每个程序结果 resolve 一个 `CodeRunResult`——`run()` 仅在 seam 被误用时才 reject,例如 binding 命名空间不合法,或对 fiber 已被 dispose 的 runtime 发起调用。配置错误在更早的插件加载期被拒绝:非 Unix 平台、非正或非整数的预算、会被 `setTimeout` 截断的定时器值、超过单个 fd-3 帧承载能力的预算,以及最坏峰值会突破 `RLIMIT_AS` 的 `addressSpaceMb`/输出预算组合,都从构造器抛出,因此配置错误在装配时就失败,而不是等到之后某次运行。子进程把程序作为 async 函数体运行,因此顶层 `await` 与 `return` 都可用;binding 调用经 fd 3 以 JSON-lines 回传。containment 不是安全边界——模型代码具有等同 bash 的信任级别;空环境、`RLIMIT_CPU`/`RLIMIT_AS`、墙钟上限与对子进程进程组的 `SIGTERM`→grace→`SIGKILL` 拆卸共同提供 containment。 +本包持有该 seam 的 wire protocol:host 侧的帧编解码,以及 Python 侧对同一套消息词汇的镜像。在该协议之上,本包交付 `PythonCodeRuntime`(插件的默认导出),它以 `language: 'python'`、`isolation: 'process'` 注册为 `codeRuntime`。每次 `run()` 启动一个全新的 `python3 -I` 进程,通过 fd 3 发送 boot 帧和程序,并为每个程序结果 resolve 一个 `CodeRunResult`——`run()` 仅在 seam 被误用时才 reject,例如 binding 命名空间不合法,或对 fiber 已被 dispose 的 runtime 发起调用。配置错误在更早的插件加载期被拒绝:非 Unix 平台、非正或非整数的预算、低于截断标记下限(64)的 `maxLogBytes`、会被 `setTimeout` 截断的定时器值、超过单个 fd-3 帧承载能力的预算,以及最坏峰值会突破 `RLIMIT_AS` 的 `addressSpaceMb`/输出预算组合,都从构造器抛出,因此配置错误在装配时就失败,而不是等到之后某次运行。子进程把程序作为 async 函数体运行,因此顶层 `await` 与 `return` 都可用;binding 调用经 fd 3 以 JSON-lines 回传。containment 不是安全边界——模型代码具有等同 bash 的信任级别;空环境、`RLIMIT_CPU`/`RLIMIT_AS`、墙钟上限与对子进程进程组的 `SIGTERM`→grace→`SIGKILL` 拆卸共同提供 containment。 ## Wire protocol @@ -37,6 +37,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **跨语言 guard 覆盖执行值与帧字段集,但不覆盖字段类型** —— `tests/protocol-mirror.e2e.ts` 使用真实 `python3` 比较 `PROTOCOL_FD`、日志截断标记,以及每个 `TypedDict` 的必填和可选字段。跨 TypeScript 与 Python 比较字段类型在此没有机械等价物,因此类型级漂移由 review 加后端真子进程套件负责。 - **`RLIMIT_AS` 在 macOS 上不施加** —— 在 exec 时映射进每个进程的 dyld 共享缓存超过任何实际的地址空间上限,内核会拒绝该 `setrlimit` 调用,故 `addressSpaceMb` 在那里被跳过。`cpuSeconds` 与 `maxWallMs` 仍约束每一次运行。 - **PID 复用防护在 macOS 上失效** —— `readProcessStart` 读取 `/proc//stat`,Darwin 不提供它,因此防止 `killGroup` 对已回收的 pgid 发信号的同一性复检在那里恒通过;该防护退化为既有行为,而非在拆卸路径上付出一次 `ps` fork。进程组拆卸与 `closeDeadline` 上界仍约束该次运行。 +- **截断日志的序列化数组会到 `maxLogBytes` 加标记为止。** 截断标记是 envelope 而非 payload——它不计费地随行,因此总能发出——而外层数组外壳在账本中预留了一字节。因此带已放行条目的截断运行,其 `logs` 数组序列化后至多为 `maxLogBytes + marker + 1`;标记单独能放进任何可接受的预算(64 字节下限保证这一点)。 - **调用 `setsid()` / `start_new_session=True` 的后代会逃出 teardown。** 终止是用 `kill(-pid)` 向子进程的进程组发信号;一个把自己移入新会话的后代已不在该进程组内,任何信号都到不了它。若它同时释放了继承而来的 stdout/stderr/fd-3 管道,leader 的 `close` 仍会结算该次运行,在 `closeDeadline` 到界之后 fiber 变为完全停稳,而那个孤儿仍在运行。这是 containment 边界,而非安全边界——模型代码具有等同 bash 的信任级别,一个 bash 工具同样能 `setsid` 逃逸。要够到这样的孤儿需要追踪每一个后代 pid(如 bash-local 后端的 process-inspector 所做),此项已推迟;进程组 teardown 会回收所有留在组内的进程。 - **日志与完成值的叠加峰值未被加载门建模。** 每项预算都是各自对照 `addressSpaceMb` 检查的。模型的 daemon 线程可以在完成值被计量并分帧的窗口内持续写入、把日志 pending 重填到接近 `maxLogBytes`,于是两个峰值以任何门都不曾放行也不曾拒绝的方式相加。对 `(maxLogBytes + maxValueBytes)` 设门的方案经评估后推迟:它的判别用例无法在 `RLIMIT_AS` 之下确定性地构造出来,因此该门只能证明自己的算术。叠加峰值被触及时该次运行死为 `worker-exit`——containment 仍然成立,只是失败分类失真。 - **1 秒双限 `ulimit -t 1` 下的 CPU 超限会被报告为 `worker-exit`,而非超时。** 当宿主在一个硬 CPU 限制等于软限制(`ulimit -t N` 同时设置两者)且该限制为 1 的环境下启动时,`_clamped` 无法把软限制降到 0,因此内核在同一 tick 直接 SIGKILL 忙循环,SIGXCPU 永不送达。宿主只在 `signal === 'SIGXCPU'` 时把 CPU 超限分类为超时,因此该超限被报告为 `worker-exit`。当双限为 2 或更大时,软限制会被降低一个单位,SIGXCPU 触发,该次运行成为超时。两种情况 containment 都成立;只是分类被降级。 diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 0808e4df6b..17a45f1203 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -227,18 +227,22 @@ const MAX_PENDING_CHUNKS = 1024 const FRAME_ENVELOPE_BYTES = 64 /** - * Smallest `maxLogBytes` the backend can honor. The log ledger's truncation - * marker (`logTruncationMarker`) plus the serialized outer-array envelope must - * fit the budget, or a truncated run returns more than the configured cap: the - * marker text is `[dsh-code-runtime-python] log capture truncated at - * bytes` — 49 fixed characters plus the digits of N plus 6 — serialized with - * quotes and brackets adds 4, so the smallest N that admits its own marker is - * 61 (49 + 2 + 6 + 4); 62 is the floor with one byte of room. `maxValueBytes` - * has no floor beyond the positive-integer requirement: a completion can be as - * small as a single byte (`1`), and the done-frame envelope is seam protocol - * cost, not the advertised completion budget. + * Smallest `maxLogBytes` the backend can honor. The truncation marker alone + * (`logTruncationMarker`) must serialize within the budget, or a marker-only + * truncated run returns more than the configured cap: the marker text is + * `[dsh-code-runtime-python] log capture truncated at bytes` — 51 fixed + * characters (the bracketed prefix `[dsh-code-runtime-python] log capture + * truncated at ` counts both square brackets) plus the digits of N plus 6 — + * and its serialized form adds 4 (two quotes, two array brackets), so the + * smallest N that admits its own marker is 63 (51 + 2 + 6 + 4 = 63); 64 is the + * floor with one byte of room. The marker itself remains envelope, not + * payload, so a truncated run with admitted entries serializes to at most + * `maxLogBytes + marker + envelope`; that bound is recorded in the README. + * `maxValueBytes` has no floor beyond the positive-integer requirement: a + * completion can be as small as a single byte (`1`), and the done-frame + * envelope is seam protocol cost, not the advertised completion budget. */ -const MIN_LOG_BYTES = 62 +const MIN_LOG_BYTES = 64 /** * Extra time added to `graceMs` before the post-kill close-deadline force-settles @@ -781,11 +785,14 @@ export class PythonCodeRuntime extends CodeRuntime { if (this.config[key] > limit) { throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the ${FRAME_CEILING_BYTES}-byte fd-3 frame ceiling, so the run would fail as worker-exit rather than output-limit), got ${String(this.config[key])}`) } - // Reject a log budget too small to honor: the ledger must fit its - // truncation marker plus the serialized outer-array envelope, or a - // truncated run returns more than the configured cap. + // Reject a log budget too small to honor: the truncation marker alone + // must serialize within the budget, or a marker-only truncated run + // returns more than the configured cap. (With admitted entries the + // marker is envelope, so the serialized logs run to + // `maxLogBytes + marker + envelope`; that bound is recorded in the + // README's Known Limitations.) if (key === 'maxLogBytes' && this.config[key] < MIN_LOG_BYTES) { - throw new Error(`dsh-code-runtime-python: config.maxLogBytes must be at least ${MIN_LOG_BYTES} (a smaller budget cannot serialize the truncation marker plus the outer-array envelope, so the run would return more than the configured cap), got ${String(this.config[key])}`) + throw new Error(`dsh-code-runtime-python: config.maxLogBytes must be at least ${MIN_LOG_BYTES} (a smaller budget cannot serialize the truncation marker itself, so a marker-only truncated run would return more than the configured cap), got ${String(this.config[key])}`) } } // The child builds, charges, and frames a `maxLogBytes` log entry or a diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index df9137e64d..b490a190a5 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -555,8 +555,8 @@ describe('PythonCodeRuntime — inherited resource limits', () => { 'import signal, time', 'if hasattr(signal, "pthread_sigmask"):', ' signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGXCPU})', - 'end = time.perf_counter() + 1.05', - 'while time.perf_counter() < end:', + 'end = time.process_time() + 1.05', + 'while time.process_time() < end:', ' pass', 'return "escaped"', ].join('\n'), @@ -585,8 +585,8 @@ describe('PythonCodeRuntime — inherited resource limits', () => { ' signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGXCPU})', ' signal.signal(signal.SIGXCPU, h)', ' signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGXCPU})', - ' end = time.perf_counter() + 1.05', - ' while time.perf_counter() < end:', + ' end = time.process_time() + 1.05', + ' while time.process_time() < end:', ' pass', 'return "escaped"', ].join('\n'), @@ -3922,10 +3922,11 @@ describe('PythonCodeRuntime — hostile peer', () => { // the serialized outer logs array adds one more byte of envelope (two // brackets and n-1 commas). The ledgers reserve that byte, so a result that // exactly exhausts the ledger still serializes within the configured cap. - // At the 64-byte floor: ledger 63, a 60-character line serializes as - // `"aaa...a"` (62 bytes) + 1 separator = 63, exactly exhausting the ledger - // and serializing as `["aaa...a"]` = 64 = the cap; a 61-character line - // costs 64 > 63 and truncates to the marker alone. + // At the 64-byte floor (the smallest admissible maxLogBytes): ledger 63, + // a 60-character line serializes as `"aaa...a"` (62 bytes) + 1 separator + // = 63, exactly exhausting the ledger and serializing as `["aaa...a"]` + // = 64 = the cap; a 61-character line costs 64 > 63 and truncates. The + // marker rides envelope, so the serialized logs run to cap + marker. const { runtime } = await setup({ maxLogBytes: 64, maxWallMs: 10_000 }) const result = await runtime.run({ program: ['print("a" * 60 + "\\n" + "b" * 61, end="")', 'return "done"'].join('\n'), @@ -3941,11 +3942,11 @@ describe('PythonCodeRuntime — hostile peer', () => { }, 15_000) it('rejects a log budget too small to serialize the truncation marker', async () => { - // A maxLogBytes below 62 cannot serialize the truncation marker plus the - // outer-array envelope; it is rejected at construction so a tiny config - // cannot report more than the public cap. maxValueBytes keeps no floor - // beyond the positive-integer requirement (a completion can be 1 byte). - await expect(setup({ maxLogBytes: 61, maxWallMs: 10_000 })).rejects.toThrow(/must be at least 62/) + // A maxLogBytes below 64 cannot serialize the truncation marker itself; + // it is rejected at construction so a marker-only truncated run cannot + // report more than the public cap. maxValueBytes keeps no floor beyond the + // positive-integer requirement (a completion can be 1 byte). + await expect(setup({ maxLogBytes: 63, maxWallMs: 10_000 })).rejects.toThrow(/must be at least 64/) }, 15_000) it('charges the JSON-escaped cost of control characters against the log ledger', async () => { @@ -4399,20 +4400,20 @@ describe('PythonCodeRuntime — hostile peer', () => { it('charges a forged log frame its escaped cost once past the code-unit lower bound', async () => { // The cheap lower bound only rejects what cannot possibly fit; a SHORT // control-heavy frame clears it and must still be charged what it costs on - // the wire. Ten NULs are 13 against the 32-byte lower bound but 63 escaped + // the wire. Eleven NULs are 14 against the 64-byte ledger's cheap bound // (six bytes each, two quotes, one separator), so the full charge truncates. - const { runtime } = await setup({ maxLogBytes: 63 }) + const { runtime } = await setup({ maxLogBytes: 64 }) const result = await runtime.run({ program: [ 'import os', - 'os.write(3, b\'{"type":"log","text":"\' + b"\\\\u0000" * 10 + b\'"}\\n\')', + 'os.write(3, b\'{"type":"log","text":"\' + b"\\\\u0000" * 11 + b\'"}\\n\')', 'return "settled"', ].join('\n'), bindings: [], }) expect(result.error).toBeUndefined() expect(result.value).toBe('settled') - expect(result.logs).toEqual([logTruncationMarker(63)]) + expect(result.logs).toEqual([logTruncationMarker(64)]) }, 8000) it('caps a forged done error.message from its code-unit prefix, never encoding the whole message', async () => {