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 8760266b9c..710eabef99 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: 97768a63f3192b90d89cc2b935cb962844e91d04 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: ae2cb4949a2c8c20a1ce12c046d6da11a5f1dcb1 +2026-07-31-code-runtime-python-settlement-fixes.md: c24ae8ae9f8d16b30281ded3b470dcdfc4c9f10a +2026-07-31-code-runtime-python-settlement-fixes.zh.md: 628c367aaca7c03844e0eb5b5b98201f3dee0d1e 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 97768a63f3..c24ae8ae9f 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. `_done_with_value` also binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments, so a `__main__` rebind after model execution cannot rewrite a legitimate success into an `exception`. +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. `_done_with_value` also binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments, so a `__main__` rebind of those two names after model execution cannot rewrite a legitimate success into an `exception`; a rebind of a transitive dep the encoder reaches (`_dump_scalar`, `io`) 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 ae2cb4949a..628c367aac 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`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_done_with_value` 还把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数,因此模型执行后的 `__main__` 重绑无法把一个合法成功改写为 `exception`。 +在 [`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`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_done_with_value` 还把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数,因此模型执行后对这两个名字的 `__main__` 重绑无法把一个合法成功改写为 `exception`;但编码器到达的一个传递依赖(`_dump_scalar`、`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 c262dea53a..3119324e61 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: 180b0eec9bd5644bfb37086b9eb35cbe93d51672 -README.zh.md: 8a1468f56c62cc1b8544a05c4420270aa26dba31 +README.md: f976b050992863cc8586b5415d9c36cfe193229e +README.zh.md: 608ac28ecab35f90d9a9d6582e15a24fa4e78c10 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 180b0eec9b..f976b05099 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -41,4 +41,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **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. - **A program that traps SIGXCPU can exceed the soft CPU limit during settlement encoding and still report success.** The settlement CPU recheck runs before the completion value is flushed and encoded; a program that traps SIGXCPU (soft limit) and keeps burning past it through the build-and-encode window returns a result before `die_if_cpu_exhausted` re-checks, so the run reports success. Containment holds — the hard limit (soft + 1s) and the wall clock still bound it — and only the classification is degraded. The CPU recheck does not run mid-encode because doing so would have to meter the encode itself, and the encode is the path the budget already bounds. - **The encoder's direct dependencies resolve at call time.** `_encode_json_plain` reaches `_dump_scalar`/`_dump_string`/`json` via module-global lookup, so a program running as `__main__` that rebinds one of those names (e.g. `__main__._dump_scalar = boom`) after returning a legitimate value can make the encode throw and downgrade a success to `exception`. The value path's top-level `_check_done_value`/`_encode_json_plain` are bound as def-time defaults, but their transitive deps are not; this is an accepted residual for the same reason the analogous `_dump_*` helpers are not rebound in practice. -- **A wide binding REPLY expands host-side state per member.** Resolutions cross through `snapshotJsonValue` in [`@deepseek-ai/dsh-session`](../../core/session/README.md), whose `walkJsonValue` pushes one task frame per member, and binding resolution carries no seam-level byte cap. A legitimate reply of several million elements can therefore exhaust the host heap. A cross-thread binding that the program joins with a synchronous `t.join()` can also deadlock: the joining thread blocks the main coroutine while the binding's reply still needs the pump to deliver it, so `await` never resumes until the wall clock. The property belongs to that shared walk, not to this backend -- the worker-thread backend consumes the same function -- so the fix belongs in `packages/core/session` where every consumer benefits. +- **A wide binding REPLY expands host-side state per member.** Resolutions cross through `snapshotJsonValue` in [`@deepseek-ai/dsh-session`](../../core/session/README.md), whose `walkJsonValue` pushes one task frame per member, and binding resolution carries no seam-level byte cap. A legitimate reply of several million elements can therefore exhaust the host heap. The property belongs to that shared walk, not to this backend -- the worker-thread backend consumes the same function -- so the fix belongs in `packages/core/session` where every consumer benefits. +- **A cross-thread binding that the program joins with a synchronous `t.join()` can deadlock.** This is specific to the `process` isolation backend: the reply pump runs on the child's main event loop, so a worker thread that joins the main coroutine with `t.join()` blocks the loop the pump needs to deliver the binding's reply, and `await` never resumes until the wall clock. The worker-thread backend does not share this structure, so the fix belongs here, not in `packages/core/session`. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 8a1468f56c..608ac28eca 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -41,4 +41,5 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **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 都成立;只是分类被降级。 - **一个 trap SIGXCPU 的程序可以在结算编码期间超过软 CPU 限制并仍报告成功。** 结算时的 CPU 复查在完成值被 flush 与编码之前运行;一个 trap SIGXCPU(软限制)并在构建与编码窗口内继续燃烧 CPU 的程序会在 `die_if_cpu_exhausted` 复查之前返回结果,因此该次运行报告成功。containment 成立——硬限制(软限制 + 1s)与墙钟仍会约束它——只是分类被降级。CPU 复查不在编码中途运行,因为那必须计量编码本身,而编码正是预算已经约束的路径。 - **编码器的直接依赖在调用时解析。** `_encode_json_plain` 通过模块全局查找到达 `_dump_scalar`/`_dump_string`/`json`,因此以 `__main__` 运行的程序在返回合法值后重绑这些名字之一(例如 `__main__._dump_scalar = boom`)可以让编码抛出、把成功降级为 `exception`。值路径顶层的 `_check_done_value`/`_encode_json_plain` 被绑定为 def 期默认值,但其传递依赖没有;这是已接受的残余,原因与对应的 `_dump_*` 辅助函数在实践中不被重绑相同。 -- **宽 binding 回复会按成员展开宿主侧状态。** 回复经由 [`@deepseek-ai/dsh-session`](../../core/session/README.zh.md) 的 `snapshotJsonValue` 穿越,其 `walkJsonValue` 为每个成员压入一个任务帧,而 binding 回复在 seam 层没有字节上限。因此一个数百万元素的合法回复可以耗尽宿主堆。程序用同步的 `t.join()` 连接一个跨线程 binding 也会死锁:该线程阻塞主协程,而 binding 的回复仍需泵来投递,因此 `await` 直到墙钟才会恢复。该性质属于那个共享遍历,而不属于本后端——worker-thread 后端消费同一个函数——所以修复应落在 `packages/core/session`,让所有消费方一并受益。 +- **宽 binding 回复会按成员展开宿主侧状态。** 回复经由 [`@deepseek-ai/dsh-session`](../../core/session/README.zh.md) 的 `snapshotJsonValue` 穿越,其 `walkJsonValue` 为每个成员压入一个任务帧,而 binding 回复在 seam 层没有字节上限。因此一个数百万元素的合法回复可以耗尽宿主堆。该性质属于那个共享遍历,而不属于本后端——worker-thread 后端消费同一个函数——所以修复应落在 `packages/core/session`,让所有消费方一并受益。 +- **程序用同步的 `t.join()` 连接一个跨线程 binding 会死锁。** 这是 `process` 隔离后端特有的:回复泵运行在子进程的主事件循环上,因此一个用 `t.join()` 阻塞主协程的 worker 线程会卡住泵投递该 binding 回复所需的循环,`await` 直到墙钟才会恢复。worker-thread 后端不共享此结构,所以修复应落在这里,而非 `packages/core/session`。