diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml
index a636d18288..cf4c994853 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.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-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md
-2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md: 6213a5d28493c388f7a05d57d353896e0b86f352
-2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md: daf02b895a06039cd06976edb7c10cf9481b7166
+2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md: afdad301a4853754184b75668d167e71420c2480
+2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md: 48d6e0748979b09053aa51a94788fdd95d997179
diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md
index 6213a5d284..afdad301a4 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md
@@ -1,4 +1,4 @@
-# Agent Note: Bound in-flight binding calls, snapshot binding metadata, and compact the reply queue in the CPython backend
+# Agent Note: Bound in-flight binding calls, snapshot binding metadata, compact the reply queue, and meter wide completions with cursors in the CPython backend
Status: implemented
@@ -6,13 +6,13 @@ English | [中文](2026-08-29-code-runtime-python-call-backlog-and-binding-metad
## Problem
-A further review round on the CPython subprocess backend (packages/experimental/code-runtime-python) surfaced three findings on the binding-dispatch and validation paths. First, the reply-backlog cap counts only RESOLVED calls — `pendingReplies` grows after the binding's `await` resolves — so a child flooding calls against a binding whose promise never settles accumulates one async closure per frame until the wall clock without ever tripping the cap. Second, `validateBindings` reads `errorClass.name`, `errorClass.memberNameProperty`, and `namespace.global` several times and retains the original errorClass object for the boot frame, whose `JSON.stringify` re-reads it after validation: a getter that returns a valid value during validation and then throws or returns a conflicting value at stringify time turns the seam-misuse rejection into a worker-exit, or injects a different name than validation approved. Third, `replyQueue` never shrinks mid-drain: the drain loop clears consumed slots to `undefined` but leaves `length` (and the backing store) growing, so a child that reads replies just fast enough to keep the drain alive but never empty grows the array linearly with cumulative throughput.
+A further review round on the CPython subprocess backend (packages/experimental/code-runtime-python) surfaced seven findings on the binding-dispatch, validation, completion-metering, and frame-parse paths. First, the reply-backlog cap counts only RESOLVED calls — `pendingReplies` grows after the binding's `await` resolves — so a child flooding calls against a binding whose promise never settles accumulates one async closure per frame until the wall clock without ever tripping the cap. Second, `validateBindings` reads `errorClass.name`, `errorClass.memberNameProperty`, and `namespace.global` several times and retains the original errorClass object for the boot frame, whose `JSON.stringify` re-reads it after validation: a getter that returns a valid value during validation and then throws or returns a conflicting value at stringify time turns the seam-misuse rejection into a worker-exit, or injects a different name than validation approved. Third, `replyQueue` never shrinks mid-drain: the drain loop clears consumed slots to `undefined` but leaves `length` (and the backing store) growing, so a child that reads replies just fast enough to keep the drain alive but never empty grows the array linearly with cumulative throughput. Fourth, the completion meter `checkDoneValue` pushes every member of an open container onto an explicit work stack, so a wide completion value near the frame cap (millions of members) copies that many references onto the stack — O(width) auxiliary memory on top of the already-parsed value — OOMing the host after the parse succeeded. Fifth, a done frame processed in the SAME data event as more than 1024 call frames settles the run before the post-macrotask call-backlog check runs (which no-ops once settled), so a child could finish successfully while leaving the outstanding closures behind. Sixth, the child's `_dump_string` folds a spelled-out surrogate pair into its astral code point, so two DIFFERENT Python dict keys — `"\ud83d\ude00"` and `"\U0001f600"` — encode to the SAME JSON member and the host's `JSON.parse` silently drops one of them, violating the lossless-JSON promise for completions and binding arguments. Seventh, the load gate bounds the CHILD's build-and-encode under `RLIMIT_AS` but not the HOST's `JSON.parse`: a legitimately configured wide completion near the frame cap (e.g. a 3-million-key dict under a 50 MiB budget) materializes several times its raw bytes in the host's property storage, so a constrained host heap (e.g. `--max-old-space-size=256`) dies with a process-level OOM during the parse — before `checkDoneValue` (which only sees the already-parsed value) could reject it.
## Decision
-### In-flight binding calls are capped at 1024
+### In-flight binding calls are capped at 1024, checked once per macrotask after the microtasks drain
-`case 'call'` counts the outstanding binding calls before dispatch (`pendingCalls`) and releases the slot in the async body's `finally`, covering the reply-written, resolution-rejected, and settled-drop exits. When the count reaches `MAX_PENDING_REPLIES`, the run settles as a `worker-exit` with a call-backlog message, bounding in-flight closures exactly like the reply backlog. This is a count bound, not a byte bound.
+`case 'call'` counts the outstanding binding calls before dispatch (`pendingCalls`) and releases the slot in the async body's `finally`, covering the reply-written, resolution-rejected, and settled-drop exits. The data handler schedules ONE post-batch check per macrotask via `setImmediate` (deduped by a flag): it runs after the current macrotask's microtasks, so it sees the TRUE outstanding count — the live count is inflated by the batch's own frames (the finallys have not run yet), and a per-event snapshot is stale when flowing mode fires several `data` events within one macrotask before any microtask drains. When the count passes `MAX_PENDING_REPLIES` — strictly greater, so exactly 1024 outstanding calls are allowed — the run settles as a `worker-exit` with a call-backlog message. The check no-ops once `settled`, so a `done` or `log` frame wins over the cap: a program that returns with binding calls it started but never awaited still completes with its value. The `done` handler independently re-checks the count before accepting the frame, closing the window where a done in the SAME batch as a flood would settle the run before the post-macrotask check could fire. This is a count bound, not a byte bound.
### Binding metadata is snapshotted into plain values before validation and the boot frame
@@ -22,20 +22,49 @@ A further review round on the CPython subprocess backend (packages/experimental/
`drainReplies` compacts the consumed prefix (`replyQueue.splice(0, head); head = 0`) once `head` reaches `MAX_PENDING_REPLIES`. The splice is O(head) once per bound of consumed frames — amortized O(1) per reply — bounding the backing store to O(backlog + bound) for a drain that never empties.
+### The completion meter walks wide values with one cursor per nesting level
+
+`checkDoneValue` now holds one cursor per OPEN container (a values iterator for the root and arrays, an entries iterator for objects whose key escapes are metered when the entry is reached), the same shape `hasNonLosslessNumber` and the child's `_check_done_value` already use. The byte budget still bounds the walk: each member is metered as its cursor yields it, and the width lower-bound checks bail an over-budget container before the cursor descends. The auxiliary state is O(depth), not O(width), so a wide completion near the frame cap meters exactly instead of copying millions of references. `encodeJsonPlain` keeps its per-container task stack, which is O(width) but holds only references while the encoded output is itself O(total bytes) — same-order as its result, so the exemption is documented in its comment.
+
+### The frame parse cap is bounded by the host's heap
+
+The raw-byte frame cap does not protect the host process: `JSON.parse` of a wide-object frame materializes several times the raw bytes in property storage. The WORST shape is a dict of many short unique keys, which forces V8's dictionary-mode property storage plus one interned string per key — measured 6.4x for a 3,000,000-key frame (~31 MB raw) on a 1 GiB heap, trending up with key count (a flat unique-key array is ~4x, a repeated-key dict ~3x); a 256 MiB heap OOMs on that frame outright. The effective cap each instance enforces is `min(protocol cap, floor((heap_size_limit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))` with a 16x multiple — ~2.5x over the measured worst shape — derived from the host's configured heap limit (`--max-old-space-size` honored via `v8.getHeapStatistics().heap_size_limit`). A default Node heap (~4 GiB) never binds; a constrained host lowers the cap and the load gate rejects any budget whose frame could not be parsed safely, failing loud at load instead of OOMing the host mid-parse. The child's `RLIMIT_AS` gate is a separate resource and stays unchanged.
+
+### Dict keys that fold to one JSON member are rejected as non-lossless
+
+The child's `_dump_string` folds a spelled-out surrogate pair into its astral code point so the host's UTF-16 strings (where the two code units and the single character are the SAME string) meter at the same cost. Python can hold both spellings as distinct keys, so a dict containing `"\ud83d\ude00"` and `"\U0001f600"` would emit two members with the same JSON key and the host's `JSON.parse` would silently drop one. Both lossless-JSON walks (`_lossless_json_violation` for binding arguments, `_check_done_value` for completions) now track each dict's combined keys in a per-dict seen-set — O(keys), the same order as the dict itself — and reject a collision as non-lossless before any encoding.
+
## Testing
- `tests/runtime.spec.ts` — a hostile child floods 5000 sequential calls against a binding that never settles (`await new Promise(() => {})`); the run settles as `worker-exit` with the call-backlog message long before `maxWallMs`. Verified fail-before: without the cap the run times out at the wall clock.
+- `tests/runtime.spec.ts` — a legitimate `asyncio.gather` of 1025 instant calls completes with all 1025 results: the post-macrotask check sees the count after the finallys drained, where a per-frame check could trip on the 1025th frame of a single 64 KiB read.
+- `tests/runtime.spec.ts` — a program that schedules 1024 slow bindings (still pending) and returns `"done"` completes with its value: the check no-ops once the done frame settles the run, and the strict threshold allows exactly 1024 outstanding calls. Verified fail-before: an unconditional event-boundary check failed this exact case.
+- `tests/runtime.spec.ts` — a single 62 KiB write of 1025 compact calls against a never-settling binding settles as `worker-exit` long before `maxWallMs`, even though no further frames ever arrive: the per-macrotask check fires after the batch. Verified fail-before: a per-event admission snapshot never re-checks without further frames and the run waited out the wall clock.
+- `tests/runtime.spec.ts` — a single write of 1025 compact calls PLUS a done frame in the same batch settles as `worker-exit`: the done handler re-checks the count before accepting the frame, where the post-macrotask check would no-op after the done settled the run. Verified fail-before: without the done re-check the run completed successfully with the outstanding closures left behind.
+- `tests/runtime.spec.ts` — a burst of 1300 instant calls whose frames split across pipe reads completes with all results: the check runs after all of a macrotask's finallys, where a per-event snapshot could see a stale in-flight count when flowing mode fires several events before any microtask drains.
+- `tests/runtime.spec.ts` — a completion value and binding arguments whose dict contains both `"\ud83d\ude00"` and `"\U0001f600"` as keys are rejected as non-lossless (invalid-output / a lossless-JSON call rejection): the two spellings fold to one JSON member, which the host's JSON.parse would silently collapse. Verified fail-before: without the collision check both round-tripped with one key dropped.
+- `tests/protocol.spec.ts` — `hostFrameParseCeiling` derives the effective parse cap from a simulated heap: the protocol cap binds on a default heap, a ~304 MiB host limit yields a 15 MiB cap, and a tiny heap leaves almost no parse room.
+- `tests/runtime.spec.ts` — a child node with a 128 MiB old space rejects a 50 MiB completion budget at load (`maxValueBytes must not exceed`), where the address-space gate alone would admit it. Verified fail-before: with the heap bound ignored the budget loaded.
+- `tests/runtime.spec.ts` — a child node with a 128 MiB old space builds a wide-unique-key dict whose frame is AT the derived cap and parses it, surviving. Verified fail-before: with the parse multiple at 8 the derived cap doubles and the same subprocess OOMs during the parse.
+- The suite's temp fixtures (`dsh-bad-bin-`, `dsh-fake-bin-`, `dsh-rlimit-*`, `dsh-staging-`, heartbeat dirs, wrapper scripts) are now registered and removed after each test, so repeated runs do not accumulate `dsh-*` artifacts in the shared tmpdir.
- Two namespace-shape tests — `errorClass.name`/`errorClass.memberNameProperty` and `namespace.global` exposed through getters that throw or change on a second read; the run boots and completes, and each field is read exactly once (asserted). Verified fail-before: without the snapshot, the errorClass getter threw inside validation and the global getter injected a different name, failing the program with `NameError`.
-- `tests/runtime.spec.ts` — a child floods calls whose replies exceed the writable high-water mark, blocking the first drain write; the resumed drain consumes a backlog past the compaction bound while a second wave of calls is still pending, and the child reads fd 3 itself (blocking the reply pump) to verify all 1524 replies arrive. Verified fail-before: a splice that removed pending frames dropped the second wave and the run hung to the wall clock.
+- `tests/runtime.spec.ts` — a child floods calls whose replies exceed the writable high-water mark, blocking the first drain write; the resumed drain consumes a backlog past the compaction bound while a second wave of calls is still pending, and the child reads fd 3 itself (blocking the reply pump) to verify all 1524 replies arrive. No fixed sleep: the child's reads pace at the drain's delivery rate, and the host finishes pushing a wave within milliseconds, so the queue is always full at the splice; newlines are counted per chunk (each reply carries exactly one), never by re-scanning the accumulated total, which would be O(n²). Verified fail-before: a splice that removed pending frames dropped the second wave and the run hung to the wall clock.
+- `tests/protocol.spec.ts` — a 2,000,000-element array and a 100,000-key object meter at their exact serialized size, reject one byte under, and still find a `-0` tail element, pinning the cursor walk's breadth behavior.
## Alternatives considered
**Pause the fd-3 read side instead of counting in-flight calls.** Rejected: pausing reads would also stall processing of `done` and `log` frames the child may send after its last call, changing settlement timing; a count cap is deterministic and matches the existing frame-cap pattern.
+**Check the in-flight count per frame.** Rejected: the finallys run on the microtask queue, which drains only when the macrotask ends, so a single event carrying more than `MAX_PENDING_REPLIES` legitimate call frames would trip a per-frame check even though every binding settled immediately.
+
+**Check the count at call admission against a per-event snapshot.** Rejected twice: an unconditional event-boundary check reclassifies a `done` frame as worker-exit when a program returns with calls it never awaited, and a snapshot that refreshes per `data` event is stale when flowing mode fires several events within one macrotask (a legitimate burst whose second chunk carries more in-flight calls than the cap would be killed). Checking the true count once per macrotask, after the microtasks drain, is chunking-independent on both axes, and the strict threshold lets exactly `MAX_PENDING_REPLIES` outstanding calls complete normally.
+
**Read metadata once but keep the original errorClass object.** Rejected: the boot frame's `JSON.stringify` re-invokes the getters; only a plain stored copy guarantees both stages read the same values.
**Rely on the drain's `finally` reset for queue memory.** Rejected: the reset runs only when the drain ends; a drain that never empties keeps growing. Mid-drain compaction bounds the backing store while the drain is alive.
+**Keep the completion meter's explicit member stack.** Rejected: the byte budget bounds the WALK but not the stack's reference count, which is O(width) — a wide value near the frame cap copies millions of references and can OOM the host after the parse succeeded; the per-level cursor shape keeps O(depth) state.
+
## Consequences
-In-flight binding closures are bounded like the reply backlog, so a child flooding calls against a never-settling binding fails the run early instead of accumulating closures until the wall clock. The boot frame serializes exactly the metadata validation approved, regardless of getter state. The reply queue's backing store stays bounded during sustained partial drains; the compaction is internal memory hygiene with no observable behavior change.
+In-flight binding closures are bounded like the reply backlog, so a child flooding calls against a never-settling binding fails the run early instead of accumulating closures until the wall clock, while a legitimate large concurrent gather is unaffected (the cap is checked after the microtask queue drains). The boot frame serializes exactly the metadata validation approved, regardless of getter state. The reply queue's backing store stays bounded during sustained partial drains; the compaction is internal memory hygiene with no observable behavior change. The completion meter keeps its exact byte accounting with O(depth) auxiliary state, so a wide completion value meters without a host OOM.
diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md
index daf02b895a..48d6e07489 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md
@@ -1,4 +1,4 @@
-# Agent Note: 在 CPython 后端限制在途 binding 调用、快照 binding 元数据并压缩回复队列
+# Agent Note: 在 CPython 后端限制在途 binding 调用、快照 binding 元数据、压缩回复队列并用游标计量宽完成值
Status: implemented
@@ -6,13 +6,13 @@ Status: implemented
## Problem
-对 CPython 子进程后端(packages/experimental/code-runtime-python)的又一轮评审在 binding 分发与校验路径上浮出三项发现。其一,回复积压上限只计数已解析的调用——`pendingReplies` 在 binding 的 `await` 解析后才增长——因此向 promise 永不结算的 binding 洪泛调用的子进程会每个帧累积一个异步闭包直到墙钟,却始终不触发该上限。其二,`validateBindings` 多次读取 `errorClass.name`、`errorClass.memberNameProperty` 与 `namespace.global`,并把原始 errorClass 对象保留到引导帧,其 `JSON.stringify` 在校验后重读该对象:getter 在校验时返回合法值、在序列化时抛错或返回冲突值,会把 seam 误用拒绝变成 worker-exit,或注入一个未经校验批准的名字。其三,`replyQueue` 在排空进行中从不收缩:排空循环把已消费槽位清成 `undefined`,但 `length`(及其后备存储)继续增长,因此以恰好能让排空持续存活却永不排空的速率读取回复的子进程,会让数组随累计吞吐量线性增长。
+对 CPython 子进程后端(packages/experimental/code-runtime-python)的又一轮评审在 binding 分发、校验、完成值计量与帧解析路径上浮出七项发现。其一,回复积压上限只计数已解析的调用——`pendingReplies` 在 binding 的 `await` 解析后才增长——因此向 promise 永不结算的 binding 洪泛调用的子进程会每个帧累积一个异步闭包直到墙钟,却始终不触发该上限。其二,`validateBindings` 多次读取 `errorClass.name`、`errorClass.memberNameProperty` 与 `namespace.global`,并把原始 errorClass 对象保留到引导帧,其 `JSON.stringify` 在校验后重读该对象:getter 在校验时返回合法值、在序列化时抛错或返回冲突值,会把 seam 误用拒绝变成 worker-exit,或注入一个未经校验批准的名字。其三,`replyQueue` 在排空进行中从不收缩:排空循环把已消费槽位清成 `undefined`,但 `length`(及其后备存储)继续增长,因此以恰好能让排空持续存活却永不排空的速率读取回复的子进程,会让数组随累计吞吐量线性增长。其四,完成值计量器 `checkDoneValue` 把开放容器的每个成员压入显式工作栈,因此接近帧上限的宽完成值(数百万成员)会把同等数量的引用复制进栈——在已解析值之上再占 O(width) 辅助内存——在解析成功后 OOM 终止宿主。其五,与超过 1024 个调用帧处于同一 data 事件的 done 帧会在批后调用积压检查运行之前结算运行(该检查在 settled 后为空操作),因此子进程可以「成功」完成却留下未结算闭包。其六,子端 `_dump_string` 把拼写出来的代理项对折叠成星面码点,因此两个不同的 Python 字典键——`"\ud83d\ude00"` 与 `"\U0001f600"`——编码成同一个 JSON 成员,宿主的 `JSON.parse` 会静默丢弃其一,违反完成值与 binding 参数的 lossless JSON 承诺。其七,加载门限制的是子端在 `RLIMIT_AS` 下的构建与编码,而非宿主的 `JSON.parse`:合法配置的接近帧上限宽完成值(如 50 MiB 预算下的 300 万键字典)会在宿主属性存储中物化其原始字节的若干倍,因此受限堆宿主(如 `--max-old-space-size=256`)会在解析期间以进程级 OOM 死亡——早于 `checkDoneValue`(它只能看到已解析值)拒绝它。
## Decision
-### 在途 binding 调用限制为 1024
+### 在途 binding 调用限制为 1024,每个宏任务在微任务排空后检查一次
-`case 'call'` 在分发前对在途 binding 调用计数(`pendingCalls`),并在异步体的 `finally` 中释放槽位,覆盖回复已写入、解析被拒绝与结算后丢弃三种出口。计数达到 `MAX_PENDING_REPLIES` 时,运行以带 call-backlog 消息的 `worker-exit` 结算,与回复积压一样限制在途闭包。这是计数上限而非字节上限。
+`case 'call'` 在分发前对在途 binding 调用计数(`pendingCalls`),并在异步体的 `finally` 中释放槽位,覆盖回复已写入、解析被拒绝与结算后丢弃三种出口。data 处理器经 `setImmediate`(用标志去重)为每个宏任务调度一次批后检查:它在该宏任务的微任务之后运行,因此看到的是真实在途计数——实时计数被本批自身帧抬高(`finally` 尚未运行),而按 `data` 事件刷新的快照在 flowing 模式下同一宏任务内连续发出多个事件(微任务尚未排空)时会过期。计数**严格大于** `MAX_PENDING_REPLIES`(恰好 1024 个在途调用允许)时,运行以带 call-backlog 消息的 `worker-exit` 结算。检查在 `settled` 后为空操作,因此 `done` 或 `log` 帧优先于上限:启动了 binding 调用却未 await 就返回的程序仍以其值正常完成。`done` 处理器在接受帧之前独立复查计数,封住与洪泛同批的 done 会在批后检查触发前结算运行的窗口。这是计数上限而非字节上限。
### binding 元数据在校验与引导帧之前快照为纯值
@@ -22,20 +22,49 @@ Status: implemented
`drainReplies` 在 `head` 达到 `MAX_PENDING_REPLIES` 时压缩已消费前缀(`replyQueue.splice(0, head); head = 0`)。该 splice 为 O(head),每消费一上限的帧执行一次——均摊到每条回复为 O(1)——使永不排空的排空把后备存储限制在 O(积压 + 上限)。
+### 完成值计量器每层持一个游标遍历宽值
+
+`checkDoneValue` 现在为每个开放容器持一个游标(根与数组用 values 迭代器,对象用 entries 迭代器——key 的转义字节在该 entry 到达时计量),与 `hasNonLosslessNumber` 及子端 `_check_done_value` 已用的形态一致。字节预算仍然限制遍历:每个成员在游标产出时计量,宽度下界检查会在游标下降之前拒绝超预算容器。辅助状态为 O(depth) 而非 O(width),因此接近帧上限的宽完成值精确计量,而不是复制数百万引用。`encodeJsonPlain` 保留其每容器任务栈——该栈为 O(width) 但只持有引用,而编码输出本身即 O(total bytes),与结果同量级,豁免已在注释中说明。
+
+### 帧解析上限受宿主堆约束
+
+原始字节帧上限并不保护宿主进程:`JSON.parse` 一个宽对象帧会在属性存储中物化其原始字节的若干倍。**最坏形态是大量短唯一键的字典**——迫使 V8 进入字典模式属性存储并为每个键内化一个字符串——1 GiB 堆上 3,000,000 键帧(约 31 MB 原始)实测 6.4 倍且随键数上升(平铺唯一键数组约 4 倍、重复键字典约 3 倍);256 MiB 堆直接在该帧上 OOM。每个实例执行的有效上限为 `min(协议上限, floor((heap_size_limit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))`,系数为 16——实测最坏形态的约 2.5 倍安全余量——由宿主配置的堆上限推导(`--max-old-space-size` 经 `v8.getHeapStatistics().heap_size_limit` 生效)。默认 Node 堆(约 4 GiB)永不收紧;受限宿主会降低上限,加载门拒绝任何帧无法被安全解析的预算,在加载期响亮失败而非在解析中途 OOM 宿主。子端的 `RLIMIT_AS` 门是另一资源,保持不变。
+
+### 折叠为同一 JSON 成员的字典键按非 lossless 拒绝
+
+子端 `_dump_string` 把拼写出来的代理项对折叠成星面码点,使宿主的 UTF-16 字符串(两个码元与单个字符是同一字符串)按相同成本计量。Python 可以把两种拼写作为不同键持有,因此包含 `"\ud83d\ude00"` 与 `"\U0001f600"` 的字典会发出两个同键成员,宿主的 `JSON.parse` 会静默丢弃其一。两条 lossless-JSON 遍历(binding 参数的 `_lossless_json_violation` 与完成值的 `_check_done_value`)现在用每字典 seen 集跟踪合并后的键——O(keys),与字典本身同量级——在编码前把冲突判为非 lossless。
+
## Testing
- `tests/runtime.spec.ts`——敌意子进程向永不结算的 binding(`await new Promise(() => {})`)洪泛 5000 个连续调用;运行在远早于 `maxWallMs` 时以带 call-backlog 消息的 `worker-exit` 结算。已实测失败前置:没有该上限时运行在墙钟处超时。
+- `tests/runtime.spec.ts`——合法的 `asyncio.gather` 并发 1025 个即时调用并全部完成:批后检查看到的是 `finally` 排空后的计数,而逐帧检查可能被单次 64 KiB 读取中的第 1025 帧误触发。
+- `tests/runtime.spec.ts`——程序调度 1024 个慢 binding(仍未结算)并返回 `"done"` 时以其值正常完成:done 帧结算运行后检查为空操作,且严格阈值允许恰好 1024 个在途调用。已实测失败前置:无条件的事件边界检查恰好在该用例上失败。
+- `tests/runtime.spec.ts`——单次 62 KiB 写入的 1025 个紧凑调用对抗永不结算的 binding,在远早于 `maxWallMs` 时以 `worker-exit` 结算,即使之后不再有帧到达:每宏任务检查在该批之后触发。已实测失败前置:按事件刷新的接纳快照在没有后续帧时永不复查,运行等到墙钟。
+- `tests/runtime.spec.ts`——单次写入的 1025 个紧凑调用**外加同批 done 帧**以 `worker-exit` 结算:done 处理器在接受帧之前复查计数,而批后检查会在 done 结算运行后空操作。已实测失败前置:没有 done 复查时运行成功完成并留下未结算闭包。
+- `tests/runtime.spec.ts`——1300 个即时调用的突发(帧跨管道读取拆分)全部完成:检查在该宏任务的所有 `finally` 之后运行,而按事件快照在 flowing 模式同宏任务内多个事件、微任务未排空时会看到过期的在途计数。
+- `tests/runtime.spec.ts`——字典同时含 `"\ud83d\ude00"` 与 `"\U0001f600"` 两个键的完成值与 binding 参数按非 lossless 拒绝(invalid-output / lossless-JSON 调用拒绝):两种拼写折叠为一个 JSON 成员,宿主的 JSON.parse 会静默折叠。已实测失败前置:没有碰撞检查时两者都以丢键 round-trip。
+- `tests/protocol.spec.ts`——`hostFrameParseCeiling` 从模拟堆推导有效解析上限:默认堆上协议上限约束,约 304 MiB 宿主上限得出 15 MiB 上限,极小堆几乎不留下解析空间。
+- `tests/runtime.spec.ts`——128 MiB old space 的子 node 在加载期拒绝 50 MiB 完成值预算(`maxValueBytes must not exceed`),而地址空间门单独会放行。已实测失败前置:忽略堆上限时该预算正常加载。
+- `tests/runtime.spec.ts`——128 MiB old space 的子 node 构造帧恰在推导上限处的宽唯一键字典并解析,存活。已实测失败前置:解析系数回退到 8 时推导上限翻倍,同一子进程在解析中 OOM。
+- 套件的临时 fixture(`dsh-bad-bin-`、`dsh-fake-bin-`、`dsh-rlimit-*`、`dsh-staging-`、heartbeat 目录、wrapper 脚本)现登记并在每个测试后移除,重复运行不再在共享 tmpdir 累积 `dsh-*` 工件。
- 两个 namespace 形态测试——`errorClass.name`/`errorClass.memberNameProperty` 与 `namespace.global` 经由第二次读取即抛错或改变的 getter 暴露;运行正常引导并完成,且每个字段恰好读取一次(已断言)。已实测失败前置:没有快照时,errorClass getter 在校验内抛错,global getter 注入不同名字,程序以 `NameError` 失败。
-- `tests/runtime.spec.ts`——子进程洪泛回复超过可写高水位线的调用,阻塞第一次排空写入;恢复的排空在第二波调用仍待发时消费超过压缩上限的积压,子进程直接读取 fd 3(阻塞回复泵)验证全部 1524 条回复送达。已实测失败前置:移除待发帧的 splice 会丢掉第二波回复,运行挂到墙钟。
+- `tests/runtime.spec.ts`——子进程洪泛回复超过可写高水位线的调用,阻塞第一次排空写入;恢复的排空在第二波调用仍待发时消费超过压缩上限的积压,子进程直接读取 fd 3(阻塞回复泵)验证全部 1524 条回复送达。无固定睡眠:子进程的读取以排空的投递速率节流,宿主在毫秒内完成一波推送,因此压缩点队列必然已满;换行按块计数(每条回复恰好一个),绝不重扫累计总量——那会是 O(n²)。已实测失败前置:移除待发帧的 splice 会丢掉第二波回复,运行挂到墙钟。
+- `tests/protocol.spec.ts`——2,000,000 元素数组与 100,000 键对象以精确序列化大小计量、少一个字节即拒绝,并仍能发现尾部的 `-0`,钉住游标遍历的广度行为。
## Alternatives considered
**暂停 fd-3 读侧而非计数在途调用。** 拒绝:暂停读取也会让子进程在最后一个调用后可能发送的 `done` 与 `log` 帧处理停滞,改变结算时机;计数上限是确定性的,且与既有帧上限模式一致。
+**逐帧检查在途计数。** 拒绝:`finally` 在微任务队列上运行,微任务只在宏任务结束时排空,因此单个事件携带超过 `MAX_PENDING_REPLIES` 个合法调用帧时,即使每个 binding 都立即结算,逐帧检查也会误触发。
+
+**在调用接纳处对照按事件刷新的快照检查。** 两次拒绝:无条件的事件边界检查会把程序返回未 await 调用时的 `done` 帧改判为 worker-exit;按 `data` 事件刷新的快照在 flowing 模式同一宏任务内多个事件时过期(第二块携带超过上限的在途调用的合法突发会被误杀)。每宏任务在微任务排空后检查真实计数,在两个轴上都不依赖分块;严格阈值让恰好 `MAX_PENDING_REPLIES` 个在途调用正常完成。
+
**只读取一次元数据但保留原始 errorClass 对象。** 拒绝:引导帧的 `JSON.stringify` 会重新调用 getter;只有存入普通副本才能保证两个阶段读到相同的值。
**依赖排空的 `finally` 重置来回收队列内存。** 拒绝:重置只在排空结束时运行;永不排空的排空会持续增长。排空进行中的压缩在排空存活期间限制后备存储。
+**保留完成值计量器的显式成员栈。** 拒绝:字节预算限制遍历本身,但不限制栈的引用数——那是 O(width)——接近帧上限的宽值会复制数百万引用,在解析成功后 OOM 宿主;每层游标形态保持 O(depth) 状态。
+
## Consequences
-在途 binding 闭包与回复积压一样受限,向永不结算的 binding 洪泛调用的子进程会让运行提前失败,而不是把闭包累积到墙钟。引导帧序列化校验批准的元数据,与 getter 状态无关。回复队列的后备存储在持续的部分排空期间保持有界;压缩是内部内存卫生,无可观察的行为变化。
+在途 binding 闭包与回复积压一样受限,向永不结算的 binding 洪泛调用的子进程会让运行提前失败,而不是把闭包累积到墙钟;合法的并发大 gather 不受影响(上限在微任务队列排空后检查)。引导帧序列化校验批准的元数据,与 getter 状态无关。回复队列的后备存储在持续的部分排空期间保持有界;压缩是内部内存卫生,无可观察的行为变化。完成值计量器以 O(depth) 辅助状态保持精确的字节核算,宽完成值不再因计量本身 OOM 宿主。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index e8077b5dfd..dcf237f60a 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: d83f1da52a85bf63e8cbe3cbfeb8b4383e42b1c9
-config-catalog.zh.md: eb875e59ddf40c4cb71744a57fc5cc5e4563e2ba
+config-catalog.md: 580674b39f1a1a406a9a78e2a9a0e750f4f10c76
+config-catalog.zh.md: 1d077c9b145b19680465a028949b81700e4e9cb7
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index d83f1da52a..580674b39f 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -573,7 +573,9 @@ export interface Config {
* under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
* interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
- * runtime clamp.
+ * runtime clamp. Also bounded at load by the host's configured heap like
+ * `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
+ * envelope.
*/
maxLogBytes?: number
/**
@@ -581,7 +583,12 @@ export interface Config {
* the same way `maxLogBytes` is: the child builds and encodes a near-budget
* value under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
- * interpreter baseline.
+ * interpreter baseline. Both budgets are ALSO bounded at load by the host's
+ * configured heap: the effective frame cap (the protocol cap, or a lower
+ * heap-derived ceiling when the host heap cannot safely parse a near-cap
+ * frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
+ * whose honest frame could OOM the host's own JSON.parse is rejected up
+ * front.
*/
maxValueBytes?: number
/** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
@@ -594,7 +601,7 @@ export interface Config {
}
```
-Source: [`packages/experimental/code-runtime-python/src/index.ts:43`](../packages/experimental/code-runtime-python/src/index.ts)
+Source: [`packages/experimental/code-runtime-python/src/index.ts:42`](../packages/experimental/code-runtime-python/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index eb875e59dd..1d077c9b14 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -358,65 +358,6 @@ export interface Config {
来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
-
-
-## `@deepseek-ai/dsh-experimental-code-runtime-python`
-
-```ts config-catalog
-/** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */
-export interface Config {
- /**
- * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child
- * rejects a float). The child sets the soft limit to `cpuSeconds` and the
- * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft
- * limit, which the host classifies as a `timeout`; the +1s hard limit is a
- * SIGKILL backstop for a program that traps SIGXCPU. Granularity is seconds —
- * a coarser counterpart to the worker backend's millisecond `computeMs`.
- */
- cpuSeconds?: number
- /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */
- maxWallMs?: number
- /**
- * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
- * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
- * every process at exec exceeds any practical cap and the kernel rejects
- * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
- * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (this static check
- * runs on Darwin too, where only the runtime `setrlimit` is skipped): each
- * budget times a worst-case Unicode expansion must fit this byte count minus a
- * fixed interpreter baseline, so a near-budget output cannot breach the address
- * space during the child's build-and-encode.
- */
- addressSpaceMb?: number
- /**
- * Shared byte budget for captured log text (host-side ledger). Bounded at load
- * against `addressSpaceMb`: the child builds and encodes a near-budget entry
- * under RLIMIT_AS with several copies live at once, so this cap times the
- * worst-case Unicode expansion must fit the address space left after the
- * interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
- * runtime clamp.
- */
- maxLogBytes?: number
- /**
- * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
- * the same way `maxLogBytes` is: the child builds and encodes a near-budget
- * value under RLIMIT_AS with several copies live at once, so this cap times the
- * worst-case Unicode expansion must fit the address space left after the
- * interpreter baseline.
- */
- maxValueBytes?: number
- /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
- graceMs?: number
- /**
- * Absolute path or basename of the CPython interpreter to spawn. Resolved
- * through `PATH` when a basename is given.
- */
- pythonBin?: string
-}
-```
-
-来源:[`packages/experimental/code-runtime-python/src/index.ts:43`](../packages/experimental/code-runtime-python/src/index.ts)
-
## `@deepseek-ai/dsh-code-runtime-worker-thread`
@@ -598,6 +539,72 @@ export interface Config {
来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts)
+
+
+## `@deepseek-ai/dsh-experimental-code-runtime-python`
+
+```ts config-catalog
+/** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */
+export interface Config {
+ /**
+ * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child
+ * rejects a float). The child sets the soft limit to `cpuSeconds` and the
+ * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft
+ * limit, which the host classifies as a `timeout`; the +1s hard limit is a
+ * SIGKILL backstop for a program that traps SIGXCPU. Granularity is seconds —
+ * a coarser counterpart to the worker backend's millisecond `computeMs`.
+ */
+ cpuSeconds?: number
+ /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */
+ maxWallMs?: number
+ /**
+ * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
+ * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
+ * every process at exec exceeds any practical cap and the kernel rejects
+ * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
+ * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (this static check
+ * runs on Darwin too, where only the runtime `setrlimit` is skipped): each
+ * budget times a worst-case Unicode expansion must fit this byte count minus a
+ * fixed interpreter baseline, so a near-budget output cannot breach the address
+ * space during the child's build-and-encode.
+ */
+ addressSpaceMb?: number
+ /**
+ * Shared byte budget for captured log text (host-side ledger). Bounded at load
+ * against `addressSpaceMb`: the child builds and encodes a near-budget entry
+ * under RLIMIT_AS with several copies live at once, so this cap times the
+ * worst-case Unicode expansion must fit the address space left after the
+ * interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
+ * runtime clamp. Also bounded at load by the host's configured heap like
+ * `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
+ * envelope.
+ */
+ maxLogBytes?: number
+ /**
+ * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
+ * the same way `maxLogBytes` is: the child builds and encodes a near-budget
+ * value under RLIMIT_AS with several copies live at once, so this cap times the
+ * worst-case Unicode expansion must fit the address space left after the
+ * interpreter baseline. Both budgets are ALSO bounded at load by the host's
+ * configured heap: the effective frame cap (the protocol cap, or a lower
+ * heap-derived ceiling when the host heap cannot safely parse a near-cap
+ * frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
+ * whose honest frame could OOM the host's own JSON.parse is rejected up
+ * front.
+ */
+ maxValueBytes?: number
+ /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
+ graceMs?: number
+ /**
+ * Absolute path or basename of the CPython interpreter to spawn. Resolved
+ * through `PATH` when a basename is given.
+ */
+ pythonBin?: string
+}
+```
+
+来源:[`packages/experimental/code-runtime-python/src/index.ts:42`](../packages/experimental/code-runtime-python/src/index.ts)
+
## `@deepseek-ai/dsh-experimental-inspector`
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 16c4e94879..0be6d66f35 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.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 docs/module-graph.md
-module-graph.md: 6f576cbfe72b2b7895d2e350f8f80d51048cd6df
-module-graph.zh.md: 77094944c733ea07b178d6600d185f90ba10d2c8
+module-graph.md: a56333ae6c9c93d67ecd040ded14b66d6c1ce06b
+module-graph.zh.md: 3acadd4fd8f35cf2cecb9d1f60676baae763664a
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 6f576cbfe7..a56333ae6c 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -427,6 +427,10 @@ flowchart TD
pkg_subprocess_e2b --> pkg_invariants
pkg_subprocess_e2b --> pkg_subprocess
pkg_subprocess_e2b --> pkg_timeout
+ pkg_experimental_code_runtime_python --> pkg_code_runtime
+ pkg_experimental_code_runtime_python --> pkg_invariants
+ pkg_experimental_code_runtime_python --> pkg_timeout
+ pkg_experimental_code_runtime_python --> pkg_util_values
pkg_experimental_inspector --> pkg_client_modules
pkg_experimental_inspector --> pkg_host_webserver
pkg_experimental_inspector --> pkg_invariants
@@ -475,10 +479,6 @@ flowchart TD
pkg_code_runtime_worker_thread --> pkg_invariants
pkg_code_runtime_worker_thread --> pkg_session
pkg_code_runtime_worker_thread --> pkg_timeout
- pkg_experimental_code_runtime_python --> pkg_code_runtime
- pkg_experimental_code_runtime_python --> pkg_invariants
- pkg_experimental_code_runtime_python --> pkg_session
- pkg_experimental_code_runtime_python --> pkg_timeout
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox --> pkg_invariants
@@ -1403,6 +1403,7 @@ flowchart TD
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1418,7 +1419,6 @@ flowchart TD
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
-| [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 77094944c7..3acadd4fd8 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -429,6 +429,10 @@ flowchart TD
pkg_subprocess_e2b --> pkg_invariants
pkg_subprocess_e2b --> pkg_subprocess
pkg_subprocess_e2b --> pkg_timeout
+ pkg_experimental_code_runtime_python --> pkg_code_runtime
+ pkg_experimental_code_runtime_python --> pkg_invariants
+ pkg_experimental_code_runtime_python --> pkg_timeout
+ pkg_experimental_code_runtime_python --> pkg_util_values
pkg_experimental_inspector --> pkg_client_modules
pkg_experimental_inspector --> pkg_host_webserver
pkg_experimental_inspector --> pkg_invariants
@@ -477,10 +481,6 @@ flowchart TD
pkg_code_runtime_worker_thread --> pkg_invariants
pkg_code_runtime_worker_thread --> pkg_session
pkg_code_runtime_worker_thread --> pkg_timeout
- pkg_experimental_code_runtime_python --> pkg_code_runtime
- pkg_experimental_code_runtime_python --> pkg_invariants
- pkg_experimental_code_runtime_python --> pkg_session
- pkg_experimental_code_runtime_python --> pkg_timeout
pkg_persona --> pkg_invariants
pkg_persona --> pkg_system_prompt
pkg_sandbox --> pkg_invariants
@@ -1405,6 +1405,7 @@ flowchart TD
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1420,7 +1421,6 @@ flowchart TD
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
-| [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
diff --git a/packages/experimental/code-runtime-python/README.i18n.yaml b/packages/experimental/code-runtime-python/README.i18n.yaml
index 09426bcc0d..7a236ab04b 100644
--- a/packages/experimental/code-runtime-python/README.i18n.yaml
+++ b/packages/experimental/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/experimental/code-runtime-python/README.md
-README.md: 1009c150a320e23811bae01e989e82cefeb9b907
-README.zh.md: b3dd8803855b9f579f2d1cfdd155ff3691b4573b
+README.md: 5816760c67767bec01d138b8cf803446e897c5b1
+README.zh.md: c6dd0a604e211e61ab3ccb3ea13a2571d62100bc
diff --git a/packages/experimental/code-runtime-python/README.md b/packages/experimental/code-runtime-python/README.md
index 1009c150a3..5816760c67 100644
--- a/packages/experimental/code-runtime-python/README.md
+++ b/packages/experimental/code-runtime-python/README.md
@@ -25,11 +25,11 @@ English | [中文](README.zh.md)
## Use this package
-Choose this package to run Python model code through the code-runtime seam: register `PythonCodeRuntime` with `dsh-tools` and `run()` executes each program in a fresh `python3 -I` subprocess, resolving with `result.value` on success and `result.error` on failure (the orthogonal `CodeRunFailure.kind` taxonomy classifies parse failures, thrown exceptions, invalid completions, output overflows, budget expiry, aborts, and substrate death). It rejects only for seam misuse — a malformed binding namespace, or a call after disposal. Configuration is rejected at load: 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, an `addressSpaceMb`/output-budget pair whose worst-case peak would breach `RLIMIT_AS`, and a `pythonBin` that is not an executable regular file — an explicit path (absolute or containing `/`) is judged directly, a bare name is judged against `PATH`.
+Choose this package to run Python model code through the code-runtime seam: register `PythonCodeRuntime` with `dsh-tools` and `run()` executes each program in a fresh `python3 -I` subprocess, resolving with `result.value` on success and `result.error` on failure (the orthogonal `CodeRunFailure.kind` taxonomy classifies parse failures, thrown exceptions, invalid completions, output overflows, budget expiry, aborts, and substrate death). It rejects only for seam misuse — a malformed binding namespace, or a call after disposal. Configuration is rejected at load: 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 (the frame cap is lowered when the host's configured heap cannot safely parse a near-cap frame — the parse of a wide object costs several times its raw bytes), an `addressSpaceMb`/output-budget pair whose worst-case peak would breach `RLIMIT_AS`, and a `pythonBin` that is not an executable regular file — an explicit path (absolute or containing `/`) is judged directly, a bare name is judged against `PATH`.
### What you get
-The package's default export is the `PythonCodeRuntime` plugin. Its public surface also re-exports the host-side protocol vocabulary: `validateChildFrame` (rebuilds every inbound frame), the lossless-JSON codec and meters (`encodeJsonPlain`, `checkDoneValue`, `hasUnsafeIntegerToken`, `hasNonLosslessNumber`), `logTruncationMarker` (the shared truncation-marker text), plus `resolvePythonBin` (interpreter lookup against the current `PATH`), `readProcessStart` (process-start statistics for tests), and `detachResidual` (a test seam for the settled run's resource cleanup). Every cap is a validated `Config` field with a default: `cpuSeconds` (60), `maxWallMs` (600000), `addressSpaceMb` (512, not applied on Darwin), `maxLogBytes` (65536), `maxValueBytes` (32768), `graceMs` (3000), and `pythonBin` (`python3`, resolved before the child spawns with an empty environment: an explicit path must be an executable regular file, a bare name must resolve on `PATH`; either failure is rejected at load, distinguishing 'is not an executable regular file' from 'does not resolve on PATH' instead of silently falling to the platform default `PATH`).
+The package's default export is the `PythonCodeRuntime` plugin. Its public surface also re-exports the host-side protocol vocabulary: `validateChildFrame` (rebuilds every inbound frame), the lossless-JSON codec and meters (`encodeJsonPlain`, `checkDoneValue`, `hasUnsafeIntegerToken`, `hasNonLosslessNumber`), `logTruncationMarker` (the shared truncation-marker text), plus `resolvePythonBin` (interpreter lookup against the current `PATH`), `readProcessStart` (process-start statistics for tests), `detachResidual` (a test seam for the settled run's resource cleanup), and `hostFrameParseCeiling` (the heap-derived frame parse cap a given heap limit admits). Every cap is a validated `Config` field with a default: `cpuSeconds` (60), `maxWallMs` (600000), `addressSpaceMb` (512, not applied on Darwin), `maxLogBytes` (65536), `maxValueBytes` (32768), `graceMs` (3000), and `pythonBin` (`python3`, resolved before the child spawns with an empty environment: an explicit path must be an executable regular file, a bare name must resolve on `PATH`; either failure is rejected at load, distinguishing 'is not an executable regular file' from 'does not resolve on PATH' instead of silently falling to the platform default `PATH`).
### The wire
@@ -37,7 +37,7 @@ Frames travel on the child's fd 3 as JSON-lines — one object per line — so s
### What can go wrong
-Host-side validation drops junk without throwing, so a malformed or forged frame never crashes the host process: `validateChildFrame` returns `undefined` for anything that does not rebuild cleanly, a non-number call id can never be echoed into a reply, and forged extra fields never ride along. A completion value that is not lossless JSON, or that exceeds the configured byte budget, is rejected explicitly (`non-lossless` / `over-budget`) rather than silently rounded or truncated. An fd-3 frame whose raw length exceeds 64 MiB settles the run as a `worker-exit` (the receive path caps raw frames before `toString`/`JSON.parse` so a compact wide frame cannot decode to far more host memory than its wire bytes admitted).
+Host-side validation drops junk without throwing, so a malformed or forged frame never crashes the host process: `validateChildFrame` returns `undefined` for anything that does not rebuild cleanly, a non-number call id can never be echoed into a reply, and forged extra fields never ride along. A completion value that is not lossless JSON, or that exceeds the configured byte budget, is rejected explicitly (`non-lossless` / `over-budget`) rather than silently rounded or truncated. An fd-3 frame whose raw length exceeds the effective frame parse cap (64 MiB, or lower when the host's configured heap cannot safely parse a near-cap frame — see `hostFrameParseCeiling`) settles the run as a `worker-exit` (the receive path caps raw frames before `toString`/`JSON.parse` so a compact wide frame cannot decode to far more host memory than its wire bytes admitted).
-----
@@ -118,7 +118,7 @@ These limits define what the package does and does not cover; they are current p
- **The truncation-marker text and the tempdir prefix keep the pre-rename short names** — the marker `[dsh-code-runtime-python] log capture truncated at bytes` and the `dsh-code-runtime-python-` tempdir prefix are byte-anchored by tests and are independent of the npm package name; promotion (dropping the `experimental-` prefix) does not rename them.
- **`run()` is one-shot** — `logs` become available only after `CodeRunResult` resolves; there is no streaming-log or progress interface for output produced by a running program.
- **No state persists across runs** — every request executes in a fresh subprocess; a persistent REPL-style kernel stays deferred until a backend brings its own logging scheme.
-- **An fd-3 frame whose raw length exceeds 64 MiB settles the run as a worker-exit** — `maxLogBytes`/`maxValueBytes` are load-bounded to the same parser cap so an honest child's frames always fit; a model-constructed binding ARGUMENT above 64 MiB (a value with no seam-level budget) trips the same cap — an accepted residual of the OOM guard.
+- **An fd-3 frame whose raw length exceeds the effective frame parse cap settles the run as a worker-exit** — the cap is 64 MiB, or lower when the host's configured heap cannot safely parse a near-cap frame (`hostFrameParseCeiling`); `maxLogBytes`/`maxValueBytes` are load-bounded to the same cap so an honest child's frames always fit; a model-constructed binding ARGUMENT above the cap (a value with no seam-level budget) trips it too — an accepted residual of the OOM guard.
- **A child that stops reading its replies settles the run as a worker-exit once the reply backlog passes 1024 frames** — the host writes replies one at a time, waiting for `drain` when the pipe is full; a child that keeps sending calls without consuming replies would otherwise grow the retained backlog (and the binding results it pins) until the wall clock, so the backlog cap fails the run early. Binding results carry no seam-level byte cap, so this is a count bound, not a byte bound.
- **A child that floods calls against a binding that never settles settles the run as a worker-exit once 1024 calls are in flight** — binding calls are counted before dispatch and released when the async body settles, so a binding whose promise never resolves would otherwise accumulate one async closure per call frame until the wall clock. Like the reply backlog, this is a count bound, not a byte bound.
- **A combined log-and-value peak is not modelled by the load gate** — a model daemon thread that keeps writing while the completion value is metered and framed can add the two peaks in a way no gate admits or rejects; the run dies as `worker-exit`, containment holds, and only the failure classification is degraded.
diff --git a/packages/experimental/code-runtime-python/README.zh.md b/packages/experimental/code-runtime-python/README.zh.md
index b3dd880385..c6dd0a604e 100644
--- a/packages/experimental/code-runtime-python/README.zh.md
+++ b/packages/experimental/code-runtime-python/README.zh.md
@@ -25,11 +25,11 @@ kind: "package-reference"
## 使用本包
-在需要通过 code-runtime seam 运行 Python 模型代码时选择本包:向 `dsh-tools` 注册 `PythonCodeRuntime`,`run()` 就在全新的 `python3 -I` 子进程中执行每个程序,成功时以 `result.value` resolve、失败时以 `result.error` resolve(正交的 `CodeRunFailure.kind` 分类涵盖解析失败、抛出异常、无效完成值、输出溢出、预算到期、中止与执行基底终止);只有 seam 误用才 reject——绑定命名空间畸形,或已释放后仍调用。配置在加载期被拒绝:非 Unix 平台、非正或非整数的预算、低于截断标记下限(64)的 `maxLogBytes`、`setTimeout` 会收敛的定时器值、超过单个 fd-3 帧可承载的预算、最坏峰值会突破 `RLIMIT_AS` 的 `addressSpaceMb`/输出预算组合,以及不是可执行普通文件的 `pythonBin`——显式路径(绝对或含 `/`)直接判定,裸名对照 `PATH` 判定。
+在需要通过 code-runtime seam 运行 Python 模型代码时选择本包:向 `dsh-tools` 注册 `PythonCodeRuntime`,`run()` 就在全新的 `python3 -I` 子进程中执行每个程序,成功时以 `result.value` resolve、失败时以 `result.error` resolve(正交的 `CodeRunFailure.kind` 分类涵盖解析失败、抛出异常、无效完成值、输出溢出、预算到期、中止与执行基底终止);只有 seam 误用才 reject——绑定命名空间畸形,或已释放后仍调用。配置在加载期被拒绝:非 Unix 平台、非正或非整数的预算、低于截断标记下限(64)的 `maxLogBytes`、`setTimeout` 会收敛的定时器值、超过单个 fd-3 帧可承载的预算(宿主的配置堆无法安全解析接近上限的帧时帧上限会降低——宽对象的解析成本是其原始字节的若干倍)、最坏峰值会突破 `RLIMIT_AS` 的 `addressSpaceMb`/输出预算组合,以及不是可执行普通文件的 `pythonBin`——显式路径(绝对或含 `/`)直接判定,裸名对照 `PATH` 判定。
### 你得到什么
-包的默认导出是 `PythonCodeRuntime` 插件。其公开面还重新导出宿主侧协议词汇:`validateChildFrame`(重建每条入站帧)、无损 JSON codec 与计量器(`encodeJsonPlain`、`checkDoneValue`、`hasUnsafeIntegerToken`、`hasNonLosslessNumber`)、`logTruncationMarker`(共享截断标记文本),以及 `resolvePythonBin`(对照当前 `PATH` 的解释器查找)、`readProcessStart`(供测试用的进程启动统计)和 `detachResidual`(已结算运行的资源清理测试 seam)。每个上限都是带默认值并经校验的 `Config` 字段:`cpuSeconds`(60)、`maxWallMs`(600000)、`addressSpaceMb`(512,Darwin 上不生效)、`maxLogBytes`(65536)、`maxValueBytes`(32768)、`graceMs`(3000)与 `pythonBin`(`python3`,在子进程以空环境启动前解析:显式路径必须是可执行普通文件,裸名必须在 `PATH` 上可解析;任一失败都在加载期被拒绝,区分『is not an executable regular file』与『does not resolve on PATH』,而不是静默回退到平台默认 `PATH`)。
+包的默认导出是 `PythonCodeRuntime` 插件。其公开面还重新导出宿主侧协议词汇:`validateChildFrame`(重建每条入站帧)、无损 JSON codec 与计量器(`encodeJsonPlain`、`checkDoneValue`、`hasUnsafeIntegerToken`、`hasNonLosslessNumber`)、`logTruncationMarker`(共享截断标记文本),以及 `resolvePythonBin`(对照当前 `PATH` 的解释器查找)、`readProcessStart`(供测试用的进程启动统计)、`detachResidual`(已结算运行的资源清理测试 seam)与 `hostFrameParseCeiling`(给定堆上限可容纳的堆推导帧解析上限)。每个上限都是带默认值并经校验的 `Config` 字段:`cpuSeconds`(60)、`maxWallMs`(600000)、`addressSpaceMb`(512,Darwin 上不生效)、`maxLogBytes`(65536)、`maxValueBytes`(32768)、`graceMs`(3000)与 `pythonBin`(`python3`,在子进程以空环境启动前解析:显式路径必须是可执行普通文件,裸名必须在 `PATH` 上可解析;任一失败都在加载期被拒绝,区分『is not an executable regular file』与『does not resolve on PATH』,而不是静默回退到平台默认 `PATH`)。
### wire
@@ -37,7 +37,7 @@ kind: "package-reference"
### 可能出错的地方
-宿主侧校验在不抛异常的情况下丢弃垃圾,因此畸形或伪造帧永远不会让宿主进程崩溃:`validateChildFrame` 对任何不能干净重建的内容返回 `undefined`,非数字的 call id 永远不会被回显进 reply,伪造的额外字段永远不会被带走。非无损 JSON 或超过配置字节预算的完成值会被显式拒绝(`non-lossless`/`over-budget`),而不是被静默取整或截断。原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 `worker-exit` 结算(接收路径在 `toString`/`JSON.parse` 之前限制原始帧,紧凑宽帧不能解码出远超其线上字节的宿主内存)。
+宿主侧校验在不抛异常的情况下丢弃垃圾,因此畸形或伪造帧永远不会让宿主进程崩溃:`validateChildFrame` 对任何不能干净重建的内容返回 `undefined`,非数字的 call id 永远不会被回显进 reply,伪造的额外字段永远不会被带走。非无损 JSON 或超过配置字节预算的完成值会被显式拒绝(`non-lossless`/`over-budget`),而不是被静默取整或截断。原始长度超过有效帧解析上限(64 MiB,或当宿主的配置堆无法安全解析接近上限的帧时更低——见 `hostFrameParseCeiling`)的 fd-3 帧会让本次运行以 `worker-exit` 结算(接收路径在 `toString`/`JSON.parse` 之前限制原始帧,紧凑宽帧不能解码出远超其线上字节的宿主内存)。
-----
@@ -116,7 +116,7 @@ kind: "package-reference"
- **binding 回复值没有 seam 级字节或深度上限**——`maxValueBytes` 只计量 done 帧的完成值;宽 binding 回复在宿主侧重建(`snapshotJsonValue` 遍历)并整帧编码,两侧都只受进程内存约束(与没有子进程侧预算的 binding 实参一样)。
- **`run()` 是一次性的**——`logs` 只有在 `CodeRunResult` resolve 后才能获得;没有为运行中程序产生的输出提供流式日志或进度接口。
- **运行之间不保留状态**——每次请求都在全新子进程中执行;持久 REPL 风格内核在某个后端带来自己的日志方案之前保持延期。
-- **原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 worker-exit 结算**——`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。
+- **原始长度超过有效帧解析上限的 fd-3 帧会让本次运行以 worker-exit 结算**——上限为 64 MiB,或当宿主的配置堆无法安全解析接近上限的帧时更低(`hostFrameParseCeiling`);`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一上限,因此诚实子进程的帧总能放得下;模型构造的超过该上限的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。
- **停止读取回复的子进程会在回复积压超过 1024 帧时以 worker-exit 结算运行**——宿主每次写一条回复,管道满时等待 `drain`;只持续发送调用而不消费回复的子进程会让保留的积压(及其钉住的 binding 结果)一直增长到墙钟,因此积压上限让运行提前失败。binding 结果在 seam 层没有字节上限,所以这是计数上限而非字节上限。
- **向永不结算的 binding 洪泛调用的子进程会在 1024 个调用在途时以 worker-exit 结算运行**——binding 调用在分发前计数、异步体结算时释放,否则 promise 永不 resolve 的 binding 会让每个调用帧累积一个异步闭包直到墙钟。与回复积压一样,这是计数上限而非字节上限。
- **组合日志与值的峰值不被加载门建模**——持续写入的模型 daemon 线程与完成值计量、分帧相加的峰值没有任何门会放行或拒绝;运行以 `worker-exit` 告终,隔离成立,只有失败分类降级。
diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json
index 239dd6c744..3ccd8f242b 100644
--- a/packages/experimental/code-runtime-python/package.json
+++ b/packages/experimental/code-runtime-python/package.json
@@ -31,16 +31,16 @@
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-util-values": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-util-values": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
diff --git a/packages/experimental/code-runtime-python/py/bootstrap.py b/packages/experimental/code-runtime-python/py/bootstrap.py
index 3095a770f8..94f27c4332 100644
--- a/packages/experimental/code-runtime-python/py/bootstrap.py
+++ b/packages/experimental/code-runtime-python/py/bootstrap.py
@@ -1883,7 +1883,7 @@ def _check_done_value(value: Any, max_bytes: int):
# forgery before any iteration begins.
exhausted = object()
visit, list_cursor, dict_cursor = 0, 1, 2
- stack: list[tuple[int, Any, Any]] = [(visit, value, None)]
+ stack: list[tuple[int, Any, Any, Any]] = [(visit, value, None, None)]
while stack:
frame = stack.pop()
kind = frame[0]
@@ -1896,10 +1896,10 @@ def _check_done_value(value: Any, max_bytes: int):
# Resume this cursor after the child is fully walked; the child goes
# on top so it is visited next (order does not affect the byte total).
stack.append(frame)
- stack.append((visit, child, None))
+ stack.append((visit, child, None, None))
continue
if kind == dict_cursor:
- container, iterator = frame[1], frame[2]
+ container, iterator, seen = frame[1], frame[2], frame[3]
entry = next(iterator, exhausted)
if entry is exhausted:
on_path.discard(id(container))
@@ -1910,6 +1910,16 @@ def _check_done_value(value: Any, max_bytes: int):
# the encoder emits its real characters.
if type(key) is not str:
return invalid(f"non-string dict key ({type(key).__name__})")
+ # The key's JSON form folds a spelled-out surrogate pair into its
+ # astral code point (`_dump_string`), so two DIFFERENT Python keys —
+ # the two code units and the single character — encode to the same
+ # JSON member, and the host's JSON.parse silently drops one of them.
+ # That is a lossless-JSON violation, so the collision is rejected
+ # here, before any encoding.
+ combined_key = _SURROGATE_PAIR.sub(_combine_surrogate_pair, key)
+ if combined_key in seen:
+ return invalid("duplicate dict key after surrogate-pair combining")
+ seen.add(combined_key)
# The same string lower bound, before escaping the key.
if total + len(key) + 3 > max_bytes:
return over_budget
@@ -1919,7 +1929,7 @@ def _check_done_value(value: Any, max_bytes: int):
if total > max_bytes:
return over_budget
stack.append(frame)
- stack.append((visit, item, None))
+ stack.append((visit, item, None, None))
continue
current = frame[1]
if current is None or type(current) is bool:
@@ -1969,7 +1979,7 @@ def _check_done_value(value: Any, max_bytes: int):
if total + count > max_bytes:
return over_budget
on_path.add(id(current))
- stack.append((list_cursor, current, iter(current)))
+ stack.append((list_cursor, current, iter(current), None))
elif type(current) is dict:
if id(current) in on_path:
return invalid("circular reference")
@@ -1984,7 +1994,10 @@ def _check_done_value(value: Any, max_bytes: int):
if total + count * 4 > max_bytes:
return over_budget
on_path.add(id(current))
- stack.append((dict_cursor, current, iter(current.items())))
+ # The seen-set holds one combined key per member — O(keys), the same
+ # order as the dict itself — so the surrogate-collision check below
+ # can detect two keys that fold to one JSON member.
+ stack.append((dict_cursor, current, iter(current.items()), set()))
else:
# tuple, set, or any other type: not round-trippable JSON.
return invalid(f"unsupported type ({type(current).__name__})")
@@ -2034,12 +2047,13 @@ def _lossless_json_violation(value: Any) -> str | None:
# iterator; children are pulled one at a time.
exhausted = object()
visit, container_cursor = 0, 1
- # A visit frame is (visit, value); a cursor frame is (cursor, container, iterator).
- stack: list[tuple[int, Any, Any]] = [(visit, value, None)]
+ # A visit frame is (visit, value, None, None); a cursor frame is
+ # (cursor, container, iterator, seen-keys-for-dicts).
+ stack: list[tuple[int, Any, Any, Any]] = [(visit, value, None, None)]
while stack:
kind = stack[-1][0]
if kind == container_cursor:
- _, container, iterator = stack[-1]
+ _, container, iterator, seen = stack[-1]
child = next(iterator, exhausted)
if child is exhausted:
# Leaving the container: it is no longer on the current path, so
@@ -2055,9 +2069,19 @@ def _lossless_json_violation(value: Any) -> str | None:
key, child = child
if type(key) is not str:
return f"non-string dict key ({type(key).__name__})"
- stack.append((visit, child, None))
+ # The key's JSON form folds a spelled-out surrogate pair into its
+ # astral code point (`_dump_string`), so two DIFFERENT Python
+ # keys -- the two code units and the single character -- encode
+ # to the same JSON member, and the host's JSON.parse silently
+ # drops one of them. A lossless-JSON violation, rejected here
+ # before any encoding.
+ combined_key = _SURROGATE_PAIR.sub(_combine_surrogate_pair, key)
+ if combined_key in seen:
+ return "duplicate dict key after surrogate-pair combining"
+ seen.add(combined_key)
+ stack.append((visit, child, None, None))
continue
- _, current, _unused = stack.pop()
+ _, current, _unused, _unused2 = stack.pop()
if current is None or type(current) is bool:
continue
if type(current) is str:
@@ -2095,10 +2119,12 @@ def _lossless_json_violation(value: Any) -> str | None:
# Keys are checked as the cursor pulls each entry, not in a
# separate pass: ``current.values()`` would need a second walk,
# and materializing ``items()`` up front allocates one tuple per
- # member -- the very spike the cursor removes.
- stack.append((container_cursor, current, iter(current.items())))
+ # member -- the very spike the cursor removes. The seen-set holds
+ # one combined key per member -- O(keys), the same order as the
+ # dict itself -- for the surrogate-collision check.
+ stack.append((container_cursor, current, iter(current.items()), set()))
else:
- stack.append((container_cursor, current, iter(current)))
+ stack.append((container_cursor, current, iter(current), None))
continue
return f"unsupported type ({type(current).__name__})"
return None
diff --git a/packages/experimental/code-runtime-python/src/index.ts b/packages/experimental/code-runtime-python/src/index.ts
index b9cb276f4b..e97f7350bd 100644
--- a/packages/experimental/code-runtime-python/src/index.ts
+++ b/packages/experimental/code-runtime-python/src/index.ts
@@ -5,10 +5,8 @@
* boundary: model code has bash-equivalent trust, contained by an empty environment, RLIMIT_CPU
* + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
*
- * The package owns the versionless fd-3 wire protocol between the Node host and
- * the CPython subprocess. The protocol's host-side codec and hostile-frame
- * validators are re-exported so every consumer of the wire shares one
- * vocabulary.
+ * The package also owns the versionless fd-3 wire protocol itself; its host-side codec and
+ * hostile-frame validators are re-exported so every consumer of the wire shares one vocabulary.
* @module @deepseek-ai/dsh-experimental-code-runtime-python
*/
@@ -17,12 +15,13 @@ import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFi
import { tmpdir } from 'node:os'
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
+import { getHeapStatistics } from 'node:v8'
import type { Duplex } from 'node:stream'
-import { Context } from 'cordis'
-import z from 'schemastery'
+import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingErrorClass, CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
-import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
+import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
import { checkDoneValue, encodeJsonPlain, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from './protocol.ts'
@@ -70,7 +69,9 @@ export interface Config {
* under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
* interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
- * runtime clamp.
+ * runtime clamp. Also bounded at load by the host's configured heap like
+ * `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
+ * envelope.
*/
maxLogBytes?: number
/**
@@ -78,7 +79,12 @@ export interface Config {
* the same way `maxLogBytes` is: the child builds and encodes a near-budget
* value under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
- * interpreter baseline.
+ * interpreter baseline. Both budgets are ALSO bounded at load by the host's
+ * configured heap: the effective frame cap (the protocol cap, or a lower
+ * heap-derived ceiling when the host heap cannot safely parse a near-cap
+ * frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
+ * whose honest frame could OOM the host's own JSON.parse is rejected up
+ * front.
*/
maxValueBytes?: number
/** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
@@ -309,6 +315,56 @@ const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 12
*/
const INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024
+/**
+ * Worst-case peak host-heap bytes the PARSE of one inbound fd-3 frame can
+ * transiently occupy, expressed as a multiple of the frame's raw bytes.
+ * `JSON.parse` of a wide container materializes the object's property storage
+ * and key strings on top of the raw text; the WORST shape is a dict of many
+ * SHORT UNIQUE keys, which forces V8's dictionary-mode property storage
+ * (~32-64 bytes per entry) plus one interned string per key (header + data)
+ * plus string-table growth: measured 6.4x for a 3,000,000-key frame (~31 MB
+ * raw) on a 1 GiB heap, trending up with key count (a flat unique-key array
+ * is ~4x, a repeated-key dict ~3x). On a constrained heap the parse also
+ * retains the raw frame string while the object builds, so the safety factor
+ * is 16x — ~2.5x over the measured worst shape, ~1.6x over the claimed
+ * GC-headroom bound. Used with the host's configured heap limit to derive the
+ * largest frame whose parse cannot OOM the host process. This bounds the
+ * HOST's parse; {@link OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE} bounds
+ * the CHILD's build and encode under RLIMIT_AS, a different resource. A fixed
+ * safety invariant, not a knob.
+ */
+const HOST_PARSE_WORST_CASE_MULTIPLE = 16
+
+/**
+ * Fixed host-heap headroom reserved for the application itself (the dsh
+ * fiber, plugins, and this runtime's own state) before the frame-parse
+ * multiple claims the rest: the effective frame cap is derived from
+ * `heap_size_limit - HOST_PARSE_BASELINE_BYTES`, so a constrained host's
+ * parse ceiling never spends the application's working set. A fixed safety
+ * margin, not a knob.
+ */
+const HOST_PARSE_BASELINE_BYTES = 64 * 1024 * 1024
+
+/**
+ * The largest inbound fd-3 frame the HOST can parse without risking a
+ * process-level OOM on its current heap: the configured heap limit (honoring
+ * `--max-old-space-size`) minus the application baseline, divided by the
+ * worst-case parse multiple, floored to the protocol frame cap. The
+ * raw-byte cap alone does not protect the heap — `JSON.parse` of a
+ * ≤64 MiB wide-object frame materializes several times that in property
+ * storage — so the effective cap is the smaller of the two. A default Node
+ * heap (~4 GiB) never binds; a constrained host (e.g.
+ * `--max-old-space-size=256` reports a ~300 MiB limit) lowers it to ~29 MiB,
+ * and the load gate rejects budgets that cannot cross it.
+ * @param heapLimit - the host's configured heap limit; the live
+ * `heap_size_limit` when omitted. A parameter so the derivation is unit
+ * testable against simulated heap sizes.
+ * @returns the effective frame parse cap in bytes.
+ */
+export function hostFrameParseCeiling(heapLimit: number = getHeapStatistics().heap_size_limit): number {
+ return Math.min(FRAME_PARSE_CAP_BYTES, Math.floor((heapLimit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))
+}
+
/**
* Interval between process-group liveness probes while settlement waits for an
* escalated SIGKILL to empty the group (see the `killing` branch in
@@ -731,6 +787,11 @@ export class PythonCodeRuntime extends CodeRuntime {
readonly isolation = 'process'
private readonly config: ResolvedConfig
+ // The frame cap this instance enforces: the protocol cap, or the host's
+ // heap-derived parse ceiling when a constrained heap makes the protocol cap
+ // unsafe to parse (see {@link hostFrameParseCeiling}). Computed per
+ // instance so the config gate and the inbound checks agree.
+ private readonly frameParseCapBytes = hostFrameParseCeiling()
private readonly live = new Set()
private disposed = false
@@ -834,10 +895,12 @@ export class PythonCodeRuntime extends CodeRuntime {
// into `CodeRunResult.error.message` and never re-crosses a frame-bounded
// channel, so it is not part of the wire-width bound (see its JSDoc). The
// admissible cap is therefore `parse-cap - envelope`: the receive path
- // rejects raw frames past FRAME_PARSE_CAP_BYTES before decoding (the run
- // settles as a worker-exit; a hostile compact-wide-frame OOM guard), so a
- // budget must not exceed what an honest child's frame can actually carry
- // through that parser.
+ // rejects raw frames past the effective parse cap (`frameParseCapBytes` —
+ // the protocol cap, or the host's heap-derived ceiling when a constrained
+ // heap makes the protocol cap unsafe to parse; see hostFrameParseCeiling)
+ // before decoding (the run settles as a worker-exit; a hostile
+ // compact-wide-frame OOM guard), so a budget must not exceed what an
+ // honest child's frame can actually carry through that parser.
for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
// Require an integer: the child reads these budgets through `int(...)`,
// which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3
@@ -847,9 +910,16 @@ export class PythonCodeRuntime extends CodeRuntime {
if (!Number.isInteger(this.config[key])) {
throw new Error(`dsh-code-runtime-python: config.${key} must be a positive integer (the child reads it as an int, so a float diverges from the host), got ${String(this.config[key])}`)
}
- const limit = FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES
+ const limit = this.frameParseCapBytes - FRAME_ENVELOPE_BYTES
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 fd-3 frame PARSER, which rejects raw frames past ${FRAME_PARSE_CAP_BYTES} bytes before decoding to bound host memory — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
+ // Only a host whose heap is below the protocol cap reaches the
+ // heap-constrained note; the constrained-heap rejection is exercised
+ // by the subprocess load test, but subprocess runs are not
+ // coverage-instrumented, so the note's arm is not schedulable from the
+ // instrumented suite (whose heap never binds).
+ /* v8 ignore next -- the heap-constrained message arm needs a host heap below the protocol cap. */
+ const heapNote = this.frameParseCapBytes < FRAME_PARSE_CAP_BYTES ? ` — this host's heap limits the parse to ${this.frameParseCapBytes} bytes, so the protocol cap of ${FRAME_PARSE_CAP_BYTES} would be unsafe` : ''
+ throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${this.frameParseCapBytes} bytes before decoding to bound host memory${heapNote} — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
}
// Reject a log budget too small to honor: the truncation marker alone
// must serialize within the budget, or a marker-only truncated run
@@ -1421,6 +1491,29 @@ export class PythonCodeRuntime extends CodeRuntime {
// fd 3 between finish() and close must not regrow the host buffer.
/* v8 ignore next -- post-settlement data needs the child to outrace close after we decided. */
if (settled) return
+ // Schedule ONE post-batch outstanding-call check per macrotask. The
+ // check must see the TRUE count — the live count is inflated by this
+ // batch's own frames (the finallys run on the microtask queue, which
+ // drains only when the macrotask ends), and a per-event snapshot is
+ // stale when flowing mode fires several 'data' events within one
+ // macrotask before any microtask drains. setImmediate runs after the
+ // current macrotask's microtasks, so the count is exact; the flag
+ // dedupes the check across the events of one macrotask. The threshold
+ // is STRICT: exactly MAX_PENDING_REPLIES outstanding calls are allowed,
+ // so a program that returns with calls it never awaited still
+ // completes (the done frame settles the run; the check no-ops on
+ // `settled`).
+ if (!postBatchCheckPending) {
+ postBatchCheckPending = true
+ setImmediate(() => {
+ postBatchCheckPending = false
+ /* v8 ignore next -- the done frame can settle the run between the schedule and this callback. */
+ if (settled) return
+ if (pendingCalls > MAX_PENDING_REPLIES) {
+ finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
+ }
+ })
+ }
pendingChunks.push(chunk)
pendingBytes += chunk.length
// Check the counter BEFORE the join, not the joined line afterwards:
@@ -1451,11 +1544,11 @@ export class PythonCodeRuntime extends CodeRuntime {
// of the wire bytes. When this chunk DOES carry a newline the buffer
// holds several frames, so the FIRST-FRAME check below (not this
// counter, which charges them all) decides.
- if (pendingBytes > FRAME_PARSE_CAP_BYTES && !chunk.includes(0x0a)) {
+ if (pendingBytes > this.frameParseCapBytes && !chunk.includes(0x0a)) {
pendingChunks = []
sealedBlocks = []
pendingBytes = 0
- finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } })
+ finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
return
}
// Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the
@@ -1510,11 +1603,11 @@ export class PythonCodeRuntime extends CodeRuntime {
}
firstFrameLen += c.length
}
- if (sawNewline && firstFrameLen > FRAME_PARSE_CAP_BYTES) {
+ if (sawNewline && firstFrameLen > this.frameParseCapBytes) {
pendingChunks = []
sealedBlocks = []
pendingBytes = 0
- finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } })
+ finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
return
}
let buffered = Buffer.concat(sealedBlocks.length > 0 ? [...sealedBlocks, ...pendingChunks] : pendingChunks)
@@ -1699,6 +1792,19 @@ export class PythonCodeRuntime extends CodeRuntime {
admit(message.text)
return
case 'done': {
+ // The call-backlog cap must also hold when the child finishes in
+ // the SAME batch as its flood: the post-macrotask check no-ops once
+ // this done frame settles the run, so a done arriving right after
+ // more than MAX_PENDING_REPLIES call frames in one data event would
+ // otherwise complete successfully with the outstanding closures
+ // left behind (a single sub-64 KiB write can carry 1025 compact
+ // calls plus a done). The strict threshold lets exactly
+ // MAX_PENDING_REPLIES outstanding calls — a program that returned
+ // without awaiting its calls — complete normally.
+ if (pendingCalls > MAX_PENDING_REPLIES) {
+ finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
+ return
+ }
if (message.error) {
finish({ error: { kind: message.error.kind, message: capMessage(message.error.message, this.config.maxValueBytes) } })
return
@@ -1763,17 +1869,12 @@ export class PythonCodeRuntime extends CodeRuntime {
sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) })
return
}
- // A binding that never settles (or resolves too slowly to keep up
- // with the child's call rate) must not let the flood accumulate one
- // async closure per frame until the wall clock: the reply cap only
- // counts resolved calls, so it never trips for in-flight ones.
- // Count the outstanding binding calls here, before dispatch, and
- // release the slot in the body's finally — bounding in-flight
- // closures to MAX_PENDING_REPLIES exactly like the reply backlog.
- if (pendingCalls >= MAX_PENDING_REPLIES) {
- finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
- return
- }
+ // Count the outstanding binding call before dispatch and release the
+ // slot in the async body's finally. The CAP CHECK runs in the data
+ // handler's post-macrotask pass (where the finallys have drained),
+ // not here: a per-frame check would see every frame of one event as
+ // in-flight and false-positive on a legitimate gather of more than
+ // MAX_PENDING_REPLIES instant calls.
pendingCalls += 1
void (async () => {
try {
@@ -1856,9 +1957,14 @@ export class PythonCodeRuntime extends CodeRuntime {
// RESOLVED calls — `pendingReplies` grows after the await — so a child
// flooding calls against a binding that never settles would accumulate
// one async closure per frame until the wall clock without tripping it.
- // Counted here before dispatch and released in the body's finally, the
- // in-flight closures are bounded to the same MAX_PENDING_REPLIES.
+ // Counted here before dispatch and released in the body's finally; the
+ // data handler schedules a post-macrotask check (see there) that settles
+ // the run as worker-exit when the true outstanding count passes
+ // MAX_PENDING_REPLIES.
let pendingCalls = 0
+ // Dedupes the post-batch outstanding-call check across the 'data' events
+ // of one macrotask (see the data handler).
+ let postBatchCheckPending = false
let draining = false
// Resolve when fd 3 can take another frame, OR when it is gone: a pipe
// destroyed under the drain (child exited, close-deadline teardown) never
diff --git a/packages/experimental/code-runtime-python/src/protocol.ts b/packages/experimental/code-runtime-python/src/protocol.ts
index dcf0b1a0c4..44f634329a 100644
--- a/packages/experimental/code-runtime-python/src/protocol.ts
+++ b/packages/experimental/code-runtime-python/src/protocol.ts
@@ -291,6 +291,12 @@ export function logTruncationMarker(maxBytes: number): string {
* @returns the compact JSON encoding.
*/
export function encodeJsonPlain(value: unknown): string {
+ // The task stack holds every member of the currently open containers — O(width)
+ // — but the encoded OUTPUT is itself O(total bytes) and the stack holds only
+ // references, so the walk's auxiliary state is same-order as its result; the
+ // metering walks (checkDoneValue/hasNonLosslessNumber) are the ones that must
+ // stay O(depth), since they can reject a wide payload without producing any
+ // output. Exempted by that same-order argument.
type Task = { text: string } | { value: unknown }
const chunks: string[] = []
const tasks: Task[] = [{ value }]
@@ -430,9 +436,36 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
// classify differently (non-lossless vs over-budget), and the JSDoc promises
// an over-budget value is rejected as over-budget regardless.
let nonLossless = false
- const stack: unknown[] = [value]
- while (stack.length > 0) {
- const current = stack.pop()
+ // One cursor per OPEN container (a values iterator for the root and arrays,
+ // an entries iterator for objects), mirroring hasNonLosslessNumber and the
+ // child's _check_done_value: a wide completion near the frame cap would
+ // otherwise copy every member's reference onto an explicit work stack —
+ // O(width) — OOMing the host after the parse already succeeded. The byte
+ // budget still bounds the walk: each member is metered as its cursor yields
+ // it, and the width lower-bound checks below bail an over-budget container
+ // before the cursor descends.
+ const cursors: Cursor[] = [{ kind: 'values', iter: [value].values() }]
+ while (cursors.length > 0) {
+ // The loop condition guarantees a top cursor.
+ const cursor = cursors.at(-1) as Cursor
+ const step = cursor.iter.next()
+ if (step.done === true) {
+ cursors.pop()
+ continue
+ }
+ let current: unknown
+ if (cursor.kind === 'entries') {
+ // Meter the key's escaped form without allocating it (same reason as the
+ // string branch), then add the colon separator, before the value's own
+ // bytes are counted.
+ const [key, member] = step.value as readonly [string, unknown]
+ const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
+ if (keyBytes === undefined) return { ok: false, reason: 'over-budget' }
+ bytes += keyBytes + 1
+ current = member
+ } else {
+ current = step.value
+ }
if (typeof current === 'number') {
// Flag a non-lossless number but keep counting its encoded bytes: a value
// that is BOTH non-lossless and over-budget must classify as over-budget
@@ -451,13 +484,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
bytes += stringBytes
} else if (Array.isArray(current)) {
// Brackets plus one comma per gap; elements add themselves. Reject
- // BEFORE enqueuing children: every element serializes to at least one
+ // BEFORE the cursor descends: every element serializes to at least one
// byte, so a forged flat array far above the budget fails here without
- // pushing its elements onto the host stack. (The array itself is already
- // materialized by the upstream parse; this only bounds the extra stack.)
+ // the cursor yielding any of them. (The array itself is already
+ // materialized by the upstream parse; this only bounds the extra walk.)
bytes += 2 + (current.length > 1 ? current.length - 1 : 0)
if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' }
- for (const item of current) stack.push(item)
+ cursors.push({ kind: 'values', iter: (current as unknown[]).values() })
} else if (typeof current === 'object' && current !== null) {
const record = current as Record
// Count own keys with for...in + hasOwn. This IS O(keys) — JS has no lazy
@@ -469,15 +502,7 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
for (const key in record) if (Object.hasOwn(record, key)) count += 1
bytes += 2 + (count > 1 ? count - 1 : 0)
if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' }
- for (const key in record) {
- if (!Object.hasOwn(record, key)) continue
- // Meter the key's escaped form without allocating it (same reason as the
- // string branch), then add the colon separator. `+ 1` for the `:`.
- const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
- if (keyBytes === undefined) return { ok: false, reason: 'over-budget' }
- bytes += keyBytes + 1
- stack.push(record[key])
- }
+ cursors.push({ kind: 'entries', iter: ownEntries(record) })
} else {
bytes += Buffer.byteLength(scalarJson(current), 'utf8')
}
@@ -538,6 +563,31 @@ export function hasUnsafeIntegerToken(line: string): boolean {
return false
}
+/**
+ * One open container in checkDoneValue's cursor walk: a values iterator (the
+ * root and arrays) or an entries iterator (objects, so each key's escaped
+ * bytes can be metered when the entry is reached). A cursor bounds the walk's
+ * auxiliary state to O(depth), not O(width).
+ */
+type Cursor =
+ | { kind: 'values'; iter: Iterator }
+ | { kind: 'entries'; iter: Iterator }
+
+/**
+ * Lazily yield one plain object's own enumerable [key, value] entries. The
+ * key escapes are metered when {@link checkDoneValue}'s cursor walk reaches
+ * each entry, so a wide object never materializes a member list: each entry
+ * is produced straight off the already-parsed record, and the escaped key
+ * bytes are counted without building the escaped string.
+ * @param record - a JSON-parse-produced object.
+ * @yields each own enumerable [key, value] pair, in key order.
+ */
+function* ownEntries(record: Record): Generator {
+ for (const key in record) {
+ if (Object.hasOwn(record, key)) yield [key, record[key]]
+ }
+}
+
/**
* Lazily yield one plain object's own enumerable property values. A generator
* (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber}
diff --git a/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts b/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts
index f5499b9a37..ddb23c0af1 100644
--- a/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts
+++ b/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'
import { dirname } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { Context } from 'cordis'
+import { Context } from '@deepseek-ai/cordis'
/**
* A synchronous `proto.write` throw on the fd-3 pipe is the one boot path a real
diff --git a/packages/experimental/code-runtime-python/tests/protocol.spec.ts b/packages/experimental/code-runtime-python/tests/protocol.spec.ts
index 7f50f6df1c..75812a2be4 100644
--- a/packages/experimental/code-runtime-python/tests/protocol.spec.ts
+++ b/packages/experimental/code-runtime-python/tests/protocol.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from '../src/index.ts'
+import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, hostFrameParseCeiling, logTruncationMarker, validateChildFrame } from '../src/index.ts'
describe('logTruncationMarker', () => {
it('names the configured byte budget', () => {
@@ -301,4 +301,46 @@ describe('checkDoneValue', () => {
expect(encodeJsonPlain(v)).toBe('[1152921504606846976]')
expect(checkDoneValue(v, 100)).toEqual({ ok: true, bytes: Buffer.byteLength('[1152921504606846976]', 'utf8') })
})
+
+ it('walks wide arrays and objects one member at a time', () => {
+ // A completion value has a seam byte budget, but the budget alone does not
+ // bound the traversal's AUXILIARY state: a wide value near the frame cap
+ // (millions of members) must not have every member's reference copied onto
+ // a work stack — that O(width) allocation would OOM the host after the
+ // parse already succeeded. The walk holds one cursor per nesting level, so
+ // a wide value meters exactly and a violation anywhere in it is found
+ // wherever it sits.
+ const wideArray = new Array(2_000_000).fill(0) as unknown[]
+ const arrayJson = `[${wideArray.join(',')}]`
+ const arrayExact = Buffer.byteLength(arrayJson, 'utf8')
+ expect(checkDoneValue(wideArray, arrayExact)).toEqual({ ok: true, bytes: arrayExact })
+ expect(checkDoneValue(wideArray, arrayExact - 1)).toEqual({ ok: false, reason: 'over-budget' })
+ // Last element, so the cursor must run the whole breadth lazily to find it.
+ wideArray[wideArray.length - 1] = -0
+ expect(checkDoneValue(wideArray, arrayExact)).toEqual({ ok: false, reason: 'non-lossless' })
+ wideArray[wideArray.length - 1] = 0
+ const wideObject: Record = {}
+ for (let i = 0; i < 100_000; i++) wideObject[`k${i}`] = i
+ const objectExact = Buffer.byteLength(JSON.stringify(wideObject), 'utf8')
+ expect(checkDoneValue(wideObject, objectExact)).toEqual({ ok: true, bytes: objectExact })
+ expect(checkDoneValue(wideObject, objectExact - 1)).toEqual({ ok: false, reason: 'over-budget' })
+ wideObject.last = -0
+ expect(checkDoneValue(wideObject, Buffer.byteLength(JSON.stringify(wideObject), 'utf8'))).toEqual({ ok: false, reason: 'non-lossless' })
+ })
+})
+
+describe('hostFrameParseCeiling', () => {
+ it('caps the parse at the protocol limit on a default heap and lower on a constrained one', () => {
+ // The raw-byte frame cap does not protect the host heap: JSON.parse of a
+ // wide-object frame materializes several times the raw bytes in property
+ // storage, so the effective cap is min(protocol cap, heap-derived
+ // ceiling). A default Node heap (~4 GiB) never binds.
+ expect(hostFrameParseCeiling(4 * 1024 * 1024 * 1024)).toBe(64 * 1024 * 1024)
+ // A constrained host (--max-old-space-size=256 reports a ~304 MiB limit)
+ // derives floor((304 - 64) / 16) = 15 MiB: a 50 MiB budget would be
+ // rejected at load, where the address-space gate alone would admit it.
+ expect(hostFrameParseCeiling(304 * 1024 * 1024)).toBe(15 * 1024 * 1024)
+ // A tiny heap leaves almost no parse room — the load gate fails loud.
+ expect(hostFrameParseCeiling(128 * 1024 * 1024)).toBe(4 * 1024 * 1024)
+ })
})
diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts
index fa32f35dad..7cf34ccb8f 100644
--- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts
+++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts
@@ -1,10 +1,11 @@
-import { existsSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
-import { basename, dirname, join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { basename, dirname, join, resolve } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import { PythonCodeRuntime, readProcessStart, resolvePythonBin } from '../src/index.ts'
+import { PythonCodeRuntime, hostFrameParseCeiling, readProcessStart, resolvePythonBin } from '../src/index.ts'
import { logTruncationMarker } from '../src/protocol.ts'
import type { Config } from '../src/index.ts'
@@ -30,9 +31,16 @@ import type { CodeBindingFunction, CodeJsonValue, CodeRunResult } from '@deepsee
* records the same race and solves it with argv-based identity; recording the
* mkdtempSync results is the fs-mock equivalent.
*/
-const { failNextCopyOf, stagedDirs } = vi.hoisted(() => ({
+const { failNextCopyOf, stagedDirs, tempDirs, tempFiles } = vi.hoisted(() => ({
failNextCopyOf: { value: undefined as string | undefined },
stagedDirs: [] as string[],
+ // Test-created temp dirs/files, registered by the helpers below and removed
+ // after each test: a suite run over real python3 subprocesses must not
+ // permanently accumulate `dsh-*` fixtures in the shared tmpdir (the runtime
+ // cleans its own per-run staging dir; these are the stubs and wrappers the
+ // tests themselves build).
+ tempDirs: [] as string[],
+ tempFiles: [] as string[],
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal()
@@ -71,6 +79,27 @@ function tools(functions: Record) {
return [{ global: 'tools', functions }]
}
+/** Create a test temp dir registered for afterEach removal. */
+async function makeTempDir(prefix: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), prefix))
+ tempDirs.push(dir)
+ return dir
+}
+
+/** Synchronous variant of {@link makeTempDir} for the PATH-stub fixtures. */
+function makeTempDirSync(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix))
+ tempDirs.push(dir)
+ return dir
+}
+
+// Remove every fixture this file created, so repeated runs do not accumulate
+// `dsh-*` directories and wrappers in the shared tmpdir.
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+ for (const file of tempFiles.splice(0)) rmSync(file, { force: true })
+})
+
describe('PythonCodeRuntime — seam descriptors and misuse', () => {
it('registers the seam descriptors', async () => {
const { runtime } = await setup()
@@ -152,6 +181,66 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
await boundary.dispose()
})
+ it('rejects a completion budget whose frame a constrained host heap cannot safely parse', async () => {
+ // The load gate bounds the CHILD's build-and-encode under RLIMIT_AS; it
+ // does not bound the HOST's JSON.parse, which materializes several times a
+ // wide frame's raw bytes in property storage. In a child node with a
+ // 128 MiB old space the heap-derived frame cap is ~14 MiB, so a 50 MiB
+ // budget is rejected at load even though the address-space gate alone
+ // would admit it (50 MiB * 12 = 600 MiB < 1 GiB - 64 MiB).
+ const script = [
+ "import { Context } from '@deepseek-ai/cordis'",
+ "import { PythonCodeRuntime } from './packages/experimental/code-runtime-python/src/index.ts'",
+ 'const ctx = new Context()',
+ 'try {',
+ ' await ctx.plugin(PythonCodeRuntime, { maxValueBytes: 50 * 1024 * 1024, addressSpaceMb: 1024 })',
+ " console.log('LOADED')",
+ ' process.exit(1)',
+ '} catch (error) {',
+ " console.log('REJECTED:' + (error instanceof Error ? error.message : String(error)))",
+ ' process.exit(0)',
+ '}',
+ ].join('\n')
+ const out = execFileSync(process.execPath, ['--max-old-space-size=128', '--import', 'tsx', '-e', script], {
+ cwd: resolve(import.meta.dirname, '../../../..'),
+ encoding: 'utf8',
+ timeout: 60_000,
+ env: { ...process.env, TSX_TSCONFIG_PATH: resolve(import.meta.dirname, '../../../../tsconfig.json') },
+ })
+ expect(out).toContain('REJECTED:')
+ expect(out).toContain('must not exceed')
+ }, 60_000)
+
+ it('parses a worst-shape frame at the derived cap on a constrained heap', async () => {
+ // The host-heap frame cap must be measured against the WORST parse shape —
+ // a dict of many short unique keys, which forces dictionary-mode property
+ // storage plus interned keys (~6.4x at 3M keys, trending up), not the ~3x
+ // of a repeated-key dict. A child node with a 128 MiB old space (~176 MiB
+ // heap limit) derives a cap of floor((176 - 64) / 16) = 7 MiB; the
+ // subprocess builds a unique-key dict whose frame is AT that cap and
+ // parses it, which must survive. Verified fail-before: with the multiple
+ // at 8 the derived cap doubles to 14 MiB and the same subprocess OOMs
+ // during the parse (plain JS, no tsx — the frame and parse are builtins).
+ const cap = hostFrameParseCeiling(176 * 1024 * 1024)
+ const script = [
+ `const cap = ${cap}`,
+ // Each entry "k:1," is ~9-12 raw bytes; a few hundred thousand
+ // unique keys put the frame just at the cap.
+ 'const count = Math.floor(cap / 12)',
+ 'const obj = {}',
+ 'for (let i = 0; i < count; i++) obj[`k${i.toString(36)}`] = 1',
+ 'const frame = JSON.stringify(obj)',
+ "if (Buffer.byteLength(frame, 'utf8') > cap) throw new Error('frame over cap: ' + frame.length)",
+ 'JSON.parse(frame)',
+ "console.log('SURVIVED:' + Buffer.byteLength(frame, 'utf8'))",
+ ].join('\n')
+ const out = execFileSync(process.execPath, ['--max-old-space-size=128', '-e', script], {
+ encoding: 'utf8',
+ timeout: 60_000,
+ })
+ expect(out).toContain('SURVIVED:')
+ }, 60_000)
+
it('rejects a pythonBin that spawn() would throw on, at load', async () => {
// Both values pass the string schema and both make `spawn` throw
// SYNCHRONOUSLY from inside run() — ERR_INVALID_ARG_VALUE for the empty
@@ -173,8 +262,8 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
// The message distinguishes the explicit-path failure from a basename that
// simply does not resolve on PATH.
const nodePath = await import('node:path')
- const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs')
- const dir = mkdtempSync(nodePath.join(tmpdir(), 'dsh-bad-bin-'))
+ const { writeFileSync, mkdirSync } = await import('node:fs')
+ const dir = makeTempDirSync('dsh-bad-bin-')
const notExecutable = nodePath.join(dir, 'not-executable')
writeFileSync(notExecutable, '#!/bin/sh\nexit 0\n') // Regular file, but no X bit.
const directory = nodePath.join(dir, 'is-a-directory')
@@ -325,10 +414,9 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
// real interpreter is used.
const cp = await import('node:child_process')
const nodePath = await import('node:path')
- const { mkdtempSync, mkdirSync } = await import('node:fs')
- const { tmpdir } = await import('node:os')
+ const { mkdirSync } = await import('node:fs')
const realPythonDir = nodePath.dirname(cp.execFileSync('which', ['python3'], { encoding: 'utf8' }).trim())
- const fakeDir = mkdtempSync(nodePath.join(tmpdir(), 'dsh-fake-bin-'))
+ const fakeDir = makeTempDirSync('dsh-fake-bin-')
mkdirSync(nodePath.join(fakeDir, 'python3')) // A directory named python3, executable by default.
vi.stubEnv('PATH', `${fakeDir}:${realPythonDir}`)
try {
@@ -546,7 +634,7 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
// `os.tmpdir()`, so pointing it at a path that is not a directory makes the
// real call fail without stubbing the module under test.
const previous = process.env.TMPDIR
- const notADirectory = join(await mkdtemp(join(tmpdir(), 'dsh-staging-')), 'file')
+ const notADirectory = join(await makeTempDir('dsh-staging-'), 'file')
await writeFile(notADirectory, '')
process.env.TMPDIR = notADirectory
try {
@@ -632,7 +720,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// `pythonBin` is the honest lever: a wrapper that lowers RLIMIT_AS and then
// execs the real interpreter reproduces the inherited-limit condition
// without touching this test process's own limits.
- const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-'))
+ const dir = await makeTempDir('dsh-rlimit-')
const wrapper = join(dir, 'python3-capped')
// 256 MiB, half the 512 MiB addressSpaceMb default, so the requested cap is
// unambiguously above the inherited ceiling.
@@ -660,7 +748,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// rejected. The rejection surfaces as an 'exception' (bootstrap's
// setrlimit-phase failure class), not a mid-run OOM. The repro is Linux-only
// (macOS ignores `ulimit -v`); there the run proceeds.
- const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-'))
+ const dir = await makeTempDir('dsh-rlimit-')
const wrapper = join(dir, 'python3-tight')
await writeFile(wrapper, `#!/bin/sh\nulimit -v 131072\nexec "${PYABS}" "$@"\n`, { mode: 0o755 })
const { runtime } = await setup({ pythonBin: wrapper, maxLogBytes: 32 * 1024 * 1024, addressSpaceMb: 512 })
@@ -702,7 +790,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// requested soft (`cpuSeconds`) sits above the inherited soft — the case that
// exposed the bug. RLIMIT_CPU is used because macOS ignores `ulimit -v`
// (RLIMIT_AS), which is exactly why the backend skips address space there.
- const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-soft-'))
+ const dir = await makeTempDir('dsh-rlimit-soft-')
const wrapper = join(dir, 'python3-soft-capped')
// Soft CPU 5 s, well below the configured 30 s, hard left unlimited.
await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 5\nexec "${PYABS}" "$@"\n`, { mode: 0o755 })
@@ -727,7 +815,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// timeout. This uses `ulimit -t 2` (hard == 2, so the soft is lowered to 1)
// and leaves SIGXCPU unhandled, so the kernel terminates the busy loop at
// 1 s with SIGXCPU and the host classifies it as a timeout.
- const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-dual-'))
+ const dir = await makeTempDir('dsh-rlimit-dual-')
const wrapper = join(dir, 'python3-dual-capped')
// Both soft and hard CPU 2 s; configured cpuSeconds 30 s.
await writeFile(wrapper, `#!/bin/sh\nulimit -t 2\nexec "${PYABS}" "$@"\n`, { mode: 0o755 })
@@ -778,6 +866,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// before model code runs, so a busy loop still ends as a timeout rather
// than running to the hard limit and being misclassified as worker-exit.
const wrapper = join(tmpdir(), `dsh-xcpu-ignore-${process.pid}.sh`)
+ tempFiles.push(wrapper)
writeFileSync(wrapper, `#!/bin/sh\ntrap "" XCPU\nexec "${PYABS}" "$@"\n`, { mode: 0o755 })
try {
const { runtime } = await setup({ maxWallMs: 30_000, cpuSeconds: 1, pythonBin: wrapper })
@@ -830,7 +919,7 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
// the inherited limit. The wrapper sets a 1 s soft CPU limit; the program
// traps SIGXCPU and busy-loops past it, then returns — the recheck must
// re-deliver SIGXCPU so the host classifies the run as a timeout.
- const dir = await mkdtemp(join(tmpdir(), 'dsh-cpu-recheck-'))
+ const dir = await makeTempDir('dsh-cpu-recheck-')
const wrapper = join(dir, 'python3-cpu-capped')
await writeFile(wrapper, `#!/bin/sh\nulimit -S -t 1\nexec "${PYABS}" "$@"\n`, { mode: 0o755 })
const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 })
@@ -2977,8 +3066,8 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
// against it, then delete it before run() — the spawn then fails exactly
// like a child that cannot start.
const nodePath = await import('node:path')
- const { mkdtempSync, rmSync, writeFileSync, chmodSync } = await import('node:fs')
- const dir = mkdtempSync(nodePath.join(tmpdir(), 'dsh-spawn-fail-'))
+ const { rmSync, writeFileSync, chmodSync } = await import('node:fs')
+ const dir = makeTempDirSync('dsh-spawn-fail-')
const wrapper = nodePath.join(dir, 'python-wrapper')
const pyAbs = resolvePythonBin('python3') ?? 'python3'
writeFileSync(wrapper, `#!/bin/sh\nexec ${pyAbs} "$@"\n`, { mode: 0o755 })
@@ -3489,7 +3578,7 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
// means whether the killed descendant is reaped or lingers as a zombie (a
// SIGKILL'd process runs no more code either way). It sleeps 30 s as a safety
// net so a broken fix cannot leak it forever.
- const handoff = await mkdtemp(join(tmpdir(), 'dsh-samegroup-'))
+ const handoff = await makeTempDir('dsh-samegroup-')
const readyMarker = join(handoff, 'ready')
const heartbeat = join(handoff, 'heartbeat')
const { runtime } = await setup({ maxWallMs: 10_000, graceMs: 300 })
@@ -3554,7 +3643,7 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
// is called; the heartbeat must be stale BY THE TIME dispose() resolves —
// proving teardown waited for the reap, not merely that the reap eventually
// happened.
- const handoff = await mkdtemp(join(tmpdir(), 'dsh-dispose-quiesce-'))
+ const handoff = await makeTempDir('dsh-dispose-quiesce-')
const readyMarker = join(handoff, 'ready')
const heartbeat = join(handoff, 'heartbeat')
const { runtime, fiber } = await setup({ maxWallMs: 10_000, graceMs: 300 })
@@ -3607,7 +3696,7 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
// rather than cancel the unfired escalation — otherwise a SIGTERM-ignoring
// same-group survivor is released for good. A synchronous busy-loop after
// run() resolves reproduces the block deterministically.
- const handoff = await mkdtemp(join(tmpdir(), 'dsh-deadline-'))
+ const handoff = await makeTempDir('dsh-deadline-')
const readyMarker = join(handoff, 'ready')
const heartbeat = join(handoff, 'heartbeat')
const graceMs = 300
@@ -5211,52 +5300,203 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect(result.error?.message).toContain('call backlog exceeded')
}, 30_000)
- it('compacts the reply queue mid-drain without dropping pending frames', async () => {
- // A reply larger than the writable high-water mark makes the FIRST write
- // return false, suspending the drain loop while the child's synchronous
- // flood starves the reply pump; the frames queued behind it push the
- // drain's consumed head past MAX_PENDING_REPLIES, so the resumed drain
- // compacts the queue mid-run. The child reads fd 3 itself (blocking the
- // asyncio pump, so its reads cannot race the host's pushes) and sends a
- // second wave of calls AFTER reading part of the first wave's replies —
- // those replies are still pending when the drain's head crosses the
- // compaction bound, so a compaction that dropped pending frames would
- // leave the child's reply count short and the read loop spinning to the
- // wall clock. The second wave is sent mid-delivery (not with the first
- // flood): pushing it earlier would trip the 1024-pending reply cap
- // before the drain resumed.
+ it('runs a legitimate gather of more than 1024 concurrent binding calls', async () => {
+ // The in-flight call cap must not count a synchronous batch of instant
+ // calls: the async bodies' finallys run on the microtask queue, which
+ // drains only between 'data' events, so a per-frame check would trip on
+ // the 1025th frame of a single event even though every binding settled
+ // immediately — killing a valid large concurrent gather as worker-exit.
+ // The cap is checked at event boundaries (after the microtask queue
+ // drained), so this gather of 1025 instant calls completes.
+ const { runtime } = await setup({ maxWallMs: 30_000 })
+ const result = await runtime.run({
+ program: [
+ 'import asyncio',
+ 'return len(await asyncio.gather(*[tools.echo(i) for i in range(1025)]))',
+ ].join('\n'),
+ bindings: [{ global: 'tools', functions: { echo: async (args: unknown) => args as CodeJsonValue } }],
+ })
+ expect(result.error).toBeUndefined()
+ expect(result.value).toBe(1025)
+ }, 30_000)
+
+ it('completes normally when a program returns with binding calls still outstanding', async () => {
+ // The in-flight call cap refuses to admit NEW calls past the bound; it must
+ // not reclassify a `done` frame as worker-exit just because the program
+ // returned with calls it started but never awaited. The child schedules
+ // exactly 1024 slow bindings (still pending when the program returns), so
+ // the done frame arrives with the outstanding count AT the cap — the event
+ // must complete with its value, not settle as `call backlog exceeded`.
+ const { runtime } = await setup({ maxWallMs: 30_000 })
+ const result = await runtime.run({
+ program: [
+ 'import asyncio',
+ 'for i in range(1024):',
+ ' asyncio.create_task(tools.slow(i))',
+ 'await asyncio.sleep(0.2)',
+ 'return "done"',
+ ].join('\n'),
+ bindings: [{
+ global: 'tools',
+ functions: { slow: async () => { await new Promise((resolve) => { setTimeout(resolve, 5_000) }); return 1 } },
+ }],
+ })
+ expect(result.error).toBeUndefined()
+ expect(result.value).toBe('done')
+ }, 30_000)
+
+ it('settles a single-batch never-settling flood as worker-exit without further frames', async () => {
+ // The outstanding-call cap must take effect even when the whole flood fits
+ // in ONE data event: a per-event admission snapshot never re-checks once no
+ // further frames arrive, so a single 62 KiB write of 1025 compact calls
+ // against a never-settling binding would otherwise wait out the full wall
+ // clock instead of tripping the cap. The post-macrotask check runs after
+ // the batch's finallys (which never run for this binding) and settles the
+ // run as worker-exit long before maxWallMs.
const { runtime } = await setup({ maxWallMs: 30_000 })
const result = await runtime.run({
program: [
'import os, time',
+ 'frame = b\'{"type":"call","id":%d,"global":"tools","name":"hang","args":{}}\\n\'',
+ 'payload = b"".join(frame % i for i in range(1025))',
+ 'view = memoryview(payload)',
+ 'while view:',
+ ' view = view[os.write(3, view):]',
+ 'time.sleep(30)',
+ 'return "unreachable"',
+ ].join('\n'),
+ bindings: [{ global: 'tools', functions: { hang: async () => await new Promise(() => {}) } }],
+ })
+ expect(result.error?.kind).toBe('worker-exit')
+ expect(result.error?.message).toContain('call backlog exceeded')
+ }, 30_000)
+
+ it('runs a burst of 1300 instant calls whose frames split across pipe reads', async () => {
+ // Flowing mode can fire several 'data' events within one macrotask, before
+ // any microtask drains, so a per-event snapshot of the outstanding count
+ // could see the first chunk's in-flight calls in the second chunk's check
+ // and false-positive on a legitimate burst. The post-macrotask check always
+ // sees the true count (all finallys have run), so this burst of compact
+ // frames — sized so the pipe read splits it — completes with all results.
+ const { runtime } = await setup({ maxWallMs: 30_000 })
+ const result = await runtime.run({
+ program: [
+ 'import asyncio',
+ 'return len(await asyncio.gather(*[t.e(i) for i in range(1300)]))',
+ ].join('\n'),
+ bindings: [{ global: 't', functions: { e: async (args: unknown) => args as CodeJsonValue } }],
+ })
+ expect(result.error).toBeUndefined()
+ expect(result.value).toBe(1300)
+ }, 30_000)
+
+ it('settles as worker-exit when a done frame lands in the same batch as a call flood', async () => {
+ // A done frame processed in the SAME data event as more than 1024 call
+ // frames settles the run before the post-macrotask check runs (which no-ops
+ // once settled), so a child could finish "successfully" while leaving the
+ // outstanding closures behind — one sub-64 KiB write carries 1025 compact
+ // calls plus a done. The done handler re-checks the count before accepting
+ // the frame, so the run settles as worker-exit with the call-backlog
+ // message instead.
+ const { runtime } = await setup({ maxWallMs: 30_000 })
+ const result = await runtime.run({
+ program: [
+ 'import os, time',
+ 'frame = b\'{"type":"call","id":%d,"global":"tools","name":"hang","args":{}}\\n\'',
+ 'payload = b"".join(frame % i for i in range(1025)) + b\'{"type":"done","value":1}\\n\'',
+ 'view = memoryview(payload)',
+ 'while view:',
+ ' view = view[os.write(3, view):]',
+ 'time.sleep(30)',
+ 'return "unreachable"',
+ ].join('\n'),
+ bindings: [{ global: 'tools', functions: { hang: async () => await new Promise(() => {}) } }],
+ })
+ expect(result.error?.kind).toBe('worker-exit')
+ expect(result.error?.message).toContain('call backlog exceeded')
+ }, 30_000)
+
+ it('rejects a completion whose dict keys fold to one JSON member', async () => {
+ // `_dump_string` folds a spelled-out surrogate pair into its astral code
+ // point, so `"\ud83d\ude00"` and `"\U0001f600"` are DIFFERENT Python keys
+ // that encode to the SAME JSON member — the host's JSON.parse would
+ // silently drop one of them, violating the lossless-JSON completion
+ // contract. The child's meter rejects the collision before encoding.
+ const { runtime } = await setup()
+ const result = await runtime.run({
+ program: 'return {"\\ud83d\\ude00": 1, "\\U0001f600": 2}',
+ bindings: [],
+ })
+ expect(result.error?.kind).toBe('invalid-output')
+ expect(result.error?.message).toContain('duplicate dict key')
+ }, 30_000)
+
+ it('rejects binding arguments whose dict keys fold to one JSON member', async () => {
+ // The same collision on the binding-argument path: the call is rejected as
+ // not lossless JSON, so the program's `await` raises and the program
+ // surfaces the rejection message.
+ const { runtime } = await setup()
+ const result = await runtime.run({
+ program: [
+ 'try:',
+ ' await tools.echo({"\\ud83d\\ude00": 1, "\\U0001f600": 2})',
+ ' return "no-error"',
+ 'except Exception as e:',
+ ' return str(e)',
+ ].join('\n'),
+ bindings: [{ global: 'tools', functions: { echo: async (args: unknown) => args as CodeJsonValue } }],
+ })
+ expect(result.error).toBeUndefined()
+ expect(result.value).toContain('duplicate dict key')
+ }, 30_000)
+
+ it('compacts the reply queue mid-drain without dropping pending frames', async () => {
+ // A reply larger than the writable high-water mark makes the FIRST write
+ // return false, suspending the drain loop; the frames queued behind it
+ // push the drain's consumed head past MAX_PENDING_REPLIES, so the resumed
+ // drain compacts the queue mid-run. The child reads fd 3 itself (blocking
+ // the asyncio pump, so its reads cannot race the host's pushes) and sends
+ // a second wave of calls AFTER reading part of the first wave's replies —
+ // those replies are still pending when the drain's head crosses the
+ // compaction bound, so a compaction that dropped pending frames would
+ // leave the child's reply count short and the read loop spinning to the
+ // wall clock. No fixed sleep: the child's reads pace at the drain's
+ // delivery rate (each write blocks until the child reads), and the host
+ // finishes pushing all of a wave within milliseconds — orders of magnitude
+ // before the head crosses the bound — so the queue is always full at the
+ // splice. Newlines are counted per chunk (each reply carries exactly one),
+ // never by re-scanning the accumulated total, which would be O(n²).
+ const { runtime } = await setup({ maxWallMs: 60_000 })
+ const result = await runtime.run({
+ program: [
+ 'import os',
'frame = b\'{"type":"call","id":%d,"global":"tools","name":"big","args":{}}\\n\'',
'for i in range(1024):',
' view = memoryview(frame % i)',
' while view:',
' view = view[os.write(3, view):]',
- 'time.sleep(0.5)',
- 'total = b""',
- 'while total.count(b"\\n") < 500:',
+ 'seen = 0',
+ 'while seen < 500:',
' chunk = os.read(3, 65536)',
' if not chunk:',
' break',
- ' total += chunk',
+ ' seen += chunk.count(b"\\n")',
'for i in range(500):',
' view = memoryview(frame % (1024 + i))',
' while view:',
' view = view[os.write(3, view):]',
- 'while total.count(b"\\n") < 1524:',
+ 'while seen < 1524:',
' chunk = os.read(3, 65536)',
' if not chunk:',
' break',
- ' total += chunk',
+ ' seen += chunk.count(b"\\n")',
'return "done"',
].join('\n'),
bindings: [{ global: 'tools', functions: { big: async () => 'x'.repeat(65 * 1024) } }],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
- }, 30_000)
+ }, 60_000)
it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
// Blank print() lines carry zero content bytes; without the +1 separator
diff --git a/packages/experimental/code-runtime-python/tsconfig.json b/packages/experimental/code-runtime-python/tsconfig.json
index 6703a4b90d..f23f9bd728 100644
--- a/packages/experimental/code-runtime-python/tsconfig.json
+++ b/packages/experimental/code-runtime-python/tsconfig.json
@@ -20,14 +20,14 @@
{
"path": "../../code-runtime/code-runtime"
},
- {
- "path": "../../core/session"
- },
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/timeout"
+ },
+ {
+ "path": "../../util/values"
}
]
}