From dbff8ffba3ffa072f76b9f8c0f3eebac3ff2bf43 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 01:33:35 +0800 Subject: [PATCH] fix(code-runtime-python): charge illegal UTF-8 by its U+FFFD width on both log paths The host stray-capture cost function charged illegal UTF-8 bytes (0x80-0xC1, 0xF5-0xFF, and orphaned multibyte leads) the raw 1, but toString('utf8') renders each as U+FFFD (3 serialized bytes). A b"\xff" flood was undercounted threefold, so the residual grew to a full budget's worth of raw bytes before flushing and, near a large maxLogBytes, expanded toward a ~1 GiB peak in the flush's concat plus toString. Replace serializedBufferCost with accrueStrayCost, a cross-chunk UTF-8 walker that charges each byte its decoded serialized width; carry its sequence state on each StrayBuffer. The child _LogStream had the same-family bug: its early-flush trigger compared _pending_chars (character count) against remaining (a serialized-byte budget), so a 30M-NUL newline-free flood stayed under a 50 MB char trigger yet encoded to ~180 MB at settlement, breaching RLIMIT_AS as worker-exit. Track _pending_cost via the _JSON_BYTE_COST table and trigger on it; keep _pending_chars for the char-based slice bounds. Correct the note's surrogate claim (only the string-walking jsonStringCostUpTo charges a lone surrogate six bytes; the byte walker never sees one). Shrink the post-truncation fixture below PIPE_BUF for a deterministic single callback. List the shared stdout/stderr budget as a third honest fail-before exception (cross-pipe arrival timing is nondeterministic). Add illegal-UTF-8, broken-multibyte, and child-log-flood regression tests; sync the zh pair. --- ...-runtime-python-settlement-fixes.i18n.yaml | 4 +- ...31-code-runtime-python-settlement-fixes.md | 12 +- ...code-runtime-python-settlement-fixes.zh.md | 12 +- .../code-runtime-python/py/bootstrap.py | 29 ++++- .../code-runtime-python/src/index.ts | 116 +++++++++++++----- .../code-runtime-python/tests/runtime.spec.ts | 95 +++++++++++++- 6 files changed, 224 insertions(+), 44 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 4daee8e02b..0c9631222c 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: a62b6c67da185081bce7895af242473797722423 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: c04131399c50d53749656111a478dc3c756e32cb +2026-07-31-code-runtime-python-settlement-fixes.md: b667ec543512ede1c1fe0402122943e6a13f7488 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: f21464d4e96a42552c96e27cb222ac31b6bf2e75 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 a62b6c67da..b667ec5435 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 @@ -6,7 +6,7 @@ English | [中文](2026-07-31-code-runtime-python-settlement-fixes.zh.md) ## Problem -The CPython subprocess backend for Code Mode, built on the [fd-3 frame protocol](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md), resolves every program outcome as a `CodeRunResult`, rejects `run()` only for seam misuse, and disposes to quiescence so no subprocess that stays in the child's process group outlives the fiber (a descendant that escapes the group with `setsid()` is the documented exception — see the package README's Known Limitations). A sequence of review passes surfaced defects that broke those contracts in ways unit coverage did not catch — each hid behind a `/* v8 ignore */`, a captured-callable that read as a fix but was not, a memory effect invisible through the seam, a load-time bound that double-counted, a process-group escalation that a survivor could outlast, a cross-event-loop completion that silently deadlocked, a synchronous throw outside the settlement path, or a transport boundary rendered as a log boundary. Most behavioral fixes ship with a test that fails without them; two do not, and are called out as such — the chunked frame read (a syscall-count improvement with no cross-platform-deterministic failure) and the confirmed-empty finalize (its only seam-observable effect, a frozen heartbeat, freezes the instant SIGKILL is delivered, which the pre-fix finalize-on-delivery code also produced, and the discriminating probe is the signal-0 check the Alternatives reject as cross-environment-unreliable). +The CPython subprocess backend for Code Mode, built on the [fd-3 frame protocol](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md), resolves every program outcome as a `CodeRunResult`, rejects `run()` only for seam misuse, and disposes to quiescence so no subprocess that stays in the child's process group outlives the fiber (a descendant that escapes the group with `setsid()` is the documented exception — see the package README's Known Limitations). A sequence of review passes surfaced defects that broke those contracts in ways unit coverage did not catch — each hid behind a `/* v8 ignore */`, a captured-callable that read as a fix but was not, a memory effect invisible through the seam, a load-time bound that double-counted, a process-group escalation that a survivor could outlast, a cross-event-loop completion that silently deadlocked, a synchronous throw outside the settlement path, or a transport boundary rendered as a log boundary. Most behavioral fixes ship with a test that fails without them; three do not, and are called out as such — the chunked frame read (a syscall-count improvement with no cross-platform-deterministic failure), the confirmed-empty finalize (its only seam-observable effect, a frozen heartbeat, freezes the instant SIGKILL is delivered, which the pre-fix finalize-on-delivery code also produced, and the discriminating probe is the signal-0 check the Alternatives reject as cross-environment-unreliable), and the shared stdout/stderr budget (its only seam-observable difference is which entry boundary a mid-stream flush lands on, and that depends on the relative arrival timing of two independent OS pipes, which `os.sched_yield` does not make deterministic; the per-pipe memory bounds it strengthens ARE covered by the single-pipe flood tests). ## Decision @@ -54,13 +54,17 @@ Also in `src/index.ts`, `spawn` is called before the settlement Promise executor ### Stray pipe output is aggregated by line, not by transport chunk -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 per byte through `serializedBufferCost`, a lower bound on the admitted line's exact cost — would cross the budget, so a control-char-dense flood flushes at roughly a sixth 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. The per-entry charge itself 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. Both cost functions charge 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: a forged `log` frame flooding `\ud800` escapes would otherwise be undercharged by half and admit roughly twice the budget. 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`. +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) or an incomplete multibyte sequence: charging each such byte the raw 1 undercounted a `b"\xff"` flood threefold, 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 invalid bytes and is charged 3, its documented illegal-byte width. 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 + +In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) the `_LogStream` wrapper around `sys.stdout`/`sys.stderr` buffers newline-free writes in a `_pending` list and early-flushes once the buffered tail can no longer fit the ledger, so a flood hits the budget while running rather than at settlement. That trigger compared `_pending_chars` (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` allocated that at once — breaching a tight `RLIMIT_AS` and surfacing host-side as `worker-exit` instead of the truncation marker. The stream now tracks `_pending_cost` alongside `_pending_chars`, accruing each appended fragment's serialized cost through the existing `_JSON_BYTE_COST` table, and the trigger fires on `_pending_cost`. Serialized cost is at least the character count, so the flush fires no later than before and strictly earlier for control-dense text; `_pending_chars` is retained for the character-based slice bounds elsewhere in `write`. ## 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); 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 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 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 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 109-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 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 30 million newline-free NULs through `sys.stdout.write` under a 50 MB `maxLogBytes` and a 64 MB `addressSpaceMb` and asserts the run completes at the truncation marker rather than `worker-exit` (the pre-fix char-count trigger let the settlement encode breach `RLIMIT_AS`; the repro is Linux-only since Darwin skips `RLIMIT_AS`, so on macOS it asserts the happy path, matching the existing control-char completion cases). A non-integer-budget case asserts a fractional `maxLogBytes`/`maxValueBytes` rejects at load. ## Alternatives considered @@ -90,4 +94,4 @@ Also in `src/index.ts`, native stdout/stderr bytes (C-extension writes, `os.writ ## 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 two called out in the Problem section — the chunked frame read (a syscall-count improvement) and the confirmed-empty finalize (whose only seam-observable effect freezes at signal delivery, which the pre-fix code also produced) — so a future regression on the rest goes red. +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 c04131399c..f21464d4e9 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 @@ -6,7 +6,7 @@ Status: implemented ## Problem -用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`,并且会 dispose 到完全停稳,从而没有任何留在子进程自己进程组内的子进程存活得比 fiber 更久(一个用 `setsid()` 逃出该进程组的后代是有文档记载的例外——见该包 README 的 Known Limitations)。一连串审查暴露出一些缺陷,它们以单元测试覆盖率无法捕获的方式破坏了这些契约:每一个都藏在一处 `/* v8 ignore */` 之后、一个读起来像修复但实际并非修复的捕获可调用对象之后、一处透过 seam 不可见的内存效应之后、一处重复计数的加载期上界之后、一处存活者能够熬过的进程组升级之后、一处静默死锁的跨事件循环完成之后、一处位于结算路径之外的同步抛出之后,或者一处被当作日志边界处理的传输边界之后。大多数行为修复都附带一个在缺少它时会失败的测试;有两处没有,并被如此标注——分块读取帧(一处系统调用次数的改进,没有可跨平台确定性断言的失败),以及确认为空后的收尾(它唯一透过 seam 可观测的效应,即一个冻结的心跳,会在 SIGKILL 被投递的瞬间冻结,而修复前"投递即收尾"的代码也会产生同样的结果,用于区分的探测手段是 Alternatives 以跨环境不可靠为由否决的 signal-0 检查)。 +用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`,并且会 dispose 到完全停稳,从而没有任何留在子进程自己进程组内的子进程存活得比 fiber 更久(一个用 `setsid()` 逃出该进程组的后代是有文档记载的例外——见该包 README 的 Known Limitations)。一连串审查暴露出一些缺陷,它们以单元测试覆盖率无法捕获的方式破坏了这些契约:每一个都藏在一处 `/* v8 ignore */` 之后、一个读起来像修复但实际并非修复的捕获可调用对象之后、一处透过 seam 不可见的内存效应之后、一处重复计数的加载期上界之后、一处存活者能够熬过的进程组升级之后、一处静默死锁的跨事件循环完成之后、一处位于结算路径之外的同步抛出之后,或者一处被当作日志边界处理的传输边界之后。大多数行为修复都附带一个在缺少它时会失败的测试;有三处没有,并被如此标注——分块读取帧(一处系统调用次数的改进,没有可跨平台确定性断言的失败)、确认为空后的收尾(它唯一透过 seam 可观测的效应,即一个冻结的心跳,会在 SIGKILL 被投递的瞬间冻结,而修复前"投递即收尾"的代码也会产生同样的结果,用于区分的探测手段是 Alternatives 以跨环境不可靠为由否决的 signal-0 检查),以及共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异,是一次流中冲刷落在哪条条目边界上,而这取决于两条相互独立的 OS 管道的相对到达时机,`os.sched_yield` 并不能使其确定;它所强化的按管道计的内存界限确实由单管道洪泛测试覆盖)。 ## Decision @@ -54,13 +54,17 @@ Status: implemented ### Stray pipe output is aggregated by line, not by transport chunk -同样在 `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)开销——通过 `serializedBufferCost` 逐字节跟踪,它是被准入行确切开销的一个下界——将要越过预算时,残余数据会被冲刷,因此一场控制字符密集的洪泛会在大约六分之一的原始字节处就冲刷,而不是先累积起满满一个预算份额的原始字节,并且 stdout 与 stderr 是合并计量的,而不是各自对照完整预算(那样会让两者同时各保留将近一个预算份额,使峰值翻倍);而一旦账本已经截断,缓冲便停止,从而不会为永远无法被准入的输出累积任何内容。每条条目的计费本身通过 `jsonStringCostUpTo` 按序列化开销计量,它把字符串走到上限即停止——先前的 `Buffer.byteLength(JSON.stringify(text))` 会先分配出整份转义后的形式,因此在一个较大的 `maxLogBytes` 之下,一行接近预算、控制字符密集的内容,仅仅为了度量它就可能瞬时分配超过一 GB。两个开销函数都会给一个孤立(LONE)代理项计满六个转义字节(在 ES2019 良构 `JSON.stringify` 下为 `\uXXXX`),而不是 `Buffer.byteLength` 为其 U+FFFD 渲染所报告的三个字节:否则一个伪造的、以 `\ud800` 转义洪泛的 `log` 帧会被少计一半,并放行大约两倍于预算的内容。该残余数据会在管道 `end` 时冲刷,也会在 `closeDeadline` 处理器销毁流之前被显式冲刷:一个持有管道不放的 `setsid` 逃逸者会迫使结算在没有 `end` 的情况下走那条路径,因此 leader 在退出前发出的最后一次不含换行符的 `os.write(1, …)` 否则会从 `logs` 中被丢弃。 +同样在 `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)或一个不完整多字节序列的修复:把每个这样的字节按原始的 1 计费会把一场 `b"\xff"` 洪泛少计三倍,让残余数据在冲刷前增长到满满一个预算份额的原始字节,并且在一个较大的 `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,即其有文档记载的非法字节宽度。该残余数据会在管道 `end` 时冲刷,也会在 `closeDeadline` 处理器销毁流之前被显式冲刷:一个持有管道不放的 `setsid` 逃逸者会迫使结算在没有 `end` 的情况下走那条路径,因此 leader 在退出前发出的最后一次不含换行符的 `os.write(1, …)` 否则会从 `logs` 中被丢弃。 + +### The child log stream early-flushes by serialized cost, not character count + +在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,包裹 `sys.stdout`/`sys.stderr` 的 `_LogStream` 把不含换行符的写入缓冲在一个 `_pending` 列表里,一旦缓冲的尾部再也放不进账本就提前冲刷,因此一场洪泛会在运行途中而不是在结算时就触及预算。那个触发条件把 `_pending_chars`(一个字符计数)与 `remaining`(一个序列化字节预算)作比较。一个控制字符最多序列化为六个字节,因此字符计数会把一场控制字符洪泛最多少计六倍:3000 万个不含换行符的 NUL 字符停留在一个 50 MB 的字符计数触发条件之下,却编码成约 180 MB,而结算的 `"".join` 加 `encode` 会一次性分配那么多——突破一个收紧的 `RLIMIT_AS`,并在宿主侧表现为 `worker-exit` 而不是截断标记。现在该流在 `_pending_chars` 之外还跟踪 `_pending_cost`,通过既有的 `_JSON_BYTE_COST` 表累加每个追加片段的序列化开销,触发条件以 `_pending_cost` 为准。序列化开销至少不小于字符计数,因此冲刷绝不会比先前更晚触发,而对控制字符密集的文本则严格更早触发;`_pending_chars` 被保留下来,用于 `write` 中别处基于字符的切片边界。 ## 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 倍,且在度量时不分配转义后的副本);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 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 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 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,因此该界限具有区分力);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 109 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 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 用例在一个 50 MB 的 `maxLogBytes` 和一个 64 MB 的 `addressSpaceMb` 之下,通过 `sys.stdout.write` 写入 3000 万个不含换行符的 NUL,断言该次运行在截断标记处完成而不是 `worker-exit`(修复前的字符计数触发条件会让结算时的编码突破 `RLIMIT_AS`;该复现仅限 Linux,因为 Darwin 跳过 `RLIMIT_AS`,所以在 macOS 上它断言正常路径,与既有的控制字符完成用例相符)。一个 non-integer-budget 用例断言一个小数的 `maxLogBytes`/`maxValueBytes` 在加载期被拒绝。 ## Alternatives considered @@ -90,4 +94,4 @@ Status: implemented ## 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 可观测的效应会在信号投递时冻结,而修复前的代码也会产生同样的结果)——因此其余各处未来若发生回归都会变红。 +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 579da24800..e989eb97c0 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -167,10 +167,22 @@ 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, kept beside the + # character count because the early-flush trigger charges against + # ``remaining`` (a serialized-byte budget) and a control byte serializes + # to up to six bytes. + self._pending_cost = 0 def writable(self) -> bool: # noqa: D401 -- inherited contract return True + @staticmethod + def _fragment_cost(chunk: str) -> int: + # Serialized JSON cost of one pending fragment WITHOUT the enclosing + # quotes, so the running total mirrors what LogBuffer charges at + # settlement. Mirrors :func:`_json_string_cost` minus its two quotes. + return sum(_JSON_BYTE_COST[b] for b in chunk.encode("utf-8", errors="replace")) + 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 @@ -222,6 +234,7 @@ 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 @@ -251,6 +264,7 @@ class _LogStream(io.TextIOBase): tail = text[pos:] self._pending.append(tail) self._pending_chars = len(tail) + self._pending_cost = self._fragment_cost(tail) else: # The ledger ran out with text still unscanned, so that text # IS being dropped and the run must say so. One push is @@ -270,11 +284,18 @@ class _LogStream(io.TextIOBase): else: self._pending.append(text) self._pending_chars += len(text) + self._pending_cost += self._fragment_cost(text) # A newline-free flood must hit the budget while running, not at # 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: + # ledger, push it through — LogBuffer truncates, emits the marker once, + # and swallows everything after. Trigger on the SERIALIZED cost, not the + # character count: a control byte serializes to up to six bytes, so the + # char-count version 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 char-count trigger yet encoded to ~180 MB at + # settlement, breaching RLIMIT_AS. Serialized cost >= char count, so this + # fires no later than before and strictly earlier for control-dense text. + if self._pending_cost > self._logs.remaining: self._push_bounded_prefix() return len(text) @@ -307,6 +328,7 @@ 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 @@ -333,6 +355,7 @@ class _LogStream(io.TextIOBase): self._logs.push("".join(self._pending)) self._pending = [] self._pending_chars = 0 + self._pending_cost = 0 # --------------------------------------------------------------------------- diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index d9586010dc..41cce884bb 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -353,27 +353,80 @@ function jsonStringCostUpTo(text: string, maxBytes: number): number | undefined } /** - * Serialized-cost lower bound of raw UTF-8 `buf`, charged per byte without - * decoding: a control byte below 0x20 costs 6 (`\uXXXX`) or 2 (the five - * short-form escapes), `"`/`\` cost 2, and every other byte — including each - * byte of a multibyte sequence — costs at least 1. It is exact for valid UTF-8 - * (a W-byte character serializes to W bytes) and a lower bound for invalid bytes - * (each decodes to U+FFFD at 3 bytes but is charged 1); since each byte costs at - * least its raw 1, the total is always ≥ the raw byte count, so a threshold on - * this cost flushes no later than a raw-byte threshold and strictly earlier for - * control-dense output. Used to bound the stray-capture residual by what the - * ledger can actually admit rather than by raw length, so a NUL flood under a - * large `maxLogBytes` flushes at roughly a sixth of the raw bytes instead of - * accumulating the full budget's worth before `admit` truncates it. - * @param buf - raw bytes from a stdout/stderr pipe chunk. - * @returns the summed per-byte serialized cost. + * Cross-chunk UTF-8 state for {@link accrueStrayCost}: `expected` continuation + * bytes still needed to finish the in-progress sequence, and its total `width`. + * Both zero between sequences. Carried on each {@link StrayBuffer} so a multibyte + * character split across pipe `data` chunks is costed as one character, not as + * two broken fragments. */ -function serializedBufferCost(buf: Buffer): number { +interface Utf8CostState { expected: number; width: number } + +/** + * Accrue the serialized JSON cost of raw pipe bytes `buf`, decoding UTF-8 + * structurally so a byte that `toString('utf8')` would render as U+FFFD is + * charged the three bytes that replacement character serializes to — not the one + * byte a naive per-byte tally gives it. Without this a `b"\xff" * N` flood (every + * byte illegal, so U+FFFD each) counted `cost = raw`, letting the residual grow + * to a full budget's worth of RAW bytes before flushing; near a large + * `maxLogBytes` that retained ~256 MiB, then `flushStray`'s `Buffer.concat` + + * `toString` expanded it to a ~1 GiB peak before `admit`'s exact check could + * truncate. A control byte below 0x20 still costs 6 (`\uXXXX`) or 2 (the five + * short escapes); `"`/`\` cost 2; ASCII costs 1; a structurally valid multibyte + * sequence costs its byte width (2/3/4); any byte outside a valid structure + * costs 3. Exotic structurally-valid-but-invalid encodings (overlong forms, + * CESU-8 surrogates) are charged their structural width rather than the larger + * per-byte U+FFFD cost — a bounded under-count on inputs a flood cannot cheaply + * produce, and `admit`'s exact `jsonStringCostUpTo` on the decoded string remains + * the truncation backstop. `state` carries the in-progress sequence across + * chunks; a sequence left unfinished at the stream's end is decoded by the final + * `flushStray` and costed exactly there. + * @param buf - raw bytes from a stdout/stderr pipe chunk. + * @param state - the pipe's carried UTF-8 sequence state, mutated in place. + * @returns the serialized cost accrued by the bytes that resolved in this call. + */ +function accrueStrayCost(buf: Buffer, state: Utf8CostState): number { let cost = 0 - for (const byte of buf) { - if (byte < 0x20) cost += byte === 0x08 || byte === 0x09 || byte === 0x0a || byte === 0x0c || byte === 0x0d ? 2 : 6 - else if (byte === 0x22 || byte === 0x5c) cost += 2 - else cost += 1 + let index = 0 + while (index < buf.length) { + const byte = buf[index] as number + if (state.expected > 0) { + if (byte >= 0x80 && byte <= 0xbf) { + state.expected -= 1 + if (state.expected === 0) { + cost += state.width + state.width = 0 + } + index += 1 + continue + } + // The sequence broke before completing: every byte consumed so far + // (`width - expected`) is an invalid byte that decodes to U+FFFD (3). Then + // reprocess this byte as a fresh start (no index advance). + cost += (state.width - state.expected) * 3 + state.expected = 0 + state.width = 0 + continue + } + if (byte < 0x20) { + cost += byte === 0x08 || byte === 0x09 || byte === 0x0a || byte === 0x0c || byte === 0x0d ? 2 : 6 + } else if (byte === 0x22 || byte === 0x5c) { + cost += 2 + } else if (byte < 0x80) { + cost += 1 + } else if (byte >= 0xc2 && byte <= 0xdf) { + state.expected = 1 + state.width = 2 + } else if (byte >= 0xe0 && byte <= 0xef) { + state.expected = 2 + state.width = 3 + } else if (byte >= 0xf0 && byte <= 0xf4) { + state.expected = 3 + state.width = 4 + } else { + // 0x80–0xc1 and 0xf5–0xff never begin a valid sequence: U+FFFD (3). + cost += 3 + } + index += 1 } return cost } @@ -838,9 +891,9 @@ export class PythonCodeRuntime extends CodeRuntime { // `os.write`s accumulates one Buffer object per write, and the object plus // backing-store overhead — which no byte or cost count sees — exhausts the // host heap far below the budget. Sealing bounds the live object count. - interface StrayBuffer { chunks: Buffer[]; blocks: Buffer[]; cost: number } - const strayOut: StrayBuffer = { chunks: [], blocks: [], cost: 0 } - const strayErr: StrayBuffer = { chunks: [], blocks: [], cost: 0 } + interface StrayBuffer { chunks: Buffer[]; blocks: Buffer[]; cost: number; utf8: Utf8CostState } + const strayOut: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0 } } + const strayErr: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0 } } const captureStray = (stray: StrayBuffer, chunk: Buffer): void => { // Once the ledger has truncated, stop buffering: admit() is a no-op past // that point, so continuing to accumulate would retain host memory for @@ -848,12 +901,12 @@ export class PythonCodeRuntime extends CodeRuntime { if (logsTruncated) return stray.chunks.push(chunk) // Track SERIALIZED cost, not raw bytes: a control-char-dense residual - // (a NUL flood) serializes several-fold, so a raw-byte threshold would - // let it grow to the full budget's worth of RAW bytes — up to ~6x what - // the ledger can admit — before flushing. The per-byte cost is a lower - // bound on the admitted line's exact cost, so flushing when it crosses - // the budget bounds the residual by what `admit` can actually keep. - stray.cost += serializedBufferCost(chunk) + // (a NUL or illegal-UTF-8 flood) serializes several-fold, so a raw-byte + // threshold would let it grow to the full budget's worth of RAW bytes + // before flushing. `accrueStrayCost` decodes UTF-8 structurally across + // chunks (via `stray.utf8`) so a byte that renders as U+FFFD is charged + // its three serialized bytes, not one. + stray.cost += accrueStrayCost(chunk, stray.utf8) // Bound the live fragment count (see the seal rationale above), before // any concat so an over-count payload is never copied whole first. if (stray.chunks.length >= MAX_PENDING_CHUNKS) { @@ -870,8 +923,12 @@ export class PythonCodeRuntime extends CodeRuntime { } // Carry the residual as a fresh right-sized copy, not the subarray view // (which would pin the whole concat allocation). See detachResidual. + // The residual begins at a character boundary (a newline is never + // inside a multibyte sequence), so its cost and UTF-8 state recompute + // cleanly from a fresh walk. stray.chunks = detachResidual(buffered) - stray.cost = serializedBufferCost(buffered) + stray.utf8 = { expected: 0, width: 0 } + stray.cost = accrueStrayCost(buffered, stray.utf8) } // Newline-free residual is bounded by the ledger, not left to grow with // the stream: an `os.write(1, b"A"*N)` flood carrying no newline would @@ -904,6 +961,7 @@ export class PythonCodeRuntime extends CodeRuntime { stray.chunks = [] stray.blocks = [] stray.cost = 0 + stray.utf8 = { expected: 0, width: 0 } admit(tail) } child.stdout.on('data', (chunk: Buffer) => { captureStray(strayOut, chunk) }) 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 68ab878c59..1af772036c 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -747,6 +747,70 @@ 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: 30M NUL chars stay under a 50 MB char-count trigger yet + // serialize to ~180 MB, which the settlement flush then allocated at once — + // breaching a 64 MB RLIMIT_AS and surfacing as worker-exit instead of the + // truncation marker. Driving the flood through sys.stdout.write (not + // os.write, which bypasses the wrapper into host stray capture) exercises the + // in-child stream. On Linux CI the pre-fix trigger dies on RLIMIT_AS; the + // serialized-cost trigger flushes while running, so the run completes and + // ends at the marker. (RLIMIT_AS is skipped on Darwin — bootstrap.py — so the + // worker-exit repro is Linux-only; locally this asserts the happy path.) + const { runtime } = await setup({ maxLogBytes: 50_000_000, addressSpaceMb: 64, maxWallMs: 20_000 }) + const result = await runtime.run({ + program: ['import sys', 'sys.stdout.write("\\x00" * 30_000_000)', 'return None'].join('\n'), + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.logs.at(-1)).toBe(logTruncationMarker(50_000_000)) + }) + + it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => { + // Every 0xFF byte is illegal in any UTF-8 sequence, so `toString('utf8')` + // renders each as U+FFFD (3 serialized bytes). `accrueStrayCost` must charge + // that 3, not the raw 1: otherwise the newline-free residual grows to a full + // budget's worth of RAW bytes before flushing — a ~3x undercount that near a + // large maxLogBytes retains hundreds of MiB then expands toward a ~1 GiB peak + // in flushStray's concat + toString. Paced single-byte writes (each its own + // `data` chunk, like the sealing case) expose the sub-chunk accrual: charged + // at 3 the residual crosses a 3072-byte budget after ~1024 bytes and flushes; + // charged at 1 it would need ~3072 bytes, so the peak residual triples. The + // largest merged buffer is the discriminator. + const realConcat = Buffer.concat.bind(Buffer) + let maxConcat = 0 + Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { + const merged = realConcat(list, total) + if (merged.length > maxConcat) maxConcat = merged.length + return merged + } + let result: CodeRunResult + try { + const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) + result = await runtime.run({ + program: [ + 'import os', + 'for _ in range(6000):', + ' os.write(1, b"\\xff")', + ' os.sched_yield()', + 'return None', + ].join('\n'), + bindings: [], + }) + } finally { + Buffer.concat = realConcat + } + expect(result.error).toBeUndefined() + expect(result.logs.at(-1)).toBe(logTruncationMarker(3072)) + // Charged at 3, the residual flushes around 1024 raw bytes; the largest + // merged buffer stays well under 2048. A raw-byte undercount would let it + // reach ~3072 before flushing, so 2048 discriminates. + expect(maxConcat).toBeLessThan(2048) + }) + it('charges a lone surrogate its full six escaped bytes, not three', async () => { // A forged `log` frame carrying `\ud800` escapes materializes lone // surrogates after JSON.parse. `Buffer.byteLength` of U+FFFD is 3, but @@ -779,10 +843,14 @@ describe('PythonCodeRuntime — programs and bindings', () => { // exhausts maxLogBytes: the first line's admit truncates and marks the // ledger, and the second line's admit — reached in the same `data` callback // — must be the post-truncation no-op. Proves that branch is exercised, so - // it carries no v8-ignore. + // it carries no v8-ignore. Kept to 109 bytes (< the smallest PIPE_BUF, 512 on + // macOS) so the whole payload lands in ONE atomic write and one `data` + // callback — the two newlines cannot split across callbacks and leave the + // branch un-exercised, which would be a hard-to-attribute per-file coverage + // flake. 103 payload bytes still exceed the 64-byte budget, so it truncates. const { runtime } = await setup({ maxLogBytes: 64 }) const result = await runtime.run({ - program: ['import os', 'os.write(1, b"A" * 5000 + b"\\nSECOND\\n")', 'return None'].join('\n'), + program: ['import os', 'os.write(1, b"A" * 100 + b"\\nSECOND\\n")', 'return None'].join('\n'), bindings: [], }) expect(result.error).toBeUndefined() @@ -790,6 +858,29 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(result.logs.join('\n')).not.toContain('SECOND') }) + it('charges a broken multibyte sequence its U+FFFD bytes, split across pipe chunks', async () => { + // A 3-byte lead (0xE4) whose continuation never arrives — the next byte is a + // fresh ASCII 'A' — must be costed as U+FFFD (3) for the orphaned lead, not + // folded into a phantom character. Driven byte-by-byte so the lead and the + // breaking byte land in separate `data` chunks, exercising accrueStrayCost's + // cross-chunk broken-sequence branch. The run completes and the bytes are + // captured (rendered U+FFFD by toString), proving the walk resynchronizes. + const { runtime } = await setup({ maxLogBytes: 1024 }) + const result = await runtime.run({ + program: [ + 'import os', + 'os.write(1, b"\\xe4")', + 'os.sched_yield()', + 'os.write(1, b"A\\n")', + 'return None', + ].join('\n'), + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.logs.join('')).toContain('A') + expect(result.logs.join('')).toContain('�') + }) + it('charges the exact serialized cost of short-escape and quote/backslash characters', async () => { // Exercises every branch of jsonStringCostUpTo's per-character cost: a tab // and other C0 controls with short JSON forms (\t etc., 2 bytes), a quote