From 2df88b5bbe551423916c23a64f2657efcfb2f191 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 10:48:35 +0800 Subject: [PATCH] fix(code-runtime-python): reject an oversized maxLogBytes at load instead of metering log capture at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child log ledger encodes an admitted entry to UTF-8 once to charge its serialized cost, so a maxLogBytes approaching addressSpaceMb lets a legitimate near-budget log entry breach RLIMIT_AS and die as worker-exit instead of truncating. Two runtime fixes were tried and both traded one resource bound for another: an exact serialized-cost check is either a full encode (the allocation being avoided) or a per-character Python loop that burns the CPU budget (a 10 MB write hits SIGXCPU under cpuSeconds:1). The breach is a property of the maxLogBytes/addressSpaceMb pair, not any write, so reject the incompatible pair at load — maxLogBytes must stay within one eighth of the addressSpaceMb byte count — and revert _LogStream to its original character-count buffering, which is memory-safe once the budget fits the address space. The check runs on every platform since the incompatibility is a config-value property, not a runtime one. Replace the child-flood regression tests (which asserted the reverted runtime behavior) with a load-rejection test. The host-side accrueStrayCost UTF-8 per-lead validation and its tests are unaffected. Update the note and zh pair. --- ...-runtime-python-settlement-fixes.i18n.yaml | 4 +- ...31-code-runtime-python-settlement-fixes.md | 8 +- ...code-runtime-python-settlement-fixes.zh.md | 8 +- .../code-runtime-python/py/bootstrap.py | 114 ++---------------- .../code-runtime-python/src/index.ts | 35 ++++++ .../code-runtime-python/tests/runtime.spec.ts | 70 +++-------- 6 files changed, 75 insertions(+), 164 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 0b625c1d51..13424b956e 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: fc275bd13891c45641e69a773bbe5e05b1d294d3 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: fca7d9245f60f623dc49162c0497ba5b68fedfba +2026-07-31-code-runtime-python-settlement-fixes.md: 3ce090b8399311f3a27a03ea409379cbe55f2c54 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: d14d71cb9390c6cfbdc196a0dd7323e7c6f3a53a 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 fc275bd138..3ce090b839 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 @@ -56,15 +56,15 @@ Also in `src/index.ts`, `spawn` is called before the settlement Promise executor Also in `src/index.ts`, native stdout/stderr bytes (C-extension writes, `os.write` past the pipe buffer) were pushed to `logs` one entry per Node `data` chunk. `logs` entries are joined with `\n` downstream (Code Mode), so a single newline-free write larger than one pipe read — arriving as several `data` chunks — read back with model-visible newlines inserted at arbitrary transport boundaries. Capture now accumulates raw `Buffer` chunks (the same shape as the fd-3 reader, and for the same reasons: a string `+=` accumulator re-copies the whole residual per chunk and scanning it from index 0 each chunk is a second quadratic — both O(N²) on a large newline-free write), splits on the raw `0x0a` byte, and admits one entry per complete line. A newline never appears inside a UTF-8 multibyte sequence, so decoding each split line is safe without a streaming decoder. Three separate bounds keep the residual from exhausting host memory, each mirroring the fd-3 reader: the fragment list SEALS into finished blocks past `MAX_PENDING_CHUNKS` so a program pacing single-byte `os.write`s cannot accumulate millions of live Buffer objects (whose per-object overhead no byte count sees); the residual is flushed when the COMBINED running SERIALIZED cost of both pipes — tracked through `accrueStrayCost`, which decodes UTF-8 structurally across chunks so a byte that renders as U+FFFD is charged the three bytes that replacement character serializes to — would cross the budget, so a control-char or illegal-UTF-8 flood flushes at a fraction of the raw bytes rather than accumulating a full budget's worth of raw bytes first, and stdout and stderr are metered together rather than each against the full budget (which would let both retain nearly a budget's worth at once, doubling the peak); and once the ledger has truncated, buffering stops so nothing accumulates for output that can never be admitted. `accrueStrayCost` charging illegal bytes their U+FFFD width is the fix for a byte that never begins a valid sequence (0x80–0xC1, 0xF5–0xFF), a multibyte sequence that breaks before completing, or a structurally-complete but ILLEGAL sequence: `toString('utf8')` renders each of those bytes as its own U+FFFD (3 bytes), so it validates each lead's first-continuation range (WHATWG: `E0`→A0-BF, `ED`→80-9F, `F0`→90-BF, `F4`→80-8F, others 80-BF) and charges 3 per byte of any sequence outside it. Charging the raw 1 undercounted a `b"\xff"` flood threefold, and charging only the structural width undercounted a CESU-8 surrogate (`ED A0 80`) or overlong (`E0 80 80`) threefold just as cheaply, letting the residual grow to a full budget's worth of raw bytes before flushing and, near a large `maxLogBytes`, expand toward a ~1 GiB peak in the flush's concat plus `toString`. The per-entry charge on the admitted string is metered by SERIALIZED cost through `jsonStringCostUpTo`, which walks the string to the cap and stops — the previous `Buffer.byteLength(JSON.stringify(text))` allocated the whole escaped form first, so a near-budget control-char-dense line under a large `maxLogBytes` could momentarily allocate over a gigabyte just to measure it. `jsonStringCostUpTo` (the string-walking function, reached by a forged `log` frame whose text `JSON.parse` produced) charges a LONE surrogate the full six escaped bytes (`\uXXXX` under ES2019 well-formed `JSON.stringify`), not the three bytes `Buffer.byteLength` reports for its U+FFFD rendering, so a `\ud800` flood is not undercharged by half; `accrueStrayCost` walks raw bytes and never sees a surrogate as such — a CESU-8-encoded surrogate reaches it as three bytes its per-lead range check rejects, each charged 3 (total 9), matching what `toString('utf8')` renders. The residual is flushed on the pipe's `end` and also explicitly in the `closeDeadline` handler before it destroys the streams: a `setsid` escapee holding the pipes open forces settlement through that path without an `end`, so a final newline-free `os.write(1, …)` the leader emitted before exiting would otherwise be dropped from `logs`. -### The child log stream early-flushes by serialized cost, not character count +### An incompatible maxLogBytes/addressSpaceMb pair is rejected at load -In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) the `_LogStream` wrapper around `sys.stdout`/`sys.stderr` buffers writes and early-flushes once the buffered text can no longer fit the ledger, so a flood hits the budget while running rather than at settlement. Every one of those fit checks — the newline-free trigger, and the per-line checks on the newline path (the first reconstructed line and each subsequent line) — compared a CHARACTER count against `remaining` (a SERIALIZED-byte budget). A control character serializes to up to six bytes, so the char count undercounted a control-char flood up to sixfold: 30 million newline-free NUL characters stayed under a 50 MB char-count trigger yet encoded to ~180 MB, and the settlement `"".join` plus `encode` (or, on the newline path, `_logs.push`'s encode of the reconstructed line) allocated that at once — breaching a tight `RLIMIT_AS` and surfacing host-side as `worker-exit` instead of the truncation marker. Each check now weighs the text by serialized cost through `_fragment_cost_upto`, which sums per-character costs from `_json_char_cost` (code point to escaped width, no `encode`) over a `start`/`end` sub-range without slicing and STOPS once the running total passes the budget — so it never materializes an encoded copy (the allocation a single 340 MiB write cannot afford, which an in-tree test pins) and never re-scans the whole buffer per write (quadratic under a daemon-thread flood, which another in-tree test pins). The newline-free trigger accumulates each fragment's result into `_pending_cost` once per write. `_pending_chars` is retained for the character-based slice bounds elsewhere in `write`. +The child's log ledger ([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py)) admits a log entry up to `maxLogBytes` and, to charge its serialized cost, encodes it to UTF-8 once — a transient copy of up to `maxLogBytes` more bytes on top of the interpreter baseline, all under `RLIMIT_AS`. When `maxLogBytes` approaches `addressSpaceMb`, a legitimate near-budget log entry breaches the address space during that encode and dies as `worker-exit` instead of truncating. The ledger's cheap pre-check bounds the encode to the log budget, which is memory-safe only while the log budget itself fits the address space with room to spare — the default 64 KiB against 512 MiB does; a `maxLogBytes` of 50 MB against a 64 MiB `addressSpaceMb` does not. 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: `maxLogBytes` must not exceed `LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION` (one eighth) of the `addressSpaceMb` byte count, leaving an 8× margin over the raw budget for the entry, its encode copy, and the interpreter. The check runs on every platform, not just where `RLIMIT_AS` is enforced: the incompatibility is a property of the two 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 whole OOM 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). ## Testing - `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 child-log-flood case writes a NUL flood in 1 MiB chunks (so no single argument str is the allocation under test) with no newline through `sys.stdout.write` under a 20 MB `maxLogBytes` and a 512 MB `addressSpaceMb`, asserting the run completes at the truncation marker rather than `worker-exit`; a newline-terminated companion writes the same flood one newline-terminated 1 MiB line at a time, covering the line-path fit check. Both catch the pre-fix char-count trigger that let the settlement encode breach `RLIMIT_AS`; the repro is Linux-only since Darwin skips `RLIMIT_AS`, so on macOS they assert the happy path, matching the existing control-char completion cases. A non-integer-budget case asserts a fractional `maxLogBytes`/`maxValueBytes` rejects at load. +- `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 log-budget/address-space case asserts a `maxLogBytes` of 50 MB against a 64 MiB `addressSpaceMb` rejects at load (past one eighth of the address space) while the default 64 KiB against 512 MiB loads. A non-integer-budget case asserts a fractional `maxLogBytes`/`maxValueBytes` rejects at load. ## Alternatives considered @@ -94,6 +94,8 @@ In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/ **Flush the two stray pipes in residual-arrival order when the combined budget crosses.** Rejected: stdout and stderr are independent OS streams whose `data` events already interleave nondeterministically with each other and with the child's own fd-3 `log` frames, so `logs` carries no cross-pipe ordering guarantee to preserve — a fixed drain order is as valid as any arrival order. Tracking a per-residual arrival tick to drain the earlier pipe first would add a branch whose two sides fire only on the relative timing of two OS pipes, which `os.sched_yield` does not make deterministic, so the branch could not be covered without a flaky test — cost with no observable contract benefit. +**Meter the child log ledger against the address space at runtime instead of rejecting the config at load.** Rejected: an exact serialized-cost check on every child write is either a full `encode` — the very allocation an oversized write cannot afford, which the ledger's cheap pre-check exists to avoid — or a per-character Python loop, which burns the CPU budget (a 10 MB legitimate write hits SIGXCPU under `cpuSeconds: 1`). Each runtime approach trades the memory bound for another resource bound on the hot path. The address-space breach is a property of the `maxLogBytes`/`addressSpaceMb` pair, not of any particular write, so rejecting the incompatible pair once at load eliminates the whole class without any per-write cost and keeps `_LogStream`'s original character-count buffering, which is memory-safe once the budget fits the address space. + ## Consequences The seam's resolve-don't-reject contract holds on the boot-write path and the synchronous-spawn-failure path, both with measured coverage, and neither strands a staging directory. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush, and stray native output is delimited by its own newlines rather than by transport chunks. Fd-3 residual memory is bounded by the actual retained bytes, and both frame readers scan an accumulating frame once rather than quadratically. The output caps admit every value a frame can carry and reject a non-integer budget at load. Disposal is genuinely quiescent against a same-group survivor — bounded by `graceMs + 2 * CLOSE_REAP_MARGIN_MS`, zero-cost when the group is already empty, with the SIGKILL timer cleared once the group empties so a stale kill cannot strike a recycled pgid — RLIMIT enforcement keeps the strictest of configured and inherited on both soft and hard (and the SIGXCPU diagnostic no longer names a budget the host cannot guarantee), bindings called from model-created threads complete instead of timing out, and the handshake frame reader no longer burns the CPU budget on a large program. Every behavioral fix carries a test that fails without it, except the three called out in the Problem section — the chunked frame read (a syscall-count improvement), the confirmed-empty finalize (whose only seam-observable effect freezes at signal delivery, which the pre-fix code also produced), and the shared stdout/stderr budget (whose only seam-observable difference turns on nondeterministic cross-pipe arrival timing) — so a future regression on the rest goes red. 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 fca7d9245f..d14d71cb93 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 @@ -56,15 +56,15 @@ Status: implemented 同样在 `src/index.ts` 中,原生 stdout/stderr 字节(C 扩展写入、越过管道缓冲区的 `os.write`)过去每来一个 Node `data` 分片就被推入 `logs` 一条条目。`logs` 条目在下游(Code Mode)会用 `\n` 拼接,因此一次大于单次管道读取、且不含换行符的写入——它以若干个 `data` 分片到达——回读时会在任意传输边界处被插入模型可见的换行符。现在捕获会累积原始 `Buffer` 分片(与 fd-3 读取器同一形态,出于同样的原因:一个字符串 `+=` 累加器会为每个分片重新复制整份残余数据,而每个分片都从索引 0 扫描它则是第二重平方——在一次大的不含换行符的写入上二者都是 O(N²)),在原始的 `0x0a` 字节处切分,并为每个完整行准入一条条目。换行符绝不会出现在一个 UTF-8 多字节序列内部,因此对每个切出的行做解码无需流式解码器即可安全进行。三条相互独立的界限使残余数据不至于耗尽宿主内存,每一条都与 fd-3 读取器相对应:分片列表在越过 `MAX_PENDING_CHUNKS` 后会封存(SEAL)为已完成的块,因此一个以单字节 `os.write` 控速的程序无法累积起数以百万计的存活 Buffer 对象(其逐对象开销是任何字节计数都看不到的);当两个管道合并(COMBINED)的持续推进序列化(SERIALIZED)开销——通过 `accrueStrayCost` 跟踪,它跨分片按结构解码 UTF-8,因此一个渲染为 U+FFFD 的字节会被计入该替换字符序列化后的三个字节——将要越过预算时,残余数据会被冲刷,因此一场控制字符或非法 UTF-8 的洪泛会在原始字节的一小部分处就冲刷,而不是先累积起满满一个预算份额的原始字节,并且 stdout 与 stderr 是合并计量的,而不是各自对照完整预算(那样会让两者同时各保留将近一个预算份额,使峰值翻倍);而一旦账本已经截断,缓冲便停止,从而不会为永远无法被准入的输出累积任何内容。`accrueStrayCost` 按 U+FFFD 宽度对非法字节计费,正是针对一个从不作为合法序列开头的字节(0x80–0xC1、0xF5–0xFF)、一个在完成前断裂的多字节序列,或一个结构完整但非法(ILLEGAL)的序列的修复:`toString('utf8')` 会把其中每一个这样的字节都渲染为它自己的 U+FFFD(3 字节),因此它校验每个前导字节的首个后续字节范围(WHATWG:`E0`→A0-BF、`ED`→80-9F、`F0`→90-BF、`F4`→80-8F,其余为 80-BF),并对任何落在该范围之外的序列按每字节 3 计费。按原始的 1 计费会把一场 `b"\xff"` 洪泛少计三倍,而只按结构宽度计费同样廉价地把一个 CESU-8 代理项(`ED A0 80`)或过长编码(`E0 80 80`)少计三倍,让残余数据在冲刷前增长到满满一个预算份额的原始字节,并且在一个较大的 `maxLogBytes` 附近,在冲刷的 concat 加 `toString` 中膨胀到约 1 GiB 的峰值。被准入字符串的每条条目计费通过 `jsonStringCostUpTo` 按序列化开销计量,它把字符串走到上限即停止——先前的 `Buffer.byteLength(JSON.stringify(text))` 会先分配出整份转义后的形式,因此在一个较大的 `maxLogBytes` 之下,一行接近预算、控制字符密集的内容,仅仅为了度量它就可能瞬时分配超过一 GB。`jsonStringCostUpTo`(走字符串的那个函数,由一个伪造的、其文本经 `JSON.parse` 产生的 `log` 帧到达)给一个孤立(LONE)代理项计满六个转义字节(在 ES2019 良构 `JSON.stringify` 下为 `\uXXXX`),而不是 `Buffer.byteLength` 为其 U+FFFD 渲染所报告的三个字节,因此一场 `\ud800` 洪泛不会被少计一半;`accrueStrayCost` 走原始字节,从不把一个代理项当作代理项看到——一个 CESU-8 编码的代理项到达它时是三个字节,被它的逐前导字节范围检查所拒绝,每个计 3(共 9),与 `toString('utf8')` 所渲染的相符。该残余数据会在管道 `end` 时冲刷,也会在 `closeDeadline` 处理器销毁流之前被显式冲刷:一个持有管道不放的 `setsid` 逃逸者会迫使结算在没有 `end` 的情况下走那条路径,因此 leader 在退出前发出的最后一次不含换行符的 `os.write(1, …)` 否则会从 `logs` 中被丢弃。 -### The child log stream early-flushes by serialized cost, not character count +### An incompatible maxLogBytes/addressSpaceMb pair is rejected at load -在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,包裹 `sys.stdout`/`sys.stderr` 的 `_LogStream` 把写入缓冲起来,一旦缓冲的文本再也放不进账本就提前冲刷,因此一场洪泛会在运行途中而不是在结算时就触及预算。那些放得下检查中的每一个——不含换行符的触发条件,以及换行路径上的逐行检查(首个被重建的行以及每个后续行)——都把一个字符计数与 `remaining`(一个序列化字节预算)作比较。一个控制字符最多序列化为六个字节,因此字符计数会把一场控制字符洪泛最多少计六倍:3000 万个不含换行符的 NUL 字符停留在一个 50 MB 的字符计数触发条件之下,却编码成约 180 MB,而结算的 `"".join` 加 `encode`(或在换行路径上,`_logs.push` 对被重建行的编码)会一次性分配那么多——突破一个收紧的 `RLIMIT_AS`,并在宿主侧表现为 `worker-exit` 而不是截断标记。现在每个检查都通过 `_fragment_cost_upto` 按序列化开销来权衡文本,它在一个 `start`/`end` 子范围上把 `_json_char_cost`(码点到转义宽度,无 `encode`)给出的逐字符开销累加起来而不做切片,并在累计值越过预算时即停止——因此它绝不物化一份编码后的副本(一次 340 MiB 写入负担不起的那种分配,由一个仓内测试钉住),也绝不在每次写入时重新扫描整个缓冲区(在 daemon 线程洪泛下是平方级的,由另一个仓内测试钉住)。不含换行符的触发条件把每个片段的结果每次写入累加进 `_pending_cost` 一次。`_pending_chars` 被保留下来,用于 `write` 中别处基于字符的切片边界。 +子进程的日志账本([`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py))准入一条至多 `maxLogBytes` 的日志条目,并且为了对其序列化开销计费,会把它编码(encode)为 UTF-8 一次——在解释器基线之上一份至多 `maxLogBytes` 的瞬时字节副本,全都处于 `RLIMIT_AS` 之下。当 `maxLogBytes` 逼近 `addressSpaceMb` 时,一条合法的、接近预算的日志条目会在那次编码期间突破地址空间,并作为 `worker-exit` 而不是截断而终止。账本那处廉价的预检把编码约束在日志预算之内,而这仅在日志预算本身能宽裕地放进地址空间时才是内存安全的——默认的 64 KiB 对照 512 MiB 满足这一点;一个 50 MB 的 `maxLogBytes` 对照一个 64 MiB 的 `addressSpaceMb` 则不满足。在运行时对每次子进程写入按地址空间计量是错误的修复:热路径上一次精确的序列化开销检查,要么是一次完整的 `encode`(正是要避免的那次分配),要么是一个逐字符的 Python 循环(它会烧掉 CPU 预算——一次 10 MB 的合法写入会在 `cpuSeconds: 1` 之下触发 SIGXCPU)。两者都是拿一种资源界限换另一种。取而代之,[`src/index.ts`](../../../../packages/code-runtime/code-runtime-python/src/index.ts) 在加载期(LOAD)拒绝这个不兼容的组合:`maxLogBytes` 不得超过 `addressSpaceMb` 字节数的 `LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION`(八分之一),从而为该条目、它的编码副本以及解释器在原始预算之上留出 8 倍的余量。该检查在每个平台上都运行,而不仅在强制 `RLIMIT_AS` 的平台上:这种不兼容是那两个配置值的属性,因此一个 Linux 部署无论由哪个宿主组装配置都会 OOM,而一致的加载期拒绝正是 fail-loud 契约(Darwin 仅跳过运行时的 `setrlimit`)。这在配置 seam 处消除了整类 OOM,而不是给写入路径打补丁,因此 `_LogStream` 保留它原有的按字符计数的缓冲(一个对序列化开销有效的下界,一旦预算放进地址空间就是内存安全的)。 ## Testing - `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 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 child-log-flood 用例在一个 20 MB 的 `maxLogBytes` 和一个 512 MB 的 `addressSpaceMb` 之下,通过 `sys.stdout.write` 以 1 MiB 分块、不含换行符地写入一场 NUL 洪泛(因此没有单个参数 str 是被测的那次分配),断言该次运行在截断标记处完成而不是 `worker-exit`;一个以换行符结尾的配套用例把同一场洪泛以一次一个换行符结尾的 1 MiB 行写入,覆盖换行路径的放得下检查。两者都能捕获修复前那个让结算时的编码突破 `RLIMIT_AS` 的字符计数触发条件;该复现仅限 Linux,因为 Darwin 跳过 `RLIMIT_AS`,所以在 macOS 上它们断言正常路径,与既有的控制字符完成用例相符。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。 +- `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 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 log-budget/address-space 用例断言一个 50 MB 的 `maxLogBytes` 对照一个 64 MiB 的 `addressSpaceMb` 在加载期被拒绝(超过地址空间的八分之一),而默认的 64 KiB 对照 512 MiB 则加载成功。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。 ## Alternatives considered @@ -94,6 +94,8 @@ Status: implemented **当合并预算被越过时,按残余数据到达顺序冲刷两个散逸管道。** 已否决:stdout 与 stderr 是相互独立的 OS 流,它们的 `data` 事件本就彼此之间、以及与子进程自己的 fd-3 `log` 帧之间不确定地交错,因此 `logs` 并不携带任何可供保留的跨管道顺序保证——一个固定的排空顺序与任何到达顺序一样有效。跟踪一个逐残余数据的到达计次以先排空较早的管道,会增加一个分支,它的两侧只在两个 OS 管道的相对时机上才触发,而 `os.sched_yield` 并不使之具有确定性,因此该分支无法在不写一个不稳定测试的情况下被覆盖——有成本却没有可观测的契约收益。 +**在运行时按地址空间对子进程日志账本计量,而不是在加载期拒绝该配置。** 已否决:对每次子进程写入做一次精确的序列化开销检查,要么是一次完整的 `encode`——正是一次超大写入负担不起、而账本那处廉价预检本就为规避它而存在的那次分配——要么是一个逐字符的 Python 循环,它会烧掉 CPU 预算(一次 10 MB 的合法写入会在 `cpuSeconds: 1` 之下触发 SIGXCPU)。每一种运行时做法都是在热路径上拿内存界限换另一种资源界限。地址空间的突破是 `maxLogBytes`/`addressSpaceMb` 组合的属性,而不是任何一次具体写入的属性,因此在加载期一次性拒绝这个不兼容的组合,能在没有任何逐次写入代价的情况下消除整类问题,并保留 `_LogStream` 原有的按字符计数的缓冲,它一旦预算放进地址空间就是内存安全的。 + ## Consequences seam 的"只 resolve、不 reject"契约在引导写入路径和同步 spawn 失败路径上都得以成立,两者的覆盖率都是被度量的,且两者都不会遗留一个暂存目录。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁,并且散逸的原生输出由它自己的换行符来分隔,而不是由传输分片来分隔。fd-3 残余数据的内存受实际保留的字节数约束,并且两个帧读取器都以一次而非平方级的方式扫描一个不断累积的帧。输出上限放行一个帧所能承载的每一个值,并在加载期拒绝一个非整数的预算。dispose 面对同进程组存活者是真正完全停稳的(以 `graceMs + 2 * CLOSE_REAP_MARGIN_MS` 为界,在进程组已为空时代价为零,并且一旦进程组清空就清除 SIGKILL 定时器,从而一次滞留的 kill 无法击中一个被回收的 pgid),RLIMIT 强制在 soft 和 hard 两者上都保持配置值与继承值中的最严格者(并且 SIGXCPU 诊断不再把一个宿主无法保证的预算说出来),并且从模型创建的线程调用的绑定会完成而不是超时,而且握手帧读取器不再在一个大程序上烧掉 CPU 预算。每处行为修复都附带一个在缺少它时会失败的测试,除了 Problem 一节点出的那三处——分块读取帧(一处系统调用次数的改进)、确认为空后的收尾(它唯一透过 seam 可观测的效应会在信号投递时冻结,而修复前的代码也会产生同样的结果),以及共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异取决于不确定的跨管道到达时机)——因此其余各处未来若发生回归都会变红。 diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index bd17b565ec..579da24800 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -167,44 +167,10 @@ class _LogStream(io.TextIOBase): # ``print("x", end="")`` must not concatenate quadratically. self._pending: list[str] = [] self._pending_chars = 0 - # Running serialized JSON cost of the pending tail, maintained beside the - # character count so the early-flush trigger charges against ``remaining`` - # (a serialized-byte budget) rather than undercharging control-char text. - # Accumulated per fragment through ``_fragment_cost_upto`` so no write - # re-scans the whole buffer. - self._pending_cost = 0 def writable(self) -> bool: # noqa: D401 -- inherited contract return True - @staticmethod - def _fragment_cost_upto(chunk: str, limit: int, start: int = 0, end: "int | None" = None) -> int: - # Serialized JSON cost of ``chunk[start:end]``'s characters (no enclosing - # quotes), indexing the str directly and STOPPING once the running total - # passes ``limit`` so the walk is bounded by a budget's worth of - # characters however large the write is. A control character serializes to - # up to six bytes, so a plain character count undercharges a control-char - # flood by up to 6x: 30M NUL characters stay under a 50 MB character budget - # yet serialize to ~180 MB, which the settlement flush would then allocate - # at once and breach RLIMIT_AS. Measuring the true serialized cost fixes - # that, but ``.encode`` to measure it would itself be the copy the - # ``_push_bounded_prefix`` path exists to avoid (a single 340 MiB write - # under a tight addressSpaceMb dies on that encode), and re-scanning the - # whole pending list per write would be quadratic under a daemon-thread - # flood — so the caller accumulates this per-fragment result once and the - # ``limit`` cap keeps each scan bounded. ``start``/``end`` weigh a - # sub-range without slicing it (the slice on a 340 MiB write would be that - # same copy); CPython ``str`` indexing is O(1) per character. - cost = 0 - stop = len(chunk) if end is None else end - index = start - while index < stop: - cost += _json_char_cost(ord(chunk[index])) - if cost > limit: - return cost - index += 1 - return cost - def write(self, text: str) -> int: # noqa: D401 -- inherited contract # Serialize the whole read-modify-write against the settlement flush and # any other thread's write: model code may spawn daemon threads that keep @@ -241,16 +207,7 @@ class _LogStream(io.TextIOBase): pos = 0 if self._pending: newline = text.index("\n") - # Weigh the reconstructed first line by SERIALIZED cost, not - # character count: `_pending_cost` already holds the buffered - # chunks' cost, and the first line's cost is scanned up to the - # newline without slicing `text` (the slice on a 340 MiB write - # would be the copy this path avoids). A character-count check - # undercharged a control-char line — 30M NUL characters plus a - # newline pass `chars + 3 > remaining` under a 50 MB budget, then - # `_logs.push` would encode the 30M-char join and breach RLIMIT_AS. - first_line_cost = self._fragment_cost_upto(text, self._logs.remaining, end=newline) - if self._pending_cost + first_line_cost + 2 > self._logs.remaining: + if self._pending_chars + newline + 3 > self._logs.remaining: # The reconstructed first line cannot fit the ledger, so # LogBuffer would reject it whole: copy only the prefix that # fails its cheap bound and drop the chunks. The slice is @@ -265,7 +222,6 @@ class _LogStream(io.TextIOBase): line = "".join(self._pending) self._pending = [] self._pending_chars = 0 - self._pending_cost = 0 self._logs.push(line) pos = newline + 1 # Scan by offset and STOP once the ledger is exhausted: a single @@ -278,17 +234,14 @@ class _LogStream(io.TextIOBase): newline = text.find("\n", pos) if newline < 0: break - # Bound the SLICE by SERIALIZED cost, not character count: a line - # whose escaped form exceeds the ledger would be copied whole - # before push could reject it, and a control-char-dense line - # (30M NUL characters plus a newline) passes a `chars + 3 > - # remaining` check under a large budget yet encodes to ~6x that, - # so `_logs.push` would allocate the encode and breach RLIMIT_AS. - # `_fragment_cost_upto` scans up to the newline without slicing and - # stops at `remaining`, so an over-budget line takes the bounded - # prefix path; push still rejects that prefix on its own cheap - # bound, emits the marker, and never materializes the full line. - if self._fragment_cost_upto(text, self._logs.remaining, start=pos, end=newline) + 2 > self._logs.remaining: + # Bound the SLICE the same way LogBuffer bounds the encode: a + # first line far above the ledger would be copied whole before + # push could reject it, and that copy is the allocation an + # over-budget write cannot afford. Copy only a budget-sized + # prefix, which push still rejects on its own cheap bound (the + # prefix is longer than `remaining`), so the marker is emitted + # and the oversized line is never materialized. + if newline - pos + 3 > self._logs.remaining: self._logs.push(text[pos:pos + self._logs.remaining + 4]) break self._logs.push(text[pos:newline]) @@ -298,7 +251,6 @@ class _LogStream(io.TextIOBase): tail = text[pos:] self._pending.append(tail) self._pending_chars = len(tail) - self._pending_cost = self._fragment_cost_upto(tail, self._logs.remaining) else: # The ledger ran out with text still unscanned, so that text # IS being dropped and the run must say so. One push is @@ -318,21 +270,11 @@ class _LogStream(io.TextIOBase): else: self._pending.append(text) self._pending_chars += len(text) - # Add this fragment's serialized cost, capped so a single oversized - # write's scan stops at the budget rather than walking all of it. - self._pending_cost += self._fragment_cost_upto(text, self._logs.remaining) # A newline-free flood must hit the budget while running, not at - # settlement. `_pending_cost` weighs the buffered tail by its SERIALIZED - # cost: a control byte serializes to up to six bytes, so a character count - # undercharged control-char floods by up to 6x and a newline-free flood of - # ~30M NUL characters (each 1 char but 6 serialized bytes) stayed under a - # character-count trigger yet encoded to ~180 MB at settlement, breaching - # RLIMIT_AS. The per-fragment scan never encodes the whole buffer (a single - # oversized write must not be copied here, per the `_push_bounded_prefix` - # contract) nor re-scans the pending list per write (which would be - # quadratic under a daemon-thread flood), yet fires no later than a - # character count and strictly earlier for control-dense text. - if self._pending_cost > self._logs.remaining: + # settlement: once the buffered tail alone can no longer fit the + # ledger (chars lower-bound the serialized cost), push it through — LogBuffer + # truncates, emits the marker once, and swallows everything after. + if self._pending_chars > self._logs.remaining: self._push_bounded_prefix() return len(text) @@ -365,7 +307,6 @@ class _LogStream(io.TextIOBase): break self._pending = [] self._pending_chars = 0 - self._pending_cost = 0 self._logs.push("".join(parts)) def flush(self) -> None: # noqa: D401 -- inherited contract @@ -392,7 +333,6 @@ class _LogStream(io.TextIOBase): self._logs.push("".join(self._pending)) self._pending = [] self._pending_chars = 0 - self._pending_cost = 0 # --------------------------------------------------------------------------- @@ -1242,34 +1182,6 @@ for _escaped_byte, _surcharge in _JSON_ESCAPE_SURCHARGES: _JSON_BYTE_COST[_escaped_byte[0]] = 1 + _surcharge -def _json_char_cost(code: int) -> int: - """Serialized JSON cost of one character, from its code point, without encoding. - - Used by X a buffered write's true - serialized cost while scanning the str directly, so the early-flush trigger - charges a control character its full escaped width (a NUL is six bytes as - ``\\u0000``) rather than the single character a length count sees. A C0 - control escapes to two bytes for the five shorthand forms or six for the - rest; ``"`` and ``\\`` escape to two; every other character stays at its raw - UTF-8 width (1/2/3 for the basic plane, 4 for an astral code point), which is - what the encoded form would hold. A lone surrogate is unreachable here — a - Python ``str`` character iterates as one code point and the caller's text has - already replaced any un-encodable surrogate. - """ - - if code < 0x20: - return 2 if code in (0x08, 0x09, 0x0a, 0x0c, 0x0d) else 6 - if code == 0x22 or code == 0x5c: - return 2 - if code < 0x80: - return 1 - if code < 0x800: - return 2 - if code < 0x10000: - return 3 - return 4 - - def _json_string_cost(raw: bytes) -> int: """UTF-8 byte length of one string's JSON form, WITHOUT building that form. diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 57a74c8da8..92f49cc396 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -216,6 +216,18 @@ const FRAME_ENVELOPE_BYTES = 64 */ const CLOSE_REAP_MARGIN_MS = 2_000 +/** + * The largest fraction of `addressSpaceMb` that `maxLogBytes` may claim, enforced + * at load. The child's log ledger encodes an admitted entry to UTF-8 once to + * charge its serialized cost, so a near-budget entry transiently needs the entry + * plus its encode copy — roughly twice `maxLogBytes` — on top of the interpreter + * baseline, all under `RLIMIT_AS`. One eighth leaves an 8x margin over the raw + * budget, comfortably past that transient at any admissible cap, so a legitimate + * near-budget log entry truncates instead of breaching the address space. A fixed + * safety invariant tying two configs together, not a deployment knob. + */ +const LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION = 1 / 8 + /** * Interval between process-group liveness probes while settlement waits for an * escalated SIGKILL to empty the group (see the `killing` branch in @@ -676,6 +688,29 @@ export class PythonCodeRuntime extends CodeRuntime { throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the ${FRAME_CEILING_BYTES}-byte fd-3 frame ceiling, so the run would fail as worker-exit rather than output-limit), got ${String(this.config[key])}`) } } + // The child's log ledger admits an entry up to `maxLogBytes` and, to charge + // its serialized cost, encodes it to UTF-8 once — a transient allocation of + // up to `maxLogBytes` more bytes (and a control-char-dense entry escapes up + // to sixfold on the wire, though the encode itself is the raw copy). That + // copy happens under `RLIMIT_AS`, so a `maxLogBytes` that approaches + // `addressSpaceMb` makes a legitimate near-budget log entry breach the + // address space and die as `worker-exit` instead of truncating. Rather than + // meter every child write against the address space at runtime — which trades + // the memory bound for a per-character CPU cost on the hot path — reject the + // incompatible pair at load: require `maxLogBytes` to leave the child room + // for the interpreter baseline plus the entry and its encode copy. The bound + // is `LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION` of the address space, well + // clear of the ~2x-plus-baseline the push path needs at the default 64 KiB + // cap. Checked on every platform, not just where `RLIMIT_AS` is enforced: the + // incompatibility is a property of the two config values, and the child OOMs + // on a Linux deployment regardless of the host that assembled the config, so + // a uniform load-time rejection is the fail-loud contract (Darwin skips only + // the runtime `setrlimit`, not this static check). + const addressSpaceBytes = this.config.addressSpaceMb * 1024 * 1024 + const logCaptureCeiling = Math.floor(addressSpaceBytes * LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION) + if (this.config.maxLogBytes > logCaptureCeiling) { + throw new Error(`dsh-code-runtime-python: config.maxLogBytes must not exceed ${logCaptureCeiling} (${LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION} of the ${addressSpaceBytes}-byte addressSpaceMb, leaving the child room to encode a near-budget log entry without breaching RLIMIT_AS), got ${String(this.config.maxLogBytes)}`) + } ctx.effect(() => () => this.teardown(), 'python code-runtime teardown') } 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 360d6152ce..89e9534d19 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -747,61 +747,21 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(result.logs.at(-1)).toBe(logTruncationMarker(4096)) }) - it('bounds a newline-free NUL flood through sys.stdout by serialized cost, not char count', async () => { - // `_LogStream` (the child's sys.stdout wrapper) buffers newline-free writes - // and early-flushes once the pending tail can no longer fit the ledger. - // Charging that trigger by CHARACTER count undercharged a control-char flood - // by up to 6x: NUL chars stay under a char-count trigger yet serialize to ~6x - // as many bytes, which the settlement "".join + encode then allocated at once. - // The program writes the flood in 1 MiB chunks (so no single argument str is - // itself the allocation under test) with no newline; the serialized-cost - // trigger flushes while running, keeping the pending tail bounded, so the run - // completes at the truncation marker. Pre-fix, the char-count trigger stayed - // dormant until ~200 MiB of chars accumulated, and the settlement encode of - // their ~1.2 GiB serialized form breached the 512 MiB RLIMIT_AS as a - // worker-exit. Driven through sys.stdout.write (not os.write, which bypasses - // the wrapper into host stray capture) to exercise the in-child stream. - // RLIMIT_AS is skipped on Darwin, so the worker-exit repro is Linux-only; - // on macOS this asserts the happy path, matching the control-char cases. - const { runtime } = await setup({ maxLogBytes: 20_000_000, addressSpaceMb: 512, maxWallMs: 30_000 }) - const result = await runtime.run({ - program: [ - 'import sys', - 'chunk = "\\x00" * (1024 * 1024)', - 'for _ in range(200):', - ' sys.stdout.write(chunk)', - 'return None', - ].join('\n'), - bindings: [], - }) - expect(result.error).toBeUndefined() - expect(result.logs.at(-1)).toBe(logTruncationMarker(20_000_000)) - }) - - it('bounds a NEWLINE-terminated NUL flood through sys.stdout by serialized cost', async () => { - // The newline path of `_LogStream.write` scans and pushes each completed - // LINE. Its per-line fit check charged CHARACTER count, so a control-char - // line (a chunk of NULs ending in a newline) passed `chars + 3 > remaining` - // under a large budget yet `_logs.push` then encoded the whole line at - // settlement — the same RLIMIT_AS breach as the newline-free path, on a - // different branch. The check now weighs the line by serialized cost via - // `_fragment_cost_upto` (scanning to the newline without slicing), so an - // over-budget line takes the bounded-prefix path and the run truncates. Each - // 1 MiB NUL chunk is newline-terminated so it exercises the line branch; - // driven through sys.stdout.write, Linux-only RLIMIT_AS repro, macOS happy path. - const { runtime } = await setup({ maxLogBytes: 20_000_000, addressSpaceMb: 512, maxWallMs: 30_000 }) - const result = await runtime.run({ - program: [ - 'import sys', - 'chunk = "\\x00" * (1024 * 1024) + "\\n"', - 'for _ in range(200):', - ' sys.stdout.write(chunk)', - 'return None', - ].join('\n'), - bindings: [], - }) - expect(result.error).toBeUndefined() - expect(result.logs.at(-1)).toBe(logTruncationMarker(20_000_000)) + it('rejects a maxLogBytes that could breach addressSpaceMb during log encode at load', async () => { + // The child's log ledger encodes an admitted entry to UTF-8 once to charge + // its serialized cost, so a `maxLogBytes` approaching `addressSpaceMb` lets a + // legitimate near-budget log entry breach RLIMIT_AS and die as worker-exit + // instead of truncating. The incompatible pair is rejected at load rather + // than metered per-write at runtime: `maxLogBytes` must stay within one + // eighth of the `addressSpaceMb` byte count. 50 MB against a 64 MiB address + // space is far over that bound; the default 64 KiB against 512 MiB is not. + const ctx = new Context() + await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 })) + .rejects.toThrow(/maxLogBytes must not exceed .* of the .*addressSpaceMb/) + // A compatible pair loads. + const ok = new Context() + const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, addressSpaceMb: 512 }) + await fiber.dispose() }) it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => {