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 0614d4f40c..f5b29575ac 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 @@ -58,7 +58,7 @@ Also in `src/index.ts`, native stdout/stderr bytes (C-extension writes, `os.writ ### An incompatible output-budget/addressSpaceMb pair is rejected at load -The child ([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py)) builds, charges, and frames a `maxLogBytes` log entry or a `maxValueBytes` completion value under `RLIMIT_AS`, and both ledgers trigger on CHARACTER count against a serialized-BYTE budget. An astral character is one character but four bytes of CPython `str` storage and four UTF-8 bytes, and the heaviest path holds THREE such copies at once: a single `sys.stdout.write(line + "\n")` keeps the caller's `text` argument (alive for the whole `write` call, ~4×), the line slice handed to `LogBuffer.push` (~4×), and the `text.encode("utf-8")` copy `_push_locked` takes to charge and ship it (~4×) — a peak of ~12× the budget. The settlement `flush_line` path holds only two (its `"".join(...)` and that encode copy — it drops the pending chunks before pushing), so the newline path is the binding worst case. When a budget approaches `addressSpaceMb`, a LEGITIMATE near-budget output breaches the address space during that build-and-encode and dies as `worker-exit` instead of truncating (log) or failing as `output-limit` (value). Metering every child write against the address space at runtime is the wrong fix: an exact serialized-cost check on the hot path is either a full `encode` (the allocation being avoided) or a per-character Python loop (which burns the CPU budget — a 10 MB legitimate write hits SIGXCPU under `cpuSeconds: 1`). Both trade one resource bound for another. Instead [`src/index.ts`](../../../../packages/code-runtime/code-runtime-python/src/index.ts) rejects the incompatible pair at LOAD: each budget times `OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE` (twelve — the three simultaneous ~4× copies of the newline path) must fit the address space LEFT after a fixed `INTERPRETER_BASELINE_BYTES` reservation for the interpreter's own footprint, with a `>=` so a budget whose worst-case peak exactly equals that room is rejected (that peak plus the reserved baseline is the whole address space, the `RLIMIT_AS` edge). `flush_line` was also made to drop the pending chunks BEFORE its push, matching the newline path's join-clear-push order, so it holds at most the join and its encode rather than three copies. The baseline is reserved SEPARATELY from the multiple because it is a fixed cost, not one that scales with the budget: folding it into the multiple would leave a budget sized right at `addressSpaceMb / 12` admitted while its peak plus the interpreter still overran. Both `maxLogBytes` and `maxValueBytes` are gated symmetrically; the value path's build-and-encode is the same shape. The check runs on every platform, not just where `RLIMIT_AS` is enforced: the incompatibility is a property of the config values, so a Linux deployment OOMs regardless of the host that assembled the config, and a uniform load-time rejection is the fail-loud contract (Darwin skips only the runtime `setrlimit`). This eliminates the class at the config seam rather than patching the write path, so `_LogStream` keeps its original character-count buffering (a valid lower bound on serialized cost, memory-safe once the budget fits the address space). This eliminates the class at the config seam rather than patching the write path, so `_LogStream` keeps its original character-count buffering (a valid lower bound on serialized cost, memory-safe once the budget fits the address space). +The child ([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py)) builds, charges, and frames a `maxLogBytes` log entry or a `maxValueBytes` completion value under `RLIMIT_AS`, and both ledgers trigger on CHARACTER count against a serialized-BYTE budget. An astral character is one character but four bytes of CPython `str` storage and four UTF-8 bytes, and the heaviest path holds THREE such copies at once: a single `sys.stdout.write(line + "\n")` keeps the caller's `text` argument (alive for the whole `write` call, ~4×), the line slice handed to `LogBuffer.push` (~4×), and the `text.encode("utf-8")` copy `_push_locked` takes to charge and ship it (~4×) — a peak of ~12× the budget. The settlement `flush_line` path holds only two (its `"".join(...)` and that encode copy — it drops the pending chunks before pushing), so the newline path is the binding worst case. When a budget approaches `addressSpaceMb`, a LEGITIMATE near-budget output breaches the address space during that build-and-encode and dies as `worker-exit` instead of truncating (log) or failing as `output-limit` (value). Metering every child write against the address space at runtime is the wrong fix: an exact serialized-cost check on the hot path is either a full `encode` (the allocation being avoided) or a per-character Python loop (which burns the CPU budget — a 10 MB legitimate write hits SIGXCPU under `cpuSeconds: 1`). Both trade one resource bound for another. Instead [`src/index.ts`](../../../../packages/code-runtime/code-runtime-python/src/index.ts) rejects the incompatible pair at LOAD: each budget times `OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE` (twelve — the three simultaneous ~4× copies of the newline path) must fit the address space LEFT after a fixed `INTERPRETER_BASELINE_BYTES` reservation for the interpreter's own footprint, with a `>=` so a budget whose worst-case peak exactly equals that room is rejected (that peak plus the reserved baseline is the whole address space, the `RLIMIT_AS` edge). `flush_line` was also made to drop the pending chunks BEFORE its push, matching the newline path's join-clear-push order, so it holds at most the join and its encode rather than three copies. The baseline is reserved SEPARATELY from the multiple because it is a fixed cost, not one that scales with the budget: folding it into the multiple would leave a budget sized right at `addressSpaceMb / 12` admitted while its peak plus the interpreter still overran. Both `maxLogBytes` and `maxValueBytes` are gated symmetrically; the value path's build-and-encode is the same shape. The check runs on every platform, not just where `RLIMIT_AS` is enforced: the incompatibility is a property of the config values, so a Linux deployment OOMs regardless of the host that assembled the config, and a uniform load-time rejection is the fail-loud contract (Darwin skips only the runtime `setrlimit`). This eliminates the class at the config seam rather than patching the write path, so `_LogStream` keeps its original character-count buffering (a valid lower bound on serialized cost, memory-safe once the budget fits the address space). The value path enforces the same discipline in a second place: `_check_done_value` (the byte meter) and `_encode_json_plain` (the frame encoder) walk in O(DEPTH), not O(width). Each container pushes ONE cursor frame that pulls its children one at a time rather than one traversal tuple or stack entry per child — a flat `[0] * 6_000_000` serializes to ~12 MB but a per-element walk allocates ~400 MB of bookkeeping (~28× the serialized size, far past the 12× the gate reserves), so a value the meter admits could OOM on the walk's own frames. With the cursor, the only width-proportional allocation is the output string the meter already bounded. The host gate validates against the CONFIGURED `addressSpaceMb`, but a launch environment can inherit a STRICTER `RLIMIT_AS` (a `ulimit -v` wrapper below `addressSpaceMb`), which the bootstrap's `_clamped` correctly lowers the EFFECTIVE limit to — leaving the budgets sized for a ceiling the child never gets. So `bootstrap.py` re-checks both budgets against the effective clamped soft limit after applying it, mirroring the host gate's multiple and baseline, and raises at boot (caught by the setrlimit-phase handler and reported as `exception`, the same class as any other resource-limit-application failure) rather than letting a near-budget output OOM mid-run. The two child constants are kept in step with the host's by the shared reasoning, not a wire field. @@ -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). +- `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). ## 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 c1320696c6..2b2c443a8e 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 @@ -58,7 +58,7 @@ Status: implemented ### An incompatible output-budget/addressSpaceMb pair is rejected at load -子进程([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py))在 `RLIMIT_AS` 之下构建、计费并分帧一条 `maxLogBytes` 的日志条目或一个 `maxValueBytes` 的完成值,而两个账本都是按字符计数、对照一个序列化字节预算触发的。一个星芒面字符是一个字符,但占 CPython `str` 存储的四个字节以及四个 UTF-8 字节,且最重的路径峰值时有三份这样的副本同时存活:一次 `sys.stdout.write(line + "\n")` 会持有调用方的 `text` 实参(在整个 `write` 调用期间存活,约 4 倍)、交给 `LogBuffer.push` 的行切片(约 4 倍)、以及 `_push_locked` 为计费和发送而取的 `text.encode("utf-8")` 副本(约 4 倍)——峰值约为预算的 12 倍。结算期的 `flush_line` 路径只持有两份(它的 `"".join(...)` 与那份 encode 副本——它在 push 之前先丢弃 pending 分块),因此换行路径才是起约束作用的最坏情况。当一项预算逼近 `addressSpaceMb` 时,一次合法的、接近预算的输出会在那次构建加编码期间突破地址空间,并作为 `worker-exit`(日志)而不是截断而终止,或作为 `output-limit`(值)而失败。在运行时对每次子进程写入按地址空间计量是错误的修复:热路径上一次精确的序列化开销检查,要么是一次完整的 `encode`(正是要避免的那次分配),要么是一个逐字符的 Python 循环(它会烧掉 CPU 预算——一次 10 MB 的合法写入会在 `cpuSeconds: 1` 之下触发 SIGXCPU)。两者都是拿一种资源界限换另一种。取而代之,[`src/index.ts`](../../../../packages/code-runtime/code-runtime-python/src/index.ts) 在加载期(LOAD)拒绝这个不兼容的组合:每项预算乘以 `OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE`(十二——换行路径三份同时存在的约 4 倍副本)必须放得进为解释器自身占用预留一份固定的 `INTERPRETER_BASELINE_BYTES` 之后剩下的地址空间,并用一个 `>=`,使得一项其最坏情况峰值恰好等于那片余量的预算也会被拒绝(该峰值加上预留的基线正好是整个地址空间,即 `RLIMIT_AS` 边界)。`flush_line` 也被改为在 push 之前先丢弃 pending 分块,与换行路径的 join-清空-push 顺序一致,使它至多只持有 join 及其 encode 副本,而非三份副本。该基线是与倍数分开(SEPARATELY)预留的,因为它是一项固定开销,而非随预算伸缩的开销:把它折进倍数会让一项恰好为 `addressSpaceMb / 12` 大小的预算被放行,而其峰值加上解释器仍会越界。`maxLogBytes` 和 `maxValueBytes` 都被对称地门控;值路径的构建加编码是同一形态。该检查在每个平台上都运行,而不仅在强制 `RLIMIT_AS` 的平台上:这种不兼容是那些配置值的属性,因此一个 Linux 部署无论由哪个宿主组装配置都会 OOM,而一致的加载期拒绝正是 fail-loud 契约(Darwin 仅跳过运行时的 `setrlimit`)。这在配置 seam 处消除了这一类问题,而不是给写入路径打补丁,因此 `_LogStream` 保留它原有的按字符计数的缓冲(一个对序列化开销有效的下界,一旦预算放进地址空间就是内存安全的)。 +子进程([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py))在 `RLIMIT_AS` 之下构建、计费并分帧一条 `maxLogBytes` 的日志条目或一个 `maxValueBytes` 的完成值,而两个账本都是按字符计数、对照一个序列化字节预算触发的。一个星芒面字符是一个字符,但占 CPython `str` 存储的四个字节以及四个 UTF-8 字节,且最重的路径峰值时有三份这样的副本同时存活:一次 `sys.stdout.write(line + "\n")` 会持有调用方的 `text` 实参(在整个 `write` 调用期间存活,约 4 倍)、交给 `LogBuffer.push` 的行切片(约 4 倍)、以及 `_push_locked` 为计费和发送而取的 `text.encode("utf-8")` 副本(约 4 倍)——峰值约为预算的 12 倍。结算期的 `flush_line` 路径只持有两份(它的 `"".join(...)` 与那份 encode 副本——它在 push 之前先丢弃 pending 分块),因此换行路径才是起约束作用的最坏情况。当一项预算逼近 `addressSpaceMb` 时,一次合法的、接近预算的输出会在那次构建加编码期间突破地址空间,并作为 `worker-exit`(日志)而不是截断而终止,或作为 `output-limit`(值)而失败。在运行时对每次子进程写入按地址空间计量是错误的修复:热路径上一次精确的序列化开销检查,要么是一次完整的 `encode`(正是要避免的那次分配),要么是一个逐字符的 Python 循环(它会烧掉 CPU 预算——一次 10 MB 的合法写入会在 `cpuSeconds: 1` 之下触发 SIGXCPU)。两者都是拿一种资源界限换另一种。取而代之,[`src/index.ts`](../../../../packages/code-runtime/code-runtime-python/src/index.ts) 在加载期(LOAD)拒绝这个不兼容的组合:每项预算乘以 `OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE`(十二——换行路径三份同时存在的约 4 倍副本)必须放得进为解释器自身占用预留一份固定的 `INTERPRETER_BASELINE_BYTES` 之后剩下的地址空间,并用一个 `>=`,使得一项其最坏情况峰值恰好等于那片余量的预算也会被拒绝(该峰值加上预留的基线正好是整个地址空间,即 `RLIMIT_AS` 边界)。`flush_line` 也被改为在 push 之前先丢弃 pending 分块,与换行路径的 join-清空-push 顺序一致,使它至多只持有 join 及其 encode 副本,而非三份副本。该基线是与倍数分开(SEPARATELY)预留的,因为它是一项固定开销,而非随预算伸缩的开销:把它折进倍数会让一项恰好为 `addressSpaceMb / 12` 大小的预算被放行,而其峰值加上解释器仍会越界。`maxLogBytes` 和 `maxValueBytes` 都被对称地门控;值路径的构建加编码是同一形态。该检查在每个平台上都运行,而不仅在强制 `RLIMIT_AS` 的平台上:这种不兼容是那些配置值的属性,因此一个 Linux 部署无论由哪个宿主组装配置都会 OOM,而一致的加载期拒绝正是 fail-loud 契约(Darwin 仅跳过运行时的 `setrlimit`)。这在配置 seam 处消除了这一类问题,而不是给写入路径打补丁,因此 `_LogStream` 保留它原有的按字符计数的缓冲(一个对序列化开销有效的下界,一旦预算放进地址空间就是内存安全的)。值路径在第二处施加同样的纪律:`_check_done_value`(字节计量器)与 `_encode_json_plain`(帧编码器)都以 O(DEPTH) 而非 O(width) 遍历。每个容器只压入一个游标帧、逐个拉取子元素,而不是每个子元素一个遍历元组或栈条目——一个扁平的 `[0] * 6_000_000` 序列化后约 12 MB,但逐元素遍历会分配约 400 MB 的簿记(约为序列化尺寸的 28 倍,远超门预留的 12 倍),于是一个被计量器放行的值可能因遍历自身的帧而 OOM。改用游标后,唯一与宽度成正比的分配就是计量器已界定的输出字符串。 宿主门控是对照配置的(CONFIGURED)`addressSpaceMb` 校验的,但一个启动环境可能继承一个更严格的(STRICTER)`RLIMIT_AS`(一个低于 `addressSpaceMb` 的 `ulimit -v` 包装层),而 bootstrap 的 `_clamped` 会正确地把有效(EFFECTIVE)限制降到该值——从而让这些预算是按一个子进程永远得不到的上限来定尺寸的。因此 `bootstrap.py` 在应用该有效被夹紧的软限制之后,会对照它重新检查两项预算,镜像宿主门控的倍数与基线,并在引导期抛出(被 setrlimit 阶段的处理器捕获,并作为 `exception` 上报——与任何其他资源限制应用失败同属一类),而不是任由一次接近预算的输出在运行途中 OOM。这两个子进程侧常量与宿主侧的保持一致,靠的是共享的推理,而不是一个 wire 字段。 diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index 8ccd2991c6..537d9d8dca 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -1120,35 +1120,62 @@ def _encode_json_plain(value: Any) -> str: checkable at one glance instead of resting on the caller. """ - chunks: list[str] = [] - # Each frame is either a literal string to emit or a value to expand. - stack: list[Any] = [value] + # O(DEPTH) auxiliary space, not O(width). A container pushes ONE cursor frame + # 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 + # 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). + buffer = io.StringIO() + exhausted = object() + visit, list_cursor, dict_cursor = 0, 1, 2 + stack: list[Any] = [(visit, value)] while stack: - current = stack.pop() + frame = stack.pop() + kind = frame[0] + if kind == list_cursor: + iterator, wrote_any = frame[1], frame[2] + child = next(iterator, exhausted) + if child is exhausted: + buffer.write("]") + continue + if wrote_any: + buffer.write(",") + else: + frame[2] = True + stack.append(frame) + stack.append((visit, child)) + continue + if kind == dict_cursor: + iterator, wrote_any = frame[1], frame[2] + entry = next(iterator, exhausted) + if entry is exhausted: + buffer.write("}") + continue + key, item = entry + if wrote_any: + buffer.write(",") + else: + frame[2] = True + buffer.write(_dump_scalar(key)) + buffer.write(":") + stack.append(frame) + stack.append((visit, item)) + continue + current = frame[1] current_type = type(current) - if current_type is _Emit: - chunks.append(current.text) - elif current_type is list or current_type is tuple: - count = len(current) - chunks.append("[") - stack.append(_Emit("]")) - for index in range(count - 1, -1, -1): - if index < count - 1: - stack.append(_Emit(",")) - stack.append(current[index]) + if current_type is list or current_type is tuple: + buffer.write("[") + stack.append([list_cursor, iter(current), False]) elif current_type is dict: - chunks.append("{") - stack.append(_Emit("}")) - items = list(dict.items(current)) - for index in range(len(items) - 1, -1, -1): - key, item = items[index] - if index < len(items) - 1: - stack.append(_Emit(",")) - stack.append(item) - stack.append(_Emit(_dump_scalar(key) + ":")) + buffer.write("{") + stack.append([dict_cursor, iter(dict.items(current)), False]) else: - chunks.append(_dump_scalar(current)) - return "".join(chunks) + buffer.write(_dump_scalar(current)) + return buffer.getvalue() def _dump_scalar(value: Any) -> str: @@ -1368,13 +1395,53 @@ def _check_done_value(value: Any, max_bytes: int): total = 0 on_path: set[int] = set() - # Each frame is (value, is_leave): a leave frame pops its container off the path. - stack: list[tuple[Any, bool]] = [(value, False)] + # The walk uses O(DEPTH) space, not O(width). A container pushes ONE cursor + # frame that pulls its children one at a time, rather than one traversal + # frame per child: a flat `[0] * 6_000_000` serializes to ~12 MB (well within + # a modest budget) but one tuple per element is ~380 MB — an RLIMIT_AS death + # on a value the byte meter would admit, the very inversion this meter exists + # to prevent. A cursor frame is (kind, container, iterator); a visit frame is + # (VISIT, value, None). The upfront structural bound still rejects a wide + # forgery before any iteration begins. + exhausted = object() + visit, list_cursor, dict_cursor = 0, 1, 2 + stack: list[tuple[int, Any, Any]] = [(visit, value, None)] while stack: - current, is_leave = stack.pop() - if is_leave: - on_path.discard(id(current)) + frame = stack.pop() + kind = frame[0] + if kind == list_cursor: + container, iterator = frame[1], frame[2] + child = next(iterator, exhausted) + if child is exhausted: + on_path.discard(id(container)) + continue + # 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)) continue + if kind == dict_cursor: + container, iterator = frame[1], frame[2] + entry = next(iterator, exhausted) + if entry is exhausted: + on_path.discard(id(container)) + continue + key, item = entry + # Only an EXACT str key survives: bool and int coerce or raise, and a + # str SUBCLASS can override the ``__len__`` the bound below reads while + # the encoder emits its real characters. + if type(key) is not str: + return invalid(f"non-string dict key ({type(key).__name__})") + # The same string lower bound, before escaping the key. + if total + len(key) + 3 > max_bytes: + return over_budget + total += len(_dump_scalar(key).encode("utf-8")) + 1 + if total > max_bytes: + return over_budget + stack.append(frame) + stack.append((visit, item, None)) + continue + current = frame[1] if current is None or type(current) is bool: total += len(_dump_scalar(current).encode("utf-8")) elif type(current) is str: @@ -1412,14 +1479,13 @@ def _check_done_value(value: Any, max_bytes: int): return invalid("circular reference") count = len(current) total += 2 + (count - 1 if count > 1 else 0) - # Reject over-budget BEFORE enqueuing children: every element - # serializes to at least one byte, so a wide flat forgery fails here - # without materializing millions of leave frames first. + # Reject over-budget BEFORE iterating: every element serializes to at + # least one byte, so a wide flat forgery fails here without pulling a + # single child. if total + count > max_bytes: return over_budget on_path.add(id(current)) - stack.append((current, True)) - stack.extend((child, False) for child in current) + stack.append((list_cursor, current, iter(current))) elif type(current) is dict: if id(current) in on_path: return invalid("circular reference") @@ -1428,23 +1494,13 @@ def _check_done_value(value: Any, max_bytes: int): # recreating the spike the bound exists to stop. count = len(current) total += 2 + (count - 1 if count > 1 else 0) - # Same pre-enqueue bound: each entry contributes a quoted key - # (>= 2 bytes), a colon, and a >= 1-byte value. + # Same pre-iterate bound: each entry contributes a quoted key + # (>= 2 bytes), a colon, and a >= 1-byte value. ``iter`` on the items + # view is O(1); the cursor meters each key as it is pulled. if total + count * 4 > max_bytes: return over_budget on_path.add(id(current)) - stack.append((current, True)) - for key, item in current.items(): - # Only an EXACT str key survives: bool and int coerce or raise, - # and a str SUBCLASS can override the ``__len__`` the bound - # below reads while the encoder emits its real characters. - if type(key) is not str: - return invalid(f"non-string dict key ({type(key).__name__})") - # The same string lower bound, before escaping the key. - if total + len(key) + 3 > max_bytes: - return over_budget - total += len(_dump_scalar(key).encode("utf-8")) + 1 - stack.append((item, False)) + stack.append((dict_cursor, current, iter(current.items()))) else: # tuple, set, or any other type: not round-trippable JSON. return invalid(f"unsupported type ({type(current).__name__})") @@ -1453,15 +1509,6 @@ def _check_done_value(value: Any, max_bytes: int): return None -class _Emit: - """A pre-rendered fragment on :func:`_encode_json_plain`'s explicit stack.""" - - __slots__ = ("text",) - - def __init__(self, text: str) -> None: - self.text = text - - def _lossless_json_violation(value: Any) -> str | None: """Return why ``value`` is not lossless JSON, or ``None`` when it is. 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 7b0572ab1a..bb953ca9f4 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -3685,6 +3685,29 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(result.error?.kind).toBe('output-limit') }, 30_000) + it('checks and encodes a wide completion value in O(depth), not O(width)', async () => { + // A wide flat list serializes to ~2 bytes per element but the pre-fix walk + // enqueued one traversal tuple per element (_check_done_value) and one stack + // entry plus a separator marker per element (_encode_json_plain) — ~56 bytes + // per element, ~28x the serialized size. A value the byte meter admits could + // therefore OOM on the checker's or encoder's own bookkeeping, the inversion + // the load gate exists to prevent (the gate reserves 12x, not 28x). Both now + // walk with an O(depth) cursor that pulls one child at a time, so the only + // width-proportional allocation is the output string the meter bounded. + // + // Config: maxValueBytes 20 MiB against 384 MiB (20*12 = 240 MiB < 320 MiB + // budgetable, so it loads). `[0] * 6_000_000` is ~12 MB of JSON, under the + // 20 MiB budget, so it must round-trip. Pre-fix the ~400 MB of per-element + // frames plus the interpreter exceeded 384 MiB and returned MemoryError as an + // exception. Linux-only RLIMIT_AS repro; on macOS the value round-trips + // either way, but the fixture stays within the address space so it is honest. + const { runtime } = await setup({ maxValueBytes: 20 * 1024 * 1024, addressSpaceMb: 384, maxWallMs: 20_000 }) + const result = await runtime.run({ program: 'return [0] * 6_000_000', bindings: [] }) + expect(result.error).toBeUndefined() + expect(Array.isArray(result.value)).toBe(true) + expect((result.value as number[]).length).toBe(6_000_000) + }, 30_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 // charge they would bypass maxLogBytes entirely and grow the retained