From c8139589d2d37558fc96f1bb4efde2217e85d452 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 25 Aug 2026 20:47:32 +0800 Subject: [PATCH] fix(code-runtime-python): reject an oversized unframed frame before the join; cap the rejection diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's remaining critical: the fd-3 data handler checked the unframed counter against the 256 MiB wire ceiling, so a single 64-256 MiB frame was fully Buffer.concat-joined (a second copy) and only then dropped in the line loop — the peak-memory doubling the pre-join check exists to prevent, for a frame the parser is guaranteed to discard. The counter is now checked against FRAME_PARSE_CAP_BYTES before the join; the regression case asserts a worker-exit with 'protocol frame exceeded' (fail-before: reverting to the ceiling turns it green, proving the join path). FRAME_CEILING_BYTES is removed. The rejection-cap fix now has its regression: a completion value whose class name is 70 MiB of Ns asserts invalid-output, not worker-exit (fail-before: uncapping the diagnostic turns it red). The settlement note (en + zh) updates the remaining stale bound text, and the fd-3 protocol note (en + zh) no longer claims protocol-only exports or a missing Python codec. Pairings re-recorded. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 +- ...-07-31-code-runtime-python-fd3-protocol.md | 8 ++-- ...-31-code-runtime-python-fd3-protocol.zh.md | 4 +- ...-runtime-python-settlement-fixes.i18n.yaml | 4 +- ...31-code-runtime-python-settlement-fixes.md | 2 +- ...code-runtime-python-settlement-fixes.zh.md | 4 +- .../code-runtime-python/src/index.ts | 44 ++++++++++--------- .../code-runtime-python/tests/runtime.spec.ts | 41 +++++++++++------ 8 files changed, 63 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index cbb0b7a59e..6842a94575 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 6e5d96c5cff0ccdb6ecb1779bc5aa003f0b0881f -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 928be28f15212cb39dff1b215cdfee58da0ac130 +2026-07-31-code-runtime-python-fd3-protocol.md: da9f1fc5010c3975b0a8e20b1dc2dff66f837dcb +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 3b2954ceb276c286b5cafb40c0d9e2c2c7e64403 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 6e5d96c5cf..da9f1fc501 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -20,7 +20,7 @@ The package ships the protocol AND the runtime implementation: `PythonCodeRuntim `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. -The package remains independently buildable with protocol-only exports. `check-workspace-constraints` reads every `packages///package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists. +The package ships the runtime alongside the protocol; it remains independently buildable. `check-workspace-constraints` reads every `packages///package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists. ## Wire contract @@ -32,12 +32,12 @@ Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free f ## Alternatives considered -**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror. No Python codec ships in this package. +**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror; the codec (`_encode_json_plain` / `_decode_json_plain`) lives in `bootstrap.py` with the runtime it serves. **Keep the protocol files outside a buildable package until a runtime ships.** Rejected: the workspace-constraint, coverage, and invariant-topology checks require every directory under `packages//` to be a buildable package, and the protocol has independent tests and a public wire vocabulary. ## Consequences -Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. A future runtime can consume a reviewed wire contract. +Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. The runtime built on it (`bootstrap.py`) consumes the reviewed wire contract. -Cost: the package name denotes a Python runtime family while `src/index.ts` exports only the protocol vocabulary. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility. +Cost: the package name denotes a Python runtime family and `src/index.ts` exports the full `PythonCodeRuntime` implementation, so the protocol vocabulary is only one part of the package surface. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 928be28f15..3b2954ceb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -20,7 +20,7 @@ Status: implemented `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 -该包只导出协议,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages///package.json`,coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。 +该包随协议一起交付 runtime,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages///package.json`,coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。 ## Wire contract @@ -40,4 +40,4 @@ Status: implemented 收获:fd-3 协议及其敌意输入 codec 构成自包含、unit 全覆盖的一层,并由执行中的 guard 防止 TypeScript/Python 字段集漂移。未来 runtime 可以直接消费经过评审的 wire contract。 -代价:包名表示 Python runtime 家族,而 `src/index.ts` 只导出协议 vocabulary。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。 +代价:包名表示 Python runtime 家族,而 `src/index.ts` 导出完整的 `PythonCodeRuntime` 实现,协议 vocabulary 只是包表面的一部分。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。 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 15aaaecff4..1f941a8d74 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: 869c2736dbd3207b92e7ac7362176d4001629389 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: 30bcdb95b46c7c551027e654a807042c9364b55d +2026-07-31-code-runtime-python-settlement-fixes.md: f1fa9d39556fd60e0c35b7911bb798ca701133a5 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: 446ec8069301fe51c7aedb8709626a7e9a7c3b8a 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 869c2736db..f1fa9d3955 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 @@ -26,7 +26,7 @@ Also in `src/index.ts`, after the newline loop over a `Buffer.concat` of the pen ### Output-cap load bound is parse-cap minus envelope, not divided by six -The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges the serialized cost via `jsonStringCostUpTo` (which walks to the cap without allocating the escaped copy), `checkDoneValue` measures the escaped form, and the producing-side `_cap_message` also caps by serialized cost — so a payload admitted under the cap occupies at most `cap + envelope` on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now `FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES`, and the unused `MAX_JSON_ESCAPE_EXPANSION` constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps. The same load check also rejects a NON-INTEGER `maxLogBytes`/`maxValueBytes`: the child reads each budget through `int(...)`, which floors a float, so `maxLogBytes: 3.5` would truncate at 3 bytes child-side while the host meters the fraction — the two sides enforcing different public config. Rejecting the float at load keeps them in step, matching the worker backend. +The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges the serialized cost via `jsonStringCostUpTo` (which walks to the cap without allocating the escaped copy), `checkDoneValue` measures the escaped form, and the producing-side `_cap_message` also caps by serialized cost — so a payload admitted under the cap occupies at most `cap + envelope` on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now `FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES` (the receive path drops raw frames past the 64 MiB parse cap before decoding, so a budget must not exceed what an honest child's frame can carry through that parser), and the unused `MAX_JSON_ESCAPE_EXPANSION` constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps. The same load check also rejects a NON-INTEGER `maxLogBytes`/`maxValueBytes`: the child reads each budget through `int(...)`, which floors a float, so `maxLogBytes: 3.5` would truncate at 3 bytes child-side while the host meters the fraction — the two sides enforcing different public config. Rejecting the float at load keeps them in step, matching the worker backend. ### Same-group survivors are reaped before the fiber goes quiescent 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 30bcdb95b4..446ec80693 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 @@ -26,7 +26,7 @@ Status: implemented ### Output-cap load bound is ceiling minus envelope, not divided by six -那处在加载期拒绝比单个 fd-3 帧所能承载更大的 `maxLogBytes`/`maxValueBytes` 的检查,会把帧上限除以六以应对最坏情况下的转义膨胀。但这两项预算都是以已转义的序列化字节来计量的:宿主日志账本通过 `jsonStringCostUpTo` 按序列化开销计费(它走到上限而不分配转义后的副本),`checkDoneValue` 度量的是转义后的形式,而生产侧的 `_cap_message` 同样按序列化开销设上限,因此一个在上限之内被放行的载荷在传输时最多占用 `cap + envelope`;转义已经包含在计费之内,不能再被乘一次。现在该上界为 `FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES`,未使用的 `MAX_JSON_ESCAPE_EXPANSION` 常量已被删除。旧的上界并非不安全(它是放行不足),但它静默地禁止了合法的大上限。这同一处加载检查还会拒绝一个非整数的 `maxLogBytes`/`maxValueBytes`:子进程通过 `int(...)` 读取每一项预算,而 `int(...)` 会对浮点数向下取整,因此 `maxLogBytes: 3.5` 会在子进程侧截断在 3 字节,而宿主却把小数部分也计入——两侧因此强制着不同的公开配置。在加载期拒绝该浮点数使两侧保持一致,与 worker 后端相符。 +那处在加载期拒绝比单个 fd-3 帧所能承载更大的 `maxLogBytes`/`maxValueBytes` 的检查,会把帧上限除以六以应对最坏情况下的转义膨胀。但这两项预算都是以已转义的序列化字节来计量的:宿主日志账本通过 `jsonStringCostUpTo` 按序列化开销计费(它走到上限而不分配转义后的副本),`checkDoneValue` 度量的是转义后的形式,而生产侧的 `_cap_message` 同样按序列化开销设上限,因此一个在上限之内被放行的载荷在传输时最多占用 `cap + envelope`;转义已经包含在计费之内,不能再被乘一次。现在该上界为 `FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES`(接收路径在解码前丢弃原始长度超过 64 MiB parse cap 的帧,因此预算不得超过诚实子进程的帧能穿过该解析器的值),未使用的 `MAX_JSON_ESCAPE_EXPANSION` 常量已被删除。旧的上界并非不安全(它是放行不足),但它静默地禁止了合法的大上限。这同一处加载检查还会拒绝一个非整数的 `maxLogBytes`/`maxValueBytes`:子进程通过 `int(...)` 读取每一项预算,而 `int(...)` 会对浮点数向下取整,因此 `maxLogBytes: 3.5` 会在子进程侧截断在 3 字节,而宿主却把小数部分也计入——两侧因此强制着不同的公开配置。在加载期拒绝该浮点数使两侧保持一致,与 worker 后端相符。 ### Same-group survivors are reaped before the fiber goes quiescent @@ -98,7 +98,7 @@ Status: implemented - `tests/boot-write-failure.spec.ts` 对 `spawn` 做 mock,使 fd-3 管道在引导写入时抛出异常(这是真实子进程无法被迫进入的唯一路径),并断言 `run()` resolve 出一个 `worker-exit` 而非 reject。一个同级用例让被 mock 的 `spawn` 同步抛出,并断言 `run()` 仍然 resolve 出一个 `worker-exit`,且会移除它的暂存目录——以被 mock 的 `spawn` 在其 argv 中收到的确切引导路径为准,因此一个同级 worker 的并发暂存不会让它变得不稳定。两者都被隔离在这个 spec 中,因此真实子进程测试套件不受影响。 - `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储(fixture 保持在 Node 的 Buffer 池阈值之上),并且不与源帧的 `ArrayBuffer` 共享。 -- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(67108800)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,随后在一个后续绑定之前回答它——断言 pump 在关闭事件循环上的 `call_soon_threadsafe` 之后仍然存活(由宿主门控的顺序使其具有确定性,未修复时会把后续绑定拖到墙钟上挂起)。inherited-soft-limit 用例通过一个 `ulimit -S -t` 包装脚本运行解释器,将 CPU 软限制设为低于 `cpuSeconds`,并断言实际应用的 `RLIMIT_CPU` 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 `ulimit -v`)。一个配套用例继承 1 秒的 CPU 软限制,让程序捕获 SIGXCPU 并忙循环越过它,断言结算复查报告 timeout——证明复查用的是实际生效的软限制,而不是配置的 `cpuSeconds`。一个 control-heavy-diagnostic 用例在一个较小的 `maxValueBytes` 之下抛出一个 NUL 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 tail-copy 用例(`maxLogBytes: 256`、`addressSpaceMb: 384`)让程序在一个变量里构建一个尾部并写入 `"\n" + tail`,其中 `tail` 为 150 MiB——构建峰值约 2 倍(约 300 MiB,落在地址空间之内,因此模型自身的分配会成功,任何 OOM 都属于缺陷路径),而修复前的整尾重新缓冲会加上第三份约 150 MiB 的副本、越过 384 MiB;切片后的前缀让该次运行得以截断并完成(仅 Linux 的 RLIMIT_AS 复现,macOS 走顺利路径——fixture 自身的构建必须放进地址空间,这是这些 RLIMIT_AS 用例的一条通用规则)。一个 output-budget/address-space 用例断言一个 50 MB 的 `maxLogBytes` 和一个 50 MB 的 `maxValueBytes` 各自对照一个 256 MiB 的 `addressSpaceMb` 在加载期被拒绝(乘以最坏情况的 12 之后超过解释器基线之后剩下的余量),而默认的各项上限对照 512 MiB 则加载成功,对两项预算对称地门控;一个具区分力的用例断言一个 48 MiB 的 `maxLogBytes` 对照一个 512 MiB 的 `addressSpaceMb` 被拒绝——48×8 = 384 MiB 放得进 448 MiB 的可预算余量(旧的 8× 倍数会错误放行),但 48×12 = 576 MiB 放不进。该倍数覆盖的约 12× 峰值来自换行路径上一次接近预算的写入——调用方自己的字符串、行切片与 encode 副本同时存活;结算期 flush 已不再是承重者,因为 `flush_line` 在 push 之前就丢弃了 pending 分块,因此只持有两份副本而非三份。一个 inherited-RLIMIT_AS 用例通过一个 `ulimit -v 131072` 包装层运行解释器,配以一个配置的 512 MiB `addressSpaceMb` 所允许的 32 MiB `maxLogBytes`,断言引导期的重新检查把它作为 `exception` 拒绝、且其消息点名了继承的 RLIMIT_AS(128 MiB 的继承限制在基线之后剩下的太少;仅 Linux,macOS 忽略 `ulimit -v`,该次运行会继续)。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。一个 combined-peak 用例(`maxLogBytes: 32 MiB`、`maxValueBytes: 32 MiB`、`addressSpaceMb: 512`——每项预算单独都被 12× 门放行)写入约 33M 个不含换行符的星芒面字符(缓冲、未冲刷)后返回约 33M 个星芒面字符,断言该次运行以 `output-limit` 结算(该值本身就超过它 32 MiB 的预算);修复前未冲刷的日志 pending 加上值的构建加编码峰值会一起越过 512 MiB 地址空间而 OOM,因此在分帧值之前先冲刷日志正是让值检查得以完成的原因(仅 Linux 的 RLIMIT_AS 复现;在 macOS 上超预算的值在两种顺序下都报 output-limit)。一个宽完成值用例(`maxValueBytes: 20 MiB`、`addressSpaceMb: 384`)返回 `[0] * 6_000_000`——JSON 约 12 MB、低于预算,因此必须成功往返;修复前 O(width) 的遍历为每个元素分配遍历元组与编码器栈项(约为序列化尺寸的 28×,超出门保留的 12×),在一个计量器已放行的值上 OOM,而 O(depth) 游标使唯一按宽度分配的只剩输出字符串本身。一个宽 binding 实参用例(`addressSpaceMb: 384`)以 `[0] * 6_000_000` 调用 binding 并断言长度回传:`_lossless_json_violation` 运行在模型构造的实参上,子进程侧没有任何字节预算先行约束,其逐元素元组实测 459.1 MiB,而游标为 0.0 MiB。一个回溯用例从 binding 返回一个 4 MiB 字符串并断言其成功往返:旧的标量正则保留的引擎状态与字符串宽度成正比(1 MiB 时 146 MiB,4 MiB 时 557.8 MiB,超过默认的 512 MiB),会在 `_pump_replies` 内抛出 MemoryError 并把该次调用搁置到墙钟。一个 control-heavy 计费用例在 16 MiB 的 `maxValueBytes` 之下返回 8M 个 NUL,断言得到 `output-limit` 而非 `exception`:以计数替代物化转义形式来计费,在字节数完全相同的前提下实测 19.1 MiB 对 228.9 MiB。一个 addressSpaceMb 下界用例断言 64 MiB 与 32 MiB 在加载期被拒绝,且消息点名 `addressSpaceMb`,而不是预算循环给出的负数上限。一个进程身份用例断言 leader 的启动时刻在 Linux 上可稳定读取、在 Darwin 上报告 undefined,这正是使被复用的 pgid 不会收到本次运行 SIGTERM 的那道守卫。一个 paced-replies 用例在一轮 `asyncio.gather` 中 resolve 八条 4 MiB 的值,断言这些帧能够往返;本修复移除的峰值(32.0 MiB 缓冲 → 0.0 MiB)位于宿主 fd-3 可写缓冲内部、透过 seam 不可见,因此该用例钉住的是往返与无回归匹配,其峰值只能在树外度量。一个 late-drop 用例让该次运行在 `maxWallMs` 上结算,随后才 resolve 那个 pending 的 binding,断言得到一个 `timeout` 结果、一个 undefined 值、且迟到路径确实被执行过——这三条断言在修复前也全部成立,因为 `sendReply` 本就丢弃结算之后的值、只是更晚,因此该用例钉住的是顺序,而非一个透过 seam 可观测的行为。一个"绑定全部用名"用例(`rebinds every name the failure path uses`)断言一个真实的 `ValueError` 在 send-done 绑定之后仍然存活——这是一个有测修复,由一个逐名重绑 `__main__.ProtocolChannel.send_sync`、`__main__.ProtocolChannel.write_encoded` 与 `__main__._encode_json_plain` 的用例钉住——这三个名字正是 shipped 的 `send_done` 若做调用时刻查找时会迟解析的那三个——并钉住 `done` 帧不被一次调用时刻的查找跳过;而完成值的 TOCTOU 预编码、stray UTF-8 预算冲刷的扣留与结算后到达的迟到拒绝的 settled 先查,都被计入那十处无 fail-before 修复(理由见 Problem 段),不由 fail-before 测试钉住。 一个 fragment-cap 滴灌用例写入 200 000 次单字符无换行的 `sys.stdout.write` 调用,断言该次运行以截断标记完成而非 MemoryError(no-fail-before:25 M 规模的 OOM 无法在 CI 中确定性构造)。一个 dual-limit CPU 用例通过一个 `ulimit -t 2` 包装脚本运行解释器并忙循环越过它,断言得到 `timeout`(软限制被降到 1,因此 SIGXCPU 触发,而非 `worker-exit`)。一个传递名重绑用例重绑 `__main__._dump_scalar`、`__main__.os`、`__main__._os_write`、`__main__._memoryview` 与 `__main__._FALLBACK_DONE_FRAME`,断言仍有一帧 done 以 `exception` 落地,而非 `worker-exit`(真实消息被固定兜底字面量替换)。 一个 BaseException 重绑用例把 `__main__.BaseException` 重绑为 `RuntimeError` 并抛出 `ValueError`,断言该次运行仍报告 `exception`,而非 `worker-exit`(catch 用的是程序运行前的局部异常类)。一个 RuntimeError 重绑闭环用例把 `__main__.RuntimeError` 重绑为 `ValueError` 作为程序首条语句,并驱动闭环 worker 模式,断言泵存活于死循环回复、投递后续 binding(泵的 `_RuntimeError` 是 def 期默认参数,因此在重绑前捕获原始值)。 一个 `_done_with_value` 重绑用例把 `__main__._done_with_value` 重绑为一个抛出函数并返回合法值,断言该次运行仍报告成功(入口名是程序运行前绑定的 `_run` 局部)。 一个 `sys.__stdout__` flush 用例不经显式 flush 直接通过 `sys.__stdout__`/`sys.__stderr__` 写入,断言两个字节都出现在 `logs` 中(`-u` 无缓冲子进程加上结算对原始 std 流的排空)。 宿主在 spawn 后立即关闭子进程的 stdin 写句柄(程序是不读 fd 0 的 async 函数体;存活的管道会在运行结束后继续持有宿主侧句柄,让继承 fd 0 的 setsid 逃逸后代拖住宿主进程)。通道的帧读取器把解码原语(`_decode_json_plain`、`os.read`、`_READ_CHUNK_BYTES`、`bytes`,以及 `len`;异步读取器还有 `asyncio.get_event_loop`)绑定为 def 期默认参数,因此 `__main__` 重绑无法杀死回复泵;`_decode_json_plain` 自身以同样方式捕获 `json.loads`/两个正则/`len`/`isinstance`/`str`/`list`。回复泵的帧读取器是 `_run` 在程序运行前捕获的绑定方法,以显式参数传入 `_pump_replies`(函数体内的 `channel.read_frame_async` 查找会解析被重绑的类属性,因为泵在程序顶层语句之后才启动)。 `send_done` 的帧形判别使用 `_run` 绑定的 `_str`/`_isinstance`(程序重绑 `__main__.isinstance` 无法让合法成功落入固定字面量兜底)。 `_make_error_class` 把 `Exception` 与 `setattr` 捕获为 def 期默认值,dispatch 把 `_lossless_json_violation`/`asyncio.get_event_loop`/通道的 send 与 write 原语绑定进 `_run` 局部(帧写入走 def 期绑定的 `write_encoded`+`_encode_json_plain`,而非 `send_sync` 的调用期函数体;日志 sink 直接走绑定的 encode+write 原语,而非 send_sync)——在首次 binding 调用前重绑这些名字无法破坏合法调用。`compile(wrapped, ..., dont_inherit=True)` 阻止本模块的 `from __future__ import annotations` 把程序的类型注解字符串化。 宿主在 `toString`/`JSON.parse` 之前把 fd-3 帧的原始长度限制在 64 MiB(`FRAME_PARSE_CAP_BYTES`):256 MiB 线上上限约束的是字节而非解码后的结构,接近它的紧凑宽帧解码后可能占用远超线上字节的宿主内存。`maxLogBytes`/`maxValueBytes` 在加载期被限制到该解析器上限,因此诚实子进程的帧总能放得下;模型构造的超限 binding 实参被丢弃(已登记在 README)。一个回归用例写入一个 65 MiB 帧加一个正常帧,断言超限帧被丢弃而尾随帧落入 logs。一个 `_decode_json_plain` 重绑用例断言 binding 回复仍能往返;一个 stdin-EOF 用例让程序读取 fd 0 并断言它立即看到 EOF(被销毁的写句柄),通过禁用销毁验证 fail-before。 一个 exact-limit 用例(`maxLogBytes: 64`)写入一个 60 字符行(62 字节 JSON + 1 分隔符 = 63 = 预留后的账本)与一个 61 字符行(64 > 63),断言前者放行、后者截断为仅标记——钉住外层数组外壳预留的精确边界;一个配套用例断言 `maxLogBytes: 61` 在构造期被拒绝。一个语法标签用例断言解析期语法错误携带 `File ""`(`ast.parse` 与 compile 及运行期 traceback 过滤使用同一来源标签)。一个 SIGXCPU 屏蔽用例屏蔽 SIGXCPU(`pthread_sigmask`)、越过软限并返回,断言得到 `timeout`(复查在重投递前解除屏蔽);一个 trap+mask 配套用例安装一个重新屏蔽的自定义 handler 并断言同样的 `timeout`(SIG_DFL 在 unblock 前恢复,因此挂起信号在内核内致死)。 +- `tests/runtime.spec.ts`:output-cap 用例断言 `parse-cap - envelope` 上界(67108800)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,随后在一个后续绑定之前回答它——断言 pump 在关闭事件循环上的 `call_soon_threadsafe` 之后仍然存活(由宿主门控的顺序使其具有确定性,未修复时会把后续绑定拖到墙钟上挂起)。inherited-soft-limit 用例通过一个 `ulimit -S -t` 包装脚本运行解释器,将 CPU 软限制设为低于 `cpuSeconds`,并断言实际应用的 `RLIMIT_CPU` 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 `ulimit -v`)。一个配套用例继承 1 秒的 CPU 软限制,让程序捕获 SIGXCPU 并忙循环越过它,断言结算复查报告 timeout——证明复查用的是实际生效的软限制,而不是配置的 `cpuSeconds`。一个 control-heavy-diagnostic 用例在一个较小的 `maxValueBytes` 之下抛出一个 NUL 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 tail-copy 用例(`maxLogBytes: 256`、`addressSpaceMb: 384`)让程序在一个变量里构建一个尾部并写入 `"\n" + tail`,其中 `tail` 为 150 MiB——构建峰值约 2 倍(约 300 MiB,落在地址空间之内,因此模型自身的分配会成功,任何 OOM 都属于缺陷路径),而修复前的整尾重新缓冲会加上第三份约 150 MiB 的副本、越过 384 MiB;切片后的前缀让该次运行得以截断并完成(仅 Linux 的 RLIMIT_AS 复现,macOS 走顺利路径——fixture 自身的构建必须放进地址空间,这是这些 RLIMIT_AS 用例的一条通用规则)。一个 output-budget/address-space 用例断言一个 50 MB 的 `maxLogBytes` 和一个 50 MB 的 `maxValueBytes` 各自对照一个 256 MiB 的 `addressSpaceMb` 在加载期被拒绝(乘以最坏情况的 12 之后超过解释器基线之后剩下的余量),而默认的各项上限对照 512 MiB 则加载成功,对两项预算对称地门控;一个具区分力的用例断言一个 48 MiB 的 `maxLogBytes` 对照一个 512 MiB 的 `addressSpaceMb` 被拒绝——48×8 = 384 MiB 放得进 448 MiB 的可预算余量(旧的 8× 倍数会错误放行),但 48×12 = 576 MiB 放不进。该倍数覆盖的约 12× 峰值来自换行路径上一次接近预算的写入——调用方自己的字符串、行切片与 encode 副本同时存活;结算期 flush 已不再是承重者,因为 `flush_line` 在 push 之前就丢弃了 pending 分块,因此只持有两份副本而非三份。一个 inherited-RLIMIT_AS 用例通过一个 `ulimit -v 131072` 包装层运行解释器,配以一个配置的 512 MiB `addressSpaceMb` 所允许的 32 MiB `maxLogBytes`,断言引导期的重新检查把它作为 `exception` 拒绝、且其消息点名了继承的 RLIMIT_AS(128 MiB 的继承限制在基线之后剩下的太少;仅 Linux,macOS 忽略 `ulimit -v`,该次运行会继续)。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。一个 combined-peak 用例(`maxLogBytes: 32 MiB`、`maxValueBytes: 32 MiB`、`addressSpaceMb: 512`——每项预算单独都被 12× 门放行)写入约 33M 个不含换行符的星芒面字符(缓冲、未冲刷)后返回约 33M 个星芒面字符,断言该次运行以 `output-limit` 结算(该值本身就超过它 32 MiB 的预算);修复前未冲刷的日志 pending 加上值的构建加编码峰值会一起越过 512 MiB 地址空间而 OOM,因此在分帧值之前先冲刷日志正是让值检查得以完成的原因(仅 Linux 的 RLIMIT_AS 复现;在 macOS 上超预算的值在两种顺序下都报 output-limit)。一个宽完成值用例(`maxValueBytes: 20 MiB`、`addressSpaceMb: 384`)返回 `[0] * 6_000_000`——JSON 约 12 MB、低于预算,因此必须成功往返;修复前 O(width) 的遍历为每个元素分配遍历元组与编码器栈项(约为序列化尺寸的 28×,超出门保留的 12×),在一个计量器已放行的值上 OOM,而 O(depth) 游标使唯一按宽度分配的只剩输出字符串本身。一个宽 binding 实参用例(`addressSpaceMb: 384`)以 `[0] * 6_000_000` 调用 binding 并断言长度回传:`_lossless_json_violation` 运行在模型构造的实参上,子进程侧没有任何字节预算先行约束,其逐元素元组实测 459.1 MiB,而游标为 0.0 MiB。一个回溯用例从 binding 返回一个 4 MiB 字符串并断言其成功往返:旧的标量正则保留的引擎状态与字符串宽度成正比(1 MiB 时 146 MiB,4 MiB 时 557.8 MiB,超过默认的 512 MiB),会在 `_pump_replies` 内抛出 MemoryError 并把该次调用搁置到墙钟。一个 control-heavy 计费用例在 16 MiB 的 `maxValueBytes` 之下返回 8M 个 NUL,断言得到 `output-limit` 而非 `exception`:以计数替代物化转义形式来计费,在字节数完全相同的前提下实测 19.1 MiB 对 228.9 MiB。一个 addressSpaceMb 下界用例断言 64 MiB 与 32 MiB 在加载期被拒绝,且消息点名 `addressSpaceMb`,而不是预算循环给出的负数上限。一个进程身份用例断言 leader 的启动时刻在 Linux 上可稳定读取、在 Darwin 上报告 undefined,这正是使被复用的 pgid 不会收到本次运行 SIGTERM 的那道守卫。一个 paced-replies 用例在一轮 `asyncio.gather` 中 resolve 八条 4 MiB 的值,断言这些帧能够往返;本修复移除的峰值(32.0 MiB 缓冲 → 0.0 MiB)位于宿主 fd-3 可写缓冲内部、透过 seam 不可见,因此该用例钉住的是往返与无回归匹配,其峰值只能在树外度量。一个 late-drop 用例让该次运行在 `maxWallMs` 上结算,随后才 resolve 那个 pending 的 binding,断言得到一个 `timeout` 结果、一个 undefined 值、且迟到路径确实被执行过——这三条断言在修复前也全部成立,因为 `sendReply` 本就丢弃结算之后的值、只是更晚,因此该用例钉住的是顺序,而非一个透过 seam 可观测的行为。一个"绑定全部用名"用例(`rebinds every name the failure path uses`)断言一个真实的 `ValueError` 在 send-done 绑定之后仍然存活——这是一个有测修复,由一个逐名重绑 `__main__.ProtocolChannel.send_sync`、`__main__.ProtocolChannel.write_encoded` 与 `__main__._encode_json_plain` 的用例钉住——这三个名字正是 shipped 的 `send_done` 若做调用时刻查找时会迟解析的那三个——并钉住 `done` 帧不被一次调用时刻的查找跳过;而完成值的 TOCTOU 预编码、stray UTF-8 预算冲刷的扣留与结算后到达的迟到拒绝的 settled 先查,都被计入那十处无 fail-before 修复(理由见 Problem 段),不由 fail-before 测试钉住。 一个 fragment-cap 滴灌用例写入 200 000 次单字符无换行的 `sys.stdout.write` 调用,断言该次运行以截断标记完成而非 MemoryError(no-fail-before:25 M 规模的 OOM 无法在 CI 中确定性构造)。一个 dual-limit CPU 用例通过一个 `ulimit -t 2` 包装脚本运行解释器并忙循环越过它,断言得到 `timeout`(软限制被降到 1,因此 SIGXCPU 触发,而非 `worker-exit`)。一个传递名重绑用例重绑 `__main__._dump_scalar`、`__main__.os`、`__main__._os_write`、`__main__._memoryview` 与 `__main__._FALLBACK_DONE_FRAME`,断言仍有一帧 done 以 `exception` 落地,而非 `worker-exit`(真实消息被固定兜底字面量替换)。 一个 BaseException 重绑用例把 `__main__.BaseException` 重绑为 `RuntimeError` 并抛出 `ValueError`,断言该次运行仍报告 `exception`,而非 `worker-exit`(catch 用的是程序运行前的局部异常类)。一个 RuntimeError 重绑闭环用例把 `__main__.RuntimeError` 重绑为 `ValueError` 作为程序首条语句,并驱动闭环 worker 模式,断言泵存活于死循环回复、投递后续 binding(泵的 `_RuntimeError` 是 def 期默认参数,因此在重绑前捕获原始值)。 一个 `_done_with_value` 重绑用例把 `__main__._done_with_value` 重绑为一个抛出函数并返回合法值,断言该次运行仍报告成功(入口名是程序运行前绑定的 `_run` 局部)。 一个 `sys.__stdout__` flush 用例不经显式 flush 直接通过 `sys.__stdout__`/`sys.__stderr__` 写入,断言两个字节都出现在 `logs` 中(`-u` 无缓冲子进程加上结算对原始 std 流的排空)。 宿主在 spawn 后立即关闭子进程的 stdin 写句柄(程序是不读 fd 0 的 async 函数体;存活的管道会在运行结束后继续持有宿主侧句柄,让继承 fd 0 的 setsid 逃逸后代拖住宿主进程)。通道的帧读取器把解码原语(`_decode_json_plain`、`os.read`、`_READ_CHUNK_BYTES`、`bytes`,以及 `len`;异步读取器还有 `asyncio.get_event_loop`)绑定为 def 期默认参数,因此 `__main__` 重绑无法杀死回复泵;`_decode_json_plain` 自身以同样方式捕获 `json.loads`/两个正则/`len`/`isinstance`/`str`/`list`。回复泵的帧读取器是 `_run` 在程序运行前捕获的绑定方法,以显式参数传入 `_pump_replies`(函数体内的 `channel.read_frame_async` 查找会解析被重绑的类属性,因为泵在程序顶层语句之后才启动)。 `send_done` 的帧形判别使用 `_run` 绑定的 `_str`/`_isinstance`(程序重绑 `__main__.isinstance` 无法让合法成功落入固定字面量兜底)。 `_make_error_class` 把 `Exception` 与 `setattr` 捕获为 def 期默认值,dispatch 把 `_lossless_json_violation`/`asyncio.get_event_loop`/通道的 send 与 write 原语绑定进 `_run` 局部(帧写入走 def 期绑定的 `write_encoded`+`_encode_json_plain`,而非 `send_sync` 的调用期函数体;日志 sink 直接走绑定的 encode+write 原语,而非 send_sync)——在首次 binding 调用前重绑这些名字无法破坏合法调用。`compile(wrapped, ..., dont_inherit=True)` 阻止本模块的 `from __future__ import annotations` 把程序的类型注解字符串化。 宿主在 `toString`/`JSON.parse` 之前把 fd-3 帧的原始长度限制在 64 MiB(`FRAME_PARSE_CAP_BYTES`):256 MiB 线上上限约束的是字节而非解码后的结构,接近它的紧凑宽帧解码后可能占用远超线上字节的宿主内存。`maxLogBytes`/`maxValueBytes` 在加载期被限制到该解析器上限,因此诚实子进程的帧总能放得下;模型构造的超限 binding 实参被丢弃(已登记在 README)。一个回归用例写入一个 65 MiB 帧加一个正常帧,断言超限帧被丢弃而尾随帧落入 logs。一个 `_decode_json_plain` 重绑用例断言 binding 回复仍能往返;一个 stdin-EOF 用例让程序读取 fd 0 并断言它立即看到 EOF(被销毁的写句柄),通过禁用销毁验证 fail-before。 一个 exact-limit 用例(`maxLogBytes: 64`)写入一个 60 字符行(62 字节 JSON + 1 分隔符 = 63 = 预留后的账本)与一个 61 字符行(64 > 63),断言前者放行、后者截断为仅标记——钉住外层数组外壳预留的精确边界;一个配套用例断言 `maxLogBytes: 61` 在构造期被拒绝。一个语法标签用例断言解析期语法错误携带 `File ""`(`ast.parse` 与 compile 及运行期 traceback 过滤使用同一来源标签)。一个 SIGXCPU 屏蔽用例屏蔽 SIGXCPU(`pthread_sigmask`)、越过软限并返回,断言得到 `timeout`(复查在重投递前解除屏蔽);一个 trap+mask 配套用例安装一个重新屏蔽的自定义 handler 并断言同样的 `timeout`(SIG_DFL 在 unblock 前恢复,因此挂起信号在内核内致死)。 ## Alternatives considered diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 548e825da9..f17a0bc1e2 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -203,25 +203,25 @@ function materializePyScripts(): string { * check at the `done` handler, deliberately decoupled from this. Not a config * knob because it is an internal framing invariant, not a deployment choice. */ -const FRAME_CEILING_BYTES = 256 * 1024 * 1024 - /** - * A frame's RAW length is capped before JSON.parse: the 256 MiB wire ceiling - * bounds the bytes on fd 3, not the decoded structure, and a compact wide + * A frame's RAW length is capped before JSON.parse: the 256 MiB fd-3 wire + * ceiling bounds the bytes, not the decoded structure, and a compact wide * frame near that ceiling (e.g. a huge array of tiny elements) could decode to * far more host memory than the wire admitted — an OOM inside the receive * path. 64 MiB raw admits every legal config (the widest in-tree completion * and binding frames are ~12 MB) while bounding decode amplification to a - * roughly constant factor of the wire bytes. A hostile-peer invariant, not a - * deployment choice. + * roughly constant factor of the wire bytes. The unframed-buffer counter is + * checked against this same cap BEFORE a `Buffer.concat` join, so an oversized + * frame is dropped at one copy of its wire bytes. A hostile-peer invariant, + * not a deployment choice. */ const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024 /** * Fragments the unframed fd-3 buffer may hold before they are coalesced into - * one Buffer, bounding retained per-chunk overhead that {@link - * FRAME_CEILING_BYTES} cannot see: that ceiling meters payload bytes, while - * each chunk is a distinct Buffer with its own object and backing store. A + * one Buffer, bounding retained per-chunk overhead that the byte cap cannot + * see: the cap meters payload bytes, while each chunk is a distinct Buffer + * with its own object and backing store. A * program writing single bytes without a newline produced one chunk per write. * 1024 keeps the overhead a small constant factor of the payload while leaving * normal pipe-sized reads (which arrive in far fewer, much larger chunks) @@ -1314,21 +1314,23 @@ export class PythonCodeRuntime extends CodeRuntime { // current line can be larger than that. That over-count is deliberate and // load-bounded on the OTHER side: the config cap is `parse-cap - envelope`, // and a legitimate near-cap frame plus a following chunk's leading bytes - // could in principle nudge the counter over the ceiling for one read - // window — but only when maxLogBytes/maxValueBytes is configured within - // one pipe read of the 256 MiB ceiling, orders of magnitude past the - // 32/64 KiB defaults. Enforcing the ceiling per-frame instead (splitting - // before the check) would require `Buffer.concat`-ing an over-ceiling - // single frame before rejecting it, reintroducing the peak-memory - // doubling this pre-concat check and its regression tests exist to - // prevent; the memory-safety bound against hostile input at any config - // takes precedence over a false-reject reachable only at a pathological - // near-ceiling config. - if (pendingBytes > FRAME_CEILING_BYTES) { + // could in principle nudge the counter over the cap for one read window + // — but only when maxLogBytes/maxValueBytes is configured within one + // pipe read of the 64 MiB cap, orders of magnitude past the 32/64 KiB + // defaults. + // + // The cap used HERE is FRAME_PARSE_CAP_BYTES, not the 256 MiB wire + // ceiling: a single frame between 64 MiB and the ceiling would otherwise + // be fully `Buffer.concat`-ed (a second copy of its bytes) and only then + // dropped in the line loop — the peak-memory doubling this pre-concat + // check exists to prevent, now for a frame the parser is guaranteed to + // discard. Dropping the oversized unframed buffer before the join keeps + // the peak at one copy of the wire bytes. + if (pendingBytes > FRAME_PARSE_CAP_BYTES) { pendingChunks = [] sealedBlocks = [] pendingBytes = 0 - finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_CEILING_BYTES} bytes on fd 3` } }) + finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } }) return } // Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the 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 d7ba2b7f8f..740ceebcf6 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -1734,32 +1734,45 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(result.error?.kind).not.toBe('worker-exit') }, 15_000) - it('drops an fd-3 frame whose raw length exceeds the parse cap before decoding it', async () => { + it('rejects an fd-3 frame whose raw length exceeds the parse cap before joining it', async () => { // The 256 MiB wire ceiling bounds the RAW frame bytes, not the decoded // structure; a compact wide frame near that ceiling could decode to far - // more host memory. The receive path caps raw frames at - // FRAME_PARSE_CAP_BYTES before toString/JSON.parse and drops the oversized - // one like any junk frame, so the following normal frame is still - // processed. Fail-before: without the cap the oversized log text would be - // parsed and admitted (truncating the ledger), and the trailing frame - // would be dropped as post-truncation instead of appearing in logs. + // more host memory. The unframed-buffer counter is checked against + // FRAME_PARSE_CAP_BYTES BEFORE the Buffer.concat join, so an oversized + // frame is dropped at one copy of its wire bytes instead of being fully + // joined (a second copy) and only then discarded in the line loop — the + // peak-memory doubling the pre-join check exists to prevent. Fail-before: + // without the check the frame is joined whole and parsed (its log text + // admitted, truncating the ledger), and the run completes normally. const { runtime } = await setup({ maxWallMs: 60_000 }) const result = await runtime.run({ program: [ 'import os', // One frame just past the 64 MiB parse cap. 'os.write(3, b"{\\"type\\":\\"log\\",\\"text\\":\\"" + b"a" * (65 * 1024 * 1024) + b"\\"}\\n")', - 'os.write(3, b"{\\"type\\":\\"log\\",\\"text\\":\\"after-cap\\"}\\n")', 'return "done"', ].join('\n'), bindings: [], }) - expect(result.error).toBeUndefined() - expect(result.value).toBe('done') - // The oversized frame was dropped before parse; the trailing frame was - // processed normally (its text survives in logs). - expect(result.logs).toContain('after-cap') - expect(result.logs.some(line => line.length > 1024 * 1024)).toBe(false) + expect(result.error?.kind).toBe('worker-exit') + expect(result.error?.message).toContain('protocol frame exceeded') + }, 90_000) + + it('caps an oversized rejection diagnostic so an invalid completion stays invalid-output', async () => { + // _done_with_value caps its rejection diagnostic through _cap_message: a + // hostile class name (huge type(value).__name__) would otherwise push the + // done frame past the host's 64 MiB parse cap, misreporting an + // invalid-output run as a worker-exit. The diagnostic is capped to the + // value budget, so the frame always crosses the parser. + const { runtime } = await setup({ maxWallMs: 60_000 }) + const result = await runtime.run({ + program: [ + 'return type("N" * (70 * 1024 * 1024), (), {})()', + ].join('\n'), + bindings: [], + }) + expect(result.error?.kind).toBe('invalid-output') + expect(result.error?.kind).not.toBe('worker-exit') }, 90_000) it('bounds an over-cap exception-group nesting on the copy', async () => {