From 2e3cf144d5e2d601cac6fab577b2f211647d163a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:52:08 +0800 Subject: [PATCH] docs(code-runtime-python): correct the claims the new backend invalidated Adding a published Python backend and reordering `flush_line` left several owning documents stating things that are no longer true. `src/invariant.ts` justified its empty installer with "ships only the fd-3 wire-protocol codec", which the subprocess execution path contradicts. The reason now states the actual one: every relation this backend maintains lives in the CPython child or on the fd-3 wire, so no same-process event sequence is observable from a listener -- the same shape the sibling worker-thread backend uses. The seam's `PORTABLE_RESERVED_WORDS` and `language` JSDoc, the code-runtime README pair, and docs/subsystems/code-runtime both said only TypeScript has a published backend. Corrected in all four, with the generated cordis catalog regenerated for the `language` change. The note attributed the 12x multiple to the settlement flush holding three copies. That stopped being true when `flush_line` was reordered to drop the pending chunks before its push: the binding worst case is the newline path's single near-budget write. Corrected in the note (both sides) and in the test comment that repeated it. The note's Testing section now registers the cases this stack added, and the Chinese side receives the O(depth) entry it never got plus the new ones -- it had drifted from the English. `INTERPRETER_BASELINE_BYTES` argued 64 MiB from a RESIDENT set while RLIMIT_AS bounds address space. It now cites the bootstrap's own measurement (30.23 MiB of mappings for `python3 -I`), making 64 MiB roughly twice the measured baseline. Also: a hardcoded `(:232-235)` comment reference becomes a reference by name, a "which now walks in O(depth) too" change narrative becomes a current-state statement, and a stray double blank line is removed. --- ...-runtime-python-settlement-fixes.i18n.yaml | 4 ++-- ...31-code-runtime-python-settlement-fixes.md | 2 +- ...code-runtime-python-settlement-fixes.zh.md | 2 +- docs/subsystems/code-runtime.i18n.yaml | 4 ++-- docs/subsystems/code-runtime.md | 2 +- docs/subsystems/code-runtime.zh.md | 2 +- .../code-runtime-python/py/bootstrap.py | 8 ++++---- .../code-runtime-python/src/index.ts | 19 +++++++++++++++---- .../code-runtime-python/src/invariant.ts | 8 +++++--- .../code-runtime-python/tests/runtime.spec.ts | 18 +++++++++++++++--- .../code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime/README.md | 4 ++-- .../code-runtime/code-runtime/README.zh.md | 4 ++-- .../code-runtime/code-runtime/src/index.ts | 6 +++--- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 15 files changed, 57 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 04e908dd25..71301d59ff 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md -2026-07-31-code-runtime-python-settlement-fixes.md: 0614d4f40c03c01b63c0ef3ae5584e667d94dddc -2026-07-31-code-runtime-python-settlement-fixes.zh.md: c1320696c6a8c2316befd79396618157299a9484 +2026-07-31-code-runtime-python-settlement-fixes.md: b3a5242c661fc162dc95cde41497940d2e36b447 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: a78aa5a5682b76b6b2d02c1519f29128e59b6111 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index f5b29575ac..b3a5242c66 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md @@ -68,7 +68,7 @@ One residual write-path copy is fixed alongside, independent of the config gate: - `tests/boot-write-failure.spec.ts` mocks `spawn` so the fd-3 pipe throws on the boot write — the one path a real subprocess cannot be coerced into — and asserts `run()` resolves a `worker-exit` rather than rejecting. A sibling case makes the mocked `spawn` throw SYNCHRONOUSLY and asserts `run()` still resolves a `worker-exit` and removes its staging directory, keyed off the exact bootstrap path the mocked `spawn` received in its argv so a sibling worker's concurrent staging cannot flake it. Both are isolated in this spec so the real-subprocess suite is untouched. - `tests/residual-detach.spec.ts` unit-tests `detachResidual`: the carried copy equals the residual, owns a backing store sized to its own length (fixture kept above Node's Buffer pool threshold), and does not share the source frame's `ArrayBuffer`. -- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the applied `RLIMIT_CPU` soft is the inherited value, not the configured one (CPU rather than address space, since macOS ignores `ulimit -v`); a companion inherits a 1 s CPU soft, has the program trap SIGXCPU and busy-loop past it, and asserts the settlement recheck reports a timeout — proving the recheck uses the effective soft, not the configured `cpuSeconds`. A control-heavy-diagnostic case raises a NUL-flood exception under a small `maxValueBytes` and asserts the serialized frame fits (proving the diagnostic is metered by serialized cost). A tail-copy case (`maxLogBytes: 256`, `addressSpaceMb: 384`) has the program build a tail in a variable and write `"\n" + tail` where `tail` is 150 MiB — construction peaks at ~2× (~300 MiB, within the address space, so the model's own allocation succeeds and any OOM belongs to the defect path), and the pre-fix whole-tail re-buffer added a third ~150 MiB copy past 384 MiB; the sliced prefix lets the run truncate and complete (Linux-only RLIMIT_AS repro, macOS happy path — the fixture's own construction must fit the address space, a general rule for these RLIMIT_AS cases). An output-budget/address-space case asserts a `maxLogBytes` of 50 MB AND a `maxValueBytes` of 50 MB each reject at load against a 256 MiB `addressSpaceMb` (past the room left after the interpreter baseline when multiplied by the worst-case 12) while the default caps against 512 MiB load, gating both budgets symmetrically; a discriminating case asserts a 48 MiB `maxLogBytes` against a 512 MiB `addressSpaceMb` rejects — 48×8 = 384 MiB fits the 448 MiB budgetable (the old 8× multiple wrongly admitted it) but 48×12 = 576 MiB does not, and that config is exactly the one whose settlement flush holds the pending chunks, their join, and the encode copy at ~12×. An inherited-RLIMIT_AS case runs the interpreter through a `ulimit -v 131072` wrapper with a 32 MiB `maxLogBytes` the configured 512 MiB `addressSpaceMb` admits, and asserts the boot re-check rejects it as an `exception` whose message names the inherited RLIMIT_AS (the 128 MiB inherited limit leaves too little after the baseline; Linux-only, macOS ignores `ulimit -v` and the run proceeds). A non-integer-budget case asserts a fractional `maxLogBytes`/`maxValueBytes` rejects at load. A combined-peak case (`maxLogBytes: 32 MiB`, `maxValueBytes: 32 MiB`, `addressSpaceMb: 512` — each budget admitted alone at 12×) writes ~33M newline-free astral characters (buffered, unflushed) then returns ~33M astral characters, and asserts the run settles as `output-limit` (the value is itself over its 32 MiB budget); pre-fix the unflushed log pending plus the value's build-and-encode peak added past the 512 MiB address space and OOM'd, so flushing the logs before framing the value is what lets the value check complete (Linux-only RLIMIT_AS repro; on macOS the over-budget value reports output-limit under both orders). A wide-value case (`maxValueBytes: 20 MiB`, `addressSpaceMb: 384`) returns `[0] * 6_000_000` — ~12 MB of JSON, under the 20 MiB budget, so it must round-trip; pre-fix the O(width) walk allocated ~400 MB of per-element traversal tuples and encoder stack entries (~28× the serialized size, past the 12× the gate reserves) and OOM'd on a value the meter admitted, while the O(depth) cursor keeps the only width-proportional allocation the output string itself (Linux-only RLIMIT_AS repro; the fixture stays within the address space so it is honest on macOS too). +- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the applied `RLIMIT_CPU` soft is the inherited value, not the configured one (CPU rather than address space, since macOS ignores `ulimit -v`); a companion inherits a 1 s CPU soft, has the program trap SIGXCPU and busy-loop past it, and asserts the settlement recheck reports a timeout — proving the recheck uses the effective soft, not the configured `cpuSeconds`. A control-heavy-diagnostic case raises a NUL-flood exception under a small `maxValueBytes` and asserts the serialized frame fits (proving the diagnostic is metered by serialized cost). A tail-copy case (`maxLogBytes: 256`, `addressSpaceMb: 384`) has the program build a tail in a variable and write `"\n" + tail` where `tail` is 150 MiB — construction peaks at ~2× (~300 MiB, within the address space, so the model's own allocation succeeds and any OOM belongs to the defect path), and the pre-fix whole-tail re-buffer added a third ~150 MiB copy past 384 MiB; the sliced prefix lets the run truncate and complete (Linux-only RLIMIT_AS repro, macOS happy path — the fixture's own construction must fit the address space, a general rule for these RLIMIT_AS cases). An output-budget/address-space case asserts a `maxLogBytes` of 50 MB AND a `maxValueBytes` of 50 MB each reject at load against a 256 MiB `addressSpaceMb` (past the room left after the interpreter baseline when multiplied by the worst-case 12) while the default caps against 512 MiB load, gating both budgets symmetrically; a discriminating case asserts a 48 MiB `maxLogBytes` against a 512 MiB `addressSpaceMb` rejects — 48×8 = 384 MiB fits the 448 MiB budgetable (the old 8× multiple wrongly admitted it) but 48×12 = 576 MiB does not. The ~12× peak the multiple covers is the NEWLINE path's single near-budget write — the caller's own string, the line slice, and the encode copy live at once; the settlement flush is no longer the binding case, because `flush_line` drops the pending chunks before its push and so holds two copies rather than three. An inherited-RLIMIT_AS case runs the interpreter through a `ulimit -v 131072` wrapper with a 32 MiB `maxLogBytes` the configured 512 MiB `addressSpaceMb` admits, and asserts the boot re-check rejects it as an `exception` whose message names the inherited RLIMIT_AS (the 128 MiB inherited limit leaves too little after the baseline; Linux-only, macOS ignores `ulimit -v` and the run proceeds). A non-integer-budget case asserts a fractional `maxLogBytes`/`maxValueBytes` rejects at load. A combined-peak case (`maxLogBytes: 32 MiB`, `maxValueBytes: 32 MiB`, `addressSpaceMb: 512` — each budget admitted alone at 12×) writes ~33M newline-free astral characters (buffered, unflushed) then returns ~33M astral characters, and asserts the run settles as `output-limit` (the value is itself over its 32 MiB budget); pre-fix the unflushed log pending plus the value's build-and-encode peak added past the 512 MiB address space and OOM'd, so flushing the logs before framing the value is what lets the value check complete (Linux-only RLIMIT_AS repro; on macOS the over-budget value reports output-limit under both orders). A wide-value case (`maxValueBytes: 20 MiB`, `addressSpaceMb: 384`) returns `[0] * 6_000_000` — ~12 MB of JSON, under the 20 MiB budget, so it must round-trip; pre-fix the O(width) walk allocated ~400 MB of per-element traversal tuples and encoder stack entries (~28× the serialized size, past the 12× the gate reserves) and OOM'd on a value the meter admitted, while the O(depth) cursor keeps the only width-proportional allocation the output string itself (Linux-only RLIMIT_AS repro; the fixture stays within the address space so it is honest on macOS too). A wide-BINDING-ARGUMENT case (`addressSpaceMb: 384`) calls a binding with `[0] * 6_000_000` and asserts the length echoes back: `_lossless_json_violation` runs on model-built arguments that no child-side budget bounds first, and its per-member tuples measured 459.1 MiB against 0.0 MiB for the cursor. A backtracking case returns a 4 MiB string from a binding and asserts it round-trips: the old scalar regex retained engine state proportional to the string's width (146 MiB at 1 MiB, 557.8 MiB at 4 MiB, past the default 512 MiB), which raised MemoryError inside `_pump_replies` and stranded the call to the wall clock. A control-heavy metering case returns 8M NULs under a 16 MiB `maxValueBytes` and asserts `output-limit`, not `exception`: charging by counting instead of materializing the escaped form measured 19.1 MiB against 228.9 MiB for an identical byte count. An addressSpaceMb-baseline case asserts 64 MiB and 32 MiB reject at load with a message naming `addressSpaceMb`, rather than the budget loop's negative admissible limit. A process-identity case asserts the leader's start time reads stably on Linux and reports undefined on Darwin, the guard that keeps a recycled pgid from receiving this run's SIGTERM. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index 2b2c443a8e..a78aa5a568 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md @@ -68,7 +68,7 @@ Status: implemented - `tests/boot-write-failure.spec.ts` 对 `spawn` 做 mock,使 fd-3 管道在引导写入时抛出异常(这是真实子进程无法被迫进入的唯一路径),并断言 `run()` resolve 出一个 `worker-exit` 而非 reject。一个同级用例让被 mock 的 `spawn` 同步抛出,并断言 `run()` 仍然 resolve 出一个 `worker-exit`,且会移除它的暂存目录——以被 mock 的 `spawn` 在其 argv 中收到的确切引导路径为准,因此一个同级 worker 的并发暂存不会让它变得不稳定。两者都被隔离在这个 spec 中,因此真实子进程测试套件不受影响。 - `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储(fixture 保持在 Node 的 Buffer 池阈值之上),并且不与源帧的 `ArrayBuffer` 共享。 -- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,随后在一个后续绑定之前回答它——断言 pump 在关闭事件循环上的 `call_soon_threadsafe` 之后仍然存活(由宿主门控的顺序使其具有确定性,未修复时会把后续绑定拖到墙钟上挂起)。inherited-soft-limit 用例通过一个 `ulimit -S -t` 包装脚本运行解释器,将 CPU 软限制设为低于 `cpuSeconds`,并断言实际应用的 `RLIMIT_CPU` 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 `ulimit -v`)。一个配套用例继承 1 秒的 CPU 软限制,让程序捕获 SIGXCPU 并忙循环越过它,断言结算复查报告 timeout——证明复查用的是实际生效的软限制,而不是配置的 `cpuSeconds`。一个 control-heavy-diagnostic 用例在一个较小的 `maxValueBytes` 之下抛出一个 NUL 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 tail-copy 用例(`maxLogBytes: 256`、`addressSpaceMb: 384`)让程序在一个变量里构建一个尾部并写入 `"\n" + tail`,其中 `tail` 为 150 MiB——构建峰值约 2 倍(约 300 MiB,落在地址空间之内,因此模型自身的分配会成功,任何 OOM 都属于缺陷路径),而修复前的整尾重新缓冲会加上第三份约 150 MiB 的副本、越过 384 MiB;切片后的前缀让该次运行得以截断并完成(仅 Linux 的 RLIMIT_AS 复现,macOS 走顺利路径——fixture 自身的构建必须放进地址空间,这是这些 RLIMIT_AS 用例的一条通用规则)。一个 output-budget/address-space 用例断言一个 50 MB 的 `maxLogBytes` 和一个 50 MB 的 `maxValueBytes` 各自对照一个 256 MiB 的 `addressSpaceMb` 在加载期被拒绝(乘以最坏情况的 12 之后超过解释器基线之后剩下的余量),而默认的各项上限对照 512 MiB 则加载成功,对两项预算对称地门控;一个具区分力的用例断言一个 48 MiB 的 `maxLogBytes` 对照一个 512 MiB 的 `addressSpaceMb` 被拒绝——48×8 = 384 MiB 放得进 448 MiB 的可预算余量(旧的 8× 倍数会错误放行),但 48×12 = 576 MiB 放不进,而正是这个配置的结算期 flush 会以约 12× 同时持有 pending 分块、它们的 join 与 encode 副本。一个 inherited-RLIMIT_AS 用例通过一个 `ulimit -v 131072` 包装层运行解释器,配以一个配置的 512 MiB `addressSpaceMb` 所允许的 32 MiB `maxLogBytes`,断言引导期的重新检查把它作为 `exception` 拒绝、且其消息点名了继承的 RLIMIT_AS(128 MiB 的继承限制在基线之后剩下的太少;仅 Linux,macOS 忽略 `ulimit -v`,该次运行会继续)。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。一个 combined-peak 用例(`maxLogBytes: 32 MiB`、`maxValueBytes: 32 MiB`、`addressSpaceMb: 512`——每项预算单独都被 12× 门放行)写入约 33M 个不含换行符的星芒面字符(缓冲、未冲刷)后返回约 33M 个星芒面字符,断言该次运行以 `output-limit` 结算(该值本身就超过它 32 MiB 的预算);修复前未冲刷的日志 pending 加上值的构建加编码峰值会一起越过 512 MiB 地址空间而 OOM,因此在分帧值之前先冲刷日志正是让值检查得以完成的原因(仅 Linux 的 RLIMIT_AS 复现;在 macOS 上超预算的值在两种顺序下都报 output-limit)。 +- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,随后在一个后续绑定之前回答它——断言 pump 在关闭事件循环上的 `call_soon_threadsafe` 之后仍然存活(由宿主门控的顺序使其具有确定性,未修复时会把后续绑定拖到墙钟上挂起)。inherited-soft-limit 用例通过一个 `ulimit -S -t` 包装脚本运行解释器,将 CPU 软限制设为低于 `cpuSeconds`,并断言实际应用的 `RLIMIT_CPU` 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 `ulimit -v`)。一个配套用例继承 1 秒的 CPU 软限制,让程序捕获 SIGXCPU 并忙循环越过它,断言结算复查报告 timeout——证明复查用的是实际生效的软限制,而不是配置的 `cpuSeconds`。一个 control-heavy-diagnostic 用例在一个较小的 `maxValueBytes` 之下抛出一个 NUL 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 tail-copy 用例(`maxLogBytes: 256`、`addressSpaceMb: 384`)让程序在一个变量里构建一个尾部并写入 `"\n" + tail`,其中 `tail` 为 150 MiB——构建峰值约 2 倍(约 300 MiB,落在地址空间之内,因此模型自身的分配会成功,任何 OOM 都属于缺陷路径),而修复前的整尾重新缓冲会加上第三份约 150 MiB 的副本、越过 384 MiB;切片后的前缀让该次运行得以截断并完成(仅 Linux 的 RLIMIT_AS 复现,macOS 走顺利路径——fixture 自身的构建必须放进地址空间,这是这些 RLIMIT_AS 用例的一条通用规则)。一个 output-budget/address-space 用例断言一个 50 MB 的 `maxLogBytes` 和一个 50 MB 的 `maxValueBytes` 各自对照一个 256 MiB 的 `addressSpaceMb` 在加载期被拒绝(乘以最坏情况的 12 之后超过解释器基线之后剩下的余量),而默认的各项上限对照 512 MiB 则加载成功,对两项预算对称地门控;一个具区分力的用例断言一个 48 MiB 的 `maxLogBytes` 对照一个 512 MiB 的 `addressSpaceMb` 被拒绝——48×8 = 384 MiB 放得进 448 MiB 的可预算余量(旧的 8× 倍数会错误放行),但 48×12 = 576 MiB 放不进。该倍数覆盖的约 12× 峰值来自换行路径上一次接近预算的写入——调用方自己的字符串、行切片与 encode 副本同时存活;结算期 flush 已不再是承重者,因为 `flush_line` 在 push 之前就丢弃了 pending 分块,因此只持有两份副本而非三份。一个 inherited-RLIMIT_AS 用例通过一个 `ulimit -v 131072` 包装层运行解释器,配以一个配置的 512 MiB `addressSpaceMb` 所允许的 32 MiB `maxLogBytes`,断言引导期的重新检查把它作为 `exception` 拒绝、且其消息点名了继承的 RLIMIT_AS(128 MiB 的继承限制在基线之后剩下的太少;仅 Linux,macOS 忽略 `ulimit -v`,该次运行会继续)。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。一个 combined-peak 用例(`maxLogBytes: 32 MiB`、`maxValueBytes: 32 MiB`、`addressSpaceMb: 512`——每项预算单独都被 12× 门放行)写入约 33M 个不含换行符的星芒面字符(缓冲、未冲刷)后返回约 33M 个星芒面字符,断言该次运行以 `output-limit` 结算(该值本身就超过它 32 MiB 的预算);修复前未冲刷的日志 pending 加上值的构建加编码峰值会一起越过 512 MiB 地址空间而 OOM,因此在分帧值之前先冲刷日志正是让值检查得以完成的原因(仅 Linux 的 RLIMIT_AS 复现;在 macOS 上超预算的值在两种顺序下都报 output-limit)。一个宽完成值用例(`maxValueBytes: 20 MiB`、`addressSpaceMb: 384`)返回 `[0] * 6_000_000`——JSON 约 12 MB、低于预算,因此必须成功往返;修复前 O(width) 的遍历为每个元素分配遍历元组与编码器栈项(约为序列化尺寸的 28×,超出门保留的 12×),在一个计量器已放行的值上 OOM,而 O(depth) 游标使唯一按宽度分配的只剩输出字符串本身。一个宽 binding 实参用例(`addressSpaceMb: 384`)以 `[0] * 6_000_000` 调用 binding 并断言长度回传:`_lossless_json_violation` 运行在模型构造的实参上,子进程侧没有任何字节预算先行约束,其逐元素元组实测 459.1 MiB,而游标为 0.0 MiB。一个回溯用例从 binding 返回一个 4 MiB 字符串并断言其成功往返:旧的标量正则保留的引擎状态与字符串宽度成正比(1 MiB 时 146 MiB,4 MiB 时 557.8 MiB,超过默认的 512 MiB),会在 `_pump_replies` 内抛出 MemoryError 并把该次调用搁置到墙钟。一个 control-heavy 计费用例在 16 MiB 的 `maxValueBytes` 之下返回 8M 个 NUL,断言得到 `output-limit` 而非 `exception`:以计数替代物化转义形式来计费,在字节数完全相同的前提下实测 19.1 MiB 对 228.9 MiB。一个 addressSpaceMb 下界用例断言 64 MiB 与 32 MiB 在加载期被拒绝,且消息点名 `addressSpaceMb`,而不是预算循环给出的负数上限。一个进程身份用例断言 leader 的启动时刻在 Linux 上可稳定读取、在 Darwin 上报告 undefined,这正是使被复用的 pgid 不会收到本次运行 SIGTERM 的那道守卫。 ## Alternatives considered diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 228ed14b56..06228e520f 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.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/subsystems/code-runtime.md -code-runtime.md: 0f633df9fc657d9d80fc04df3bc8ad6fafdddcb2 -code-runtime.zh.md: 43b78ce49575741f7ae6c4e2751b63b7562fc99a +code-runtime.md: eaffa17b552f4d91440c7f9f4ca089549d1e8966 +code-runtime.zh.md: 762c21d9366f33129f1e4e386b5d6ae2d3a44258 diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index 0f633df9fc..eaffa17b55 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -158,7 +158,7 @@ interface CodeRunFailure { ## The service -`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` and `'python'` are the well-known values, those `dsh-tools` presents, and only `'typescript'` has a published backend; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes. +`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` and `'python'` are the well-known values, those `dsh-tools` presents, and each has a published backend; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes. diff --git a/docs/subsystems/code-runtime.zh.md b/docs/subsystems/code-runtime.zh.md index 43b78ce495..762c21d936 100644 --- a/docs/subsystems/code-runtime.zh.md +++ b/docs/subsystems/code-runtime.zh.md @@ -158,7 +158,7 @@ interface CodeRunFailure { ## 服务 -`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,已知值为 `'typescript'` 与 `'python'`,即 `dsh-tools` 能呈现的那些,其中只有 `'typescript'` 有已发布的后端;生成语言相关展示的 Consumer 据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 要等到所有进行中的运行均已终止并结算后才完成。 +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,已知值为 `'typescript'` 与 `'python'`,即 `dsh-tools` 能呈现的那些,两者都有已发布的后端;生成语言相关展示的 Consumer 据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 要等到所有进行中的运行均已终止并结算后才完成。 diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index 2a19a39c54..47f3136e9c 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -363,8 +363,8 @@ class _LogStream(io.TextIOBase): # against them. with self._logs.lock: if self._pending: - # Join, drop the chunks, THEN push — the same order the newline - # path uses (:232-235). Pushing before the clear would keep the + # Join, drop the chunks, THEN push — the same join-clear-push order + # as `_write_locked`'s newline branch. Pushing before the clear would keep the # pending chunks alive through `_push_locked`'s `text.encode`, so # the chunks, their join, and the encode copy would all be live at # once; dropping the chunks first leaves only the join and its @@ -1184,8 +1184,8 @@ def _encode_json_plain(value: Any) -> str: # that pulls its children one at a time and writes each into the shared buffer, # rather than one stack entry (plus a separator marker) per child: a flat # `[0] * 6_000_000` encodes to ~12 MB but per-element frames are ~400 MB — an - # RLIMIT_AS death on a value `_check_done_value` already admitted (which now - # walks in O(depth) too). The output string is the only width-proportional + # RLIMIT_AS death on a value `_check_done_value` already admitted (it walks by + # depth as well). The output string is the only width-proportional # allocation, and its size the caller metered within budget. `io.StringIO` # accumulates without the intermediate `"".join(chunks)` second copy. A cursor # frame is [kind, iterator, wrote_any]; a visit frame is (VISIT, value). diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index a1dfae947c..1ab308a163 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -266,13 +266,16 @@ const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 12 * multiple claims the rest. The budget check subtracts this from `addressSpaceMb` * so a budget sized right at `addressSpaceMb / MULTIPLE` — which the multiple * alone would admit — cannot leave the peak output allocation plus the - * interpreter over the limit. 64 MiB is generous for a `python3 -I` process - * whose own resident set is tens of MiB; the value is a fixed safety margin, not - * a deployment knob. + * interpreter over the limit. Sized against ADDRESS SPACE, which is what + * `RLIMIT_AS` bounds, not resident set: the bootstrap's own measurement is + * 30.23 MiB of mappings for a `python3 -I` child (see `_make_cpu_enforcer`, + * which also records the 64 MiB glibc per-thread arena reservation that pushes + * it to 102.37 MiB when threads are used). 64 MiB is roughly twice the measured + * baseline, leaving room for allocator arenas and import jitter. The value is a + * fixed safety margin, not a deployment knob. */ const INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024 - /** * Interval between process-group liveness probes while settlement waits for an * escalated SIGKILL to empty the group (see the `killing` branch in @@ -794,6 +797,14 @@ export class PythonCodeRuntime extends CodeRuntime { // peak plus the reserved baseline is the whole address space, the RLIMIT_AS // edge. `ceil(budgetableBytes / MULTIPLE) - 1` is the last integer strictly // under `budgetableBytes / MULTIPLE`. + // Reject a too-small address space on its own terms FIRST. Once + // `budgetableBytes` is zero or negative no budget can pass, and the loop + // below would report "a limit of -1" (or -2796203 at addressSpaceMb 32) while + // naming `maxLogBytes` -- pointing the operator at the knob that is not the + // problem. The baseline is what `addressSpaceMb` must clear here. + if (budgetableBytes <= 0) { + throw new Error(`dsh-code-runtime-python: config.addressSpaceMb must exceed the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline with room for the output budgets, so the child has address space left to build and encode them; got ${String(this.config.addressSpaceMb)} MiB (${addressSpaceBytes} bytes)`) + } const admissibleBudget = Math.ceil(budgetableBytes / OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE) - 1 for (const key of ['maxLogBytes', 'maxValueBytes'] as const) { if (this.config[key] * OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE >= budgetableBytes) { diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts index 6f616bc5c0..c2bcb66f51 100644 --- a/packages/code-runtime/code-runtime-python/src/invariant.ts +++ b/packages/code-runtime/code-runtime-python/src/invariant.ts @@ -15,9 +15,11 @@ export const name = 'code-runtime-python-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this package ships only the fd-3 wire-protocol codec and its Python mirror, - * exposing no runtime event sequence or mutable data relation; `protocol.spec.ts` and - * `protocol-mirror.e2e.ts` cover the protocol's behavior. + * No runtime invariant: every relation this backend maintains — frame ordering, budget accounting, + * and process teardown — lives in the CPython subprocess or on the fd-3 wire, so no same-process + * event sequence or mutable data relation is observable from a Cordis listener. `protocol.spec.ts`, + * `protocol-mirror.e2e.ts`, and the real-subprocess `runtime.spec.ts` cover that behavior, matching + * the sibling process-boundary backend `@deepseek-ai/dsh-code-runtime-worker-thread`. */ const install: InvariantInstaller = () => {} diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index febd598512..b3a46cee21 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -827,12 +827,24 @@ describe('PythonCodeRuntime — programs and bindings', () => { .rejects.toThrow(/maxValueBytes times the 12x worst-case Unicode expansion must fit/) // Discriminates 12 from 8: a 48 MiB maxLogBytes against a 512 MiB address // space leaves 448 MiB budgetable. 48*8 = 384 MiB fits (the old 8x multiple - // wrongly ADMITTED this), but 48*12 = 576 MiB does not — and this is exactly - // the config that OOMs, since a settlement flush holds the pending chunks, - // their join, and the encode copy at once (~12x). The 12x gate rejects it. + // wrongly ADMITTED this), but 48*12 = 576 MiB does not. The ~12x peak this + // guards is the NEWLINE path's single near-budget write — the caller's own + // string, the line slice, and the encode copy live at once. The settlement + // flush is no longer the binding case: `flush_line` drops the pending chunks + // before its push, so it holds two copies, not three. const ctxTwelve = new Context() await expect(ctxTwelve.plugin(PythonCodeRuntime, { maxLogBytes: 48 * 1024 * 1024, addressSpaceMb: 512 })) .rejects.toThrow(/maxLogBytes times the 12x worst-case Unicode expansion must fit/) + // An addressSpaceMb at or below the interpreter baseline leaves nothing + // budgetable, so no budget value can pass. It is rejected on its own terms: + // the budget loop would otherwise report "a limit of -1" (or -2796203 at + // 32 MiB) while naming maxLogBytes, sending the operator to the wrong knob. + const ctxBaseline = new Context() + await expect(ctxBaseline.plugin(PythonCodeRuntime, { addressSpaceMb: 64 })) + .rejects.toThrow(/addressSpaceMb must exceed the 67108864-byte interpreter baseline/) + const ctxBelow = new Context() + await expect(ctxBelow.plugin(PythonCodeRuntime, { addressSpaceMb: 32 })) + .rejects.toThrow(/addressSpaceMb must exceed the 67108864-byte interpreter baseline/) // The default caps against the default 512 MiB address space load. const ok = new Context() const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, maxValueBytes: 32768, addressSpaceMb: 512 }) diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index 54afe6235e..c8913de7c0 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md -README.md: e3d43e7add4992c44fef966651f91addb81aa1eb -README.zh.md: bcbeaa8bbdfee33baa1b29e232038e2bd6b77728 +README.md: b16a8c81e80e07665b7ac868e4cb643529938055 +README.zh.md: ad5ec96fc8df00c0f3c1b1771bc5efbf148de054 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index e3d43e7add..b16a8c81e8 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -41,7 +41,7 @@ const result = await ctx.codeRuntime.run({ ### Choose a backend -Backends declare two descriptors you can rely on: `language` — what the program must be written in, with `'typescript'` and `'python'` as the well-known values and only TypeScript shipped — and `isolation` — the execution substrate (`'worker-thread'`, `'process'`, `'container'`), a label for deployments and diagnostics, not a security claim. The shipped backend is [`dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md), which executes TypeScript in a fresh Node worker thread; [`dsh-code-runtime-python`](../code-runtime-python/README.md) owns the wire protocol for the CPython backend. +Backends declare two descriptors you can rely on: `language` — what the program must be written in, with `'typescript'` and `'python'` as the well-known values and both backed by published providers — and `isolation` — the execution substrate (`'worker-thread'`, `'process'`, `'container'`), a label for deployments and diagnostics, not a security claim. [`dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md) executes TypeScript in a fresh Node worker thread; [`dsh-code-runtime-python`](../code-runtime-python/README.md) executes Python in a fresh CPython subprocess. ### Name your bindings portably @@ -98,7 +98,7 @@ Read these when the package-level contract is not enough. They move from the PTC - [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — how the tool registry consumes `ctx.codeRuntime` and presents `run_code` to the model. - [Worker-thread backend](../code-runtime-worker-thread/README.md) — the shipped TypeScript execution backend. -- [Python protocol package](../code-runtime-python/README.md) — the wire protocol for the CPython backend. +- [Python backend](../code-runtime-python/README.md) — the CPython subprocess execution provider and its fd-3 protocol. - [Code runtime subsystem reference](../../../docs/subsystems/code-runtime.md) — request/result vocabulary, bindings, and the `ctx.codeRuntime` cordis surface. - [Capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) — the Service Definition / Service Provider / Consumer split. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index bcbeaa8bbd..ad5ec96fc8 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -41,7 +41,7 @@ const result = await ctx.codeRuntime.run({ ### 选择后端 -后端声明两个你可以依赖的描述符:`language`——程序必须使用的源语言,已知值为 `'typescript'` 与 `'python'`,目前只有 TypeScript 已发布——以及 `isolation`——执行基底(`'worker-thread'`、`'process'`、`'container'`),仅供部署与诊断使用,不构成安全声明。已发布的后端是 [`dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.zh.md),在全新的 Node Worker 线程中执行 TypeScript;[`dsh-code-runtime-python`](../code-runtime-python/README.zh.md) 持有 CPython 后端的协议格式(wire protocol)。 +后端声明两个你可以依赖的描述符:`language`——程序必须使用的源语言,已知值为 `'typescript'` 与 `'python'`,两者都有已发布的提供方——以及 `isolation`——执行基底(`'worker-thread'`、`'process'`、`'container'`),仅供部署与诊断使用,不构成安全声明。[`dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.zh.md) 在全新的 Node Worker 线程中执行 TypeScript;[`dsh-code-runtime-python`](../code-runtime-python/README.zh.md) 在全新的 CPython 子进程中执行 Python。 ### 可移植地命名绑定 @@ -98,7 +98,7 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 - [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——工具注册表如何消费 `ctx.codeRuntime` 并把 `run_code` 呈现给模型。 - [Worker 线程后端](../code-runtime-worker-thread/README.zh.md)——已发布的 TypeScript 执行后端。 -- [Python 协议包](../code-runtime-python/README.zh.md)——CPython 后端的协议格式。 +- [Python 后端](../code-runtime-python/README.zh.md)——CPython 子进程执行提供方及其 fd-3 协议。 - [代码运行时子系统参考](../../../docs/subsystems/code-runtime.zh.md)——请求/结果词汇、绑定与 `ctx.codeRuntime` 的 cordis 接口面。 - [能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)——Service Definition / Service Provider / Consumer 拆分。 diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index c23143f821..8f3b57d03a 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -66,8 +66,8 @@ export const DUNDER_MEMBER = /^__.+__$/ /** * Reserved words of every portable target language (ECMAScript ∪ Python), * refused as {@link CodeBindingNamespace.global} / error-class names by all - * backends. Python is a portability target here even though only the - * TypeScript worker has a published backend. The portable-identifier contract + * backends, which ship for both languages: the TypeScript worker thread and + * the CPython subprocess. The portable-identifier contract * promises a namespace list valid on one backend is valid on every backend; a * per-language check would let `lambda` pass the TypeScript backend and fail * the Python one. Extending the seam with a new language means widening this @@ -106,7 +106,7 @@ export abstract class CodeRuntime extends Service { * generates language-specific presentation (typed SDK stubs, usage * instructions) switches on it and fails loud on a language it cannot * present. Well-known values: `'typescript'` and `'python'`, those - * `dsh-tools` presents; only `'typescript'` has a published backend. + * `dsh-tools` presents; each has a published backend. */ abstract readonly language: string diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9d22fdcfeb..8dbda7c2d0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -598,7 +598,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract readonly language: string', - description: 'The source language run expects `program` to be written in, as a lowercase identifier. Informational, not gating — a consumer that generates language-specific presentation (typed SDK stubs, usage instructions) switches on it and fails loud on a language it cannot present. Well-known values: `\'typescript\'` and `\'python\'`, those `dsh-tools` presents; only `\'typescript\'` has a published backend.', + description: 'The source language run expects `program` to be written in, as a lowercase identifier. Informational, not gating — a consumer that generates language-specific presentation (typed SDK stubs, usage instructions) switches on it and fails loud on a language it cannot present. Well-known values: `\'typescript\'` and `\'python\'`, those `dsh-tools` presents; each has a published backend.', parameters: [], }, {