DSH / Atlas
2026-07-31implementedbug-fixarchived 2026-09-04

Settlement, framing, and lifecycle fixes in the CPython backend

CPython 后端中的结算、分帧与生命周期修复

The CPython subprocess backend for PTC 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

English

Problem

The CPython subprocess backend for PTC mode, built on the fd-3 frame protocol, 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; eleven 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), 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), the flush_line join-clear-push reorder (it lowers the settlement-flush peak from three copies to two, but the 12x load gate already covers the three-copy newline path, so every gate-admitted config stays within the address space under both orders and no seam-observable difference exists — the memory effect is inside the Python child, unmeasurable through the seam like the shared-budget case), pacing binding replies (its in-tree case only asserts the framed replies still round-trip; the peak it removes lives inside the host's fd-3 writable buffer, unseen through the seam, so the 32.0 MiB → 0.0 MiB reduction is measurable only out-of-tree), dropping a late binding resolution before snapshot (its three assertions all also hold pre-fix, because sendReply already dropped after-settlement values — just later than the snapshot), the done-value TOCTOU pre-encoding (a concurrent mutation racing the encode cannot be deterministically constructed through the seam — its daemon-mutation regression only asserts the result is never a worker-exit, which is probabilistic and non-discriminating, so under the existing no-fail-before-with-a-reason precedent it is registered as no-fail-before), the stray-UTF-8 budget-flush retention (a budget flush landing exactly on a multibyte boundary is not schedulable through the seam; it is cross-referenced as v8-ignored), and the late-rejection settled guard (a rejection arriving after the run has already settled cannot be deterministically constructed from the seam), and the log-fragment seal (a 25 M single-character drip that would OOM is not deterministically constructible in CI; the in-tree case only asserts it completes and truncates), and the unknown-binding preview cap (the whole-target JSON.stringify peak is a transient allocation inside the reply path — its only seam-observable trace is peak memory under a forged near-ceiling global/name, not measurable through the seam; the in-tree case only asserts the run completes).

Decision

Independent corrections, each in the package that owns the defect.

The unknown-binding preview is escaped from a 1 KiB prefix

The unknown-binding reply built its message with JSON.stringify over the WHOLE capped target (global + . + name, each up to maxValueBytes code units) — the escaped form could reach ~6x the input under control-heavy fields, a multi-hundred-MB spike near the maxValueBytes ceiling that no hostile-peer bound would have admitted. The preview is now escaped from a 1 KiB prefix of the target (enough to identify the binding); capMessage still enforces the reply budget.

A merged open-log entry is billed once, split across its fragments

An explicit flush() of an unterminated line emits a log frame with open: true, and the host appends the next frame to the SAME entry (print('a', end='', flush=True); print('b') reads back as one 'ab' entry, not a fake newline). The split-billing arithmetic — first fragment pays quotes+content+separator, continuations and the closing frame pay content only, host caps logBudget - 1/logBudget + 2, the sub-2-byte walk guard, the child's _open_started keying — is stated once, in the fd-3 protocol note's wire-contract section.

Boot-write failure no longer rejects run()

In src/index.ts the fd-3 boot-frame write is the last statement of run()'s synchronous setup. Its catch calls finish(), and finish() reads wallTimer and onAbort and — through settle() — live. Those bindings are const and were declared AFTER the boot-write, so on a synchronous write failure finish() touched them in their temporal dead zone and threw a ReferenceError. That escaped the Promise executor and REJECTED run(), violating the seam's "outcomes resolve" contract: the caller saw a thrown error instead of the worker-exit the catch constructs. The boot-write block is now emitted after wallTimer, onAbort, and live are initialized, and the /* v8 ignore */ that had hidden the branch from coverage is removed so the catch is measured.

Log capture is serialized against settlement

In py/bootstrap.py the settlement flush_out()/flush_err() on the main coroutine read and clear each stream's _pending list and mutate the shared LogBuffer ledger. Model code may start daemon threads whose print/write mutate the same state concurrently. Capturing the bound method (out_stream.flush_line) fixed only WHICH callable settlement invokes, not what it reads mid-flight: an interleaved flush could join a _pending list being mutated under it, corrupting the ledger and costing the done frame — stranding the run to the wall clock. LogBuffer now owns one re-entrant lock shared by both streams; _LogStream.write and flush_line, and LogBuffer.push, take it, so the whole read-modify-write is atomic across threads.

Fd-3 residual is copied, not viewed

Also in src/index.ts, after the newline loop over a Buffer.concat of the pending fd-3 chunks, the leftover partial line was carried forward as the subarray VIEW it was sliced to. A view keeps the entire concat backing allocation alive, so a large frame followed by a tiny trailing fragment pinned a whole frame's worth of memory while pendingBytes — set to the fragment's length — reported far less than was retained. The residual is now detached into a fresh right-sized Buffer via the exported detachResidual helper, letting the concat allocation be collected and keeping pendingBytes an honest measure.

Output-cap load bound is parse-cap minus envelope, not divided by six

The load-time check that rejects a maxLogBytes/maxValueBytes larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges the serialized cost via jsonStringCostUpTo (which walks to the cap without allocating the escaped copy), checkDoneValue measures the escaped form, and the producing-side _cap_message also caps by serialized cost — so a payload admitted under the cap occupies at most cap + envelope on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES (the receive path rejects raw frames past the 64 MiB parse cap before decoding — the run settles as a worker-exit — so a budget must not exceed what an honest child's frame can carry through that parser), and the unused MAX_JSON_ESCAPE_EXPANSION constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps. The same load check also rejects a NON-INTEGER maxLogBytes/maxValueBytes: the child reads each budget through int(...), which floors a float, so maxLogBytes: 3.5 would truncate at 3 bytes child-side while the host meters the fraction — the two sides enforcing different public config. Rejecting the float at load keeps them in step, matching the worker backend.

Same-group survivors are reaped before the fiber goes quiescent

A model program can leave a descendant in the child's OWN process group (no setsid, so kill(-pid) reaches it) that ignores SIGTERM but releases the inherited stdout/stderr/fd-3 pipes. The leader then exits, its close fires because the pipes drained, and settlement runs while that descendant is still alive. kill() arms an unref'd SIGKILL timer after SIGTERM; the fix is that settle() no longer resolves the run's finished promise — nor drops the run from live — immediately when an escalation is in flight. Instead, when killing is set and the process group is not yet empty (process.kill(-pid, 0) does not throw ESRCH), it polls the group on a REF'd timer, bounded by graceMs + CLOSE_REAP_MARGIN_MS, and both drops the run from live and resolves finished only once the group has emptied. The ref'd poll is the load-bearing part: it keeps the host event loop alive until the SIGKILL has actually reaped the group, so even a short-lived host — a one-shot headless run, a config subprocess — cannot exit and reparent the survivor to init. Deferring the live removal is what makes a dispose() racing a just-resolved run() still await the survivor: dropping the run from live at settlement (before the reap) would let teardown snapshot an empty set and return while the descendant lived. In the normal case (the leader was the only member) the first probe returns ESRCH and settlement finalizes with zero added latency. teardown() awaits each run's finished, so disposal is genuinely quiescent, matching its JSDoc — including for a run that already resolved.

Settlement also CANCELS the SIGKILL timer the moment the group is confirmed empty (the normal path, and when the poll sees the survivor gone). Leaving it armed would expose a PID-reuse hazard: a kill(-pid) left pending for up to graceMs after the leader was reaped could hit a RECYCLED pgid once the kernel reused the leader's pid, SIGKILLing an unrelated group (killGroup swallowing ESRCH does not help — the danger is precisely the kill that SUCCEEDS against a reused group). Clearing it on the empty probe bounds the reuse window to only the genuine-survivor case, where the group cannot be empty to reuse.

The window the cleared timer cannot cover is closed by an IDENTITY check inside killGroup. Every signal it sends is a raw process.kill(-child.pid, sig), which — unlike child.kill() — has no handle guard, so it would reach a recycled pgid during the interval between the leader being reaped and close firing (measured at 3039 ms with a pipe-holding descendant). The leader's start time is therefore read once at spawn (/proc/<pid>/stat field 22) and re-read before each signal, with two rulings: a reading that is PRESENT AND DIFFERENT means the number now belongs to another process, so the signal is withheld; an ABSENT reading means the leader was already reaped, which is the ordinary case for every escalation — its /proc entry is gone while the group it led can still hold the survivor this teardown exists to reap — so the signal proceeds. Absent is also the constant reading on a platform with no /proc, where the guard is inert and the pre-existing behavior stands. Reading absent as a mismatch is not hypothetical: the first version did, which withheld the grace SIGKILL and the poll deadline's SIGKILL, and the three same-group heartbeat cases went red on the Linux coverage lane while passing on Darwin, where the reader always returns undefined.

The reap poll also handles a host event loop BLOCKED past both timers. If a synchronous computation holds the loop from before the poll was scheduled until after its deadline, both the poll timer and the grace-window SIGKILL timer are overdue when the loop resumes, and Node runs the earlier-scheduled poll first — so the grace SIGKILL may never have fired. The deadline branch therefore sends SIGKILL ITSELF (idempotent if the timer already ran) rather than cancelling the unfired escalation, then grants ONE more CLOSE_REAP_MARGIN_MS and keeps polling until the group is confirmed empty, because finalizing on mere signal delivery would declare quiescence while the group is still dying. The outer bound on the wait is therefore graceMs + 2 * CLOSE_REAP_MARGIN_MS. A final hard bound finalizes if that extra margin elapses with the group still non-empty; that branch carries a /* v8 ignore */ because it is reachable only where a SIGKILL'd survivor lingers as a zombie and is never wait()'d — a container whose PID 1 does not reap orphans — which cannot be built deterministically across CI platforms. The ignore's reason states that environment dependence rather than claiming the branch cannot run, cross-referencing the Alternatives entry that rejected the signal-0 reap assertion for the same reason.

RLIMIT clamps against the inherited soft limit, not only the hard

In py/bootstrap.py _clamped bounded a requested (soft, hard) rlimit pair by the inherited HARD limit alone. A deployment that inherited a soft limit below the requested one — say inherited (100, 200), requested (150, 160) — got back (150, 160), RAISING the effective soft from 100 to 150: for RLIMIT_AS that loosens the memory ceiling, for RLIMIT_CPU it defers SIGXCPU, both violating "strictest of configured and inherited". _clamped now clamps each side against its own inherited counterpart (RLIM_INFINITY imposing no ceiling), then pins soft under hard so setrlimit never sees an inverted pair. The settlement-time CPU recheck (die_if_cpu_exhausted) follows the same rule: it compares spent CPU against the EFFECTIVE clamped cpu_soft, not the configured cpuSeconds, so a program that traps SIGXCPU and burns past a stricter inherited soft before returning is reported as a timeout rather than a false success. The recheck restores SIG_DFL BEFORE unblocking a program-masked SIGXCPU (pthread_sigmask(SIG_UNBLOCK, ...), captured at import): a program that installed a custom handler AND masked the signal would otherwise have that pending handler run at the unblock — in model code, able to re-mask or raise — so the disposition must already be SIG_DFL when the signal is released; with SIG_DFL first the pending signal kills inside the kernel with no bytecode window, and the kill re-raise is the fallback for the never-pending case. The SIGXCPU diagnostic no longer names the configured cpuSeconds as the effective budget — under a stricter inherited soft that number is wrong — and instead reports that CPU time was exhausted at "at most the configured N seconds", which holds whichever limit fired.

Concurrent binding replies are paced against fd 3

sendReply ignored proto.write's false return, so a program resolving several large values in one asyncio.gather round encoded every reply in the same turn and queued all of them in fd 3's writable buffer. Binding resolution carries no seam-level byte cap to bound that, and the failure kills the HOST process rather than failing the run: measured on a 64 KiB-highWaterMark pipe, eight 4 MiB replies buffered 32.0 MiB at once. Replies now go through a queue that encodes and writes one frame at a time, awaiting drain when the pipe is full, which measured a 0.0 MiB peak for the same shape. The encode happens inside the loop so a queued reply the run no longer needs is dropped by the settled check without ever being serialized. The same settled predicate also guards the reply callback AFTER await fn(...) but BEFORE snapshotJsonValue, so a wide value that resolves after settlement is dropped before its width is walked — the host does not expand a late value for a run whose outcome is already fixed.

Pacing changes nothing the model can observe. The child matches each reply to its call by id from a pump that reads fd 3 continuously, so arrival order was never observable, and the bindings themselves still run concurrently -- only the host's peak memory and the flush timing change. That is also why serializing is not a narrowing of the seam's concurrency contract, which was the reason this was first deferred; that reasoning was wrong.

Binding replies complete on the calling loop's thread

Also in py/bootstrap.py, a binding reply Future is created on the loop that ran dispatch. When the model calls a binding from a worker THREAD via asyncio.run(tools.x(...)), that Future belongs to the thread's loop, not the main loop where _pump_replies reads the reply. asyncio.Future is not thread-safe: completing it from another thread does not wake its own loop, so the direct set_result/set_exception left the awaiting thread stranded and the run degraded to a wall-clock timeout. Each pending entry now records its Future's loop alongside the Future, and _pump_replies completes it via that loop's call_soon_threadsafe. The shared pending/next_id state is guarded by a threading.Lock held across the id claim, the fd-3 write, and the counter advance, so concurrent callers cannot interleave frames out of the id order the host requires. call_soon_threadsafe onto a loop that has already CLOSED (the worker thread finished and abandoned its call before the reply arrived) raises RuntimeError; that schedule is wrapped so the moot reply is dropped rather than letting the exception end the pump task and strand every later reply.

The blocking frame reader reads in chunks, not byte by byte

ProtocolChannel.read_frame — used for the boot and run handshake frames — read through FileIO.readline() on the unbuffered (buffering=0) fd, which issues one os.read(1) per byte. The run frame arrives AFTER RLIMIT_CPU is in force, so a legitimate multi-megabyte program burned seconds of CPU in millions of single-byte syscalls before ast.parse ran — potentially exhausting the budget on the read alone. It now reads in _READ_CHUNK_BYTES chunks into the same _pending residual buffer the async reader already uses (the wrapping os.fdopen object is gone; both readers call os.read(self._fd, ...) directly), so the read cost is trivial and read-ahead past a newline is preserved for the next frame. Both readers track a running scan offset (find(b"\n", scanned)) so a large frame accumulated across many chunks is scanned once, not re-scanned from index 0 per chunk — a chunked rescan would have replaced the byte-at-a-time cost with an O(N²) memchr cost on the same large-frame path.

Synchronous spawn failure resolves worker-exit, not reject

Also in src/index.ts, spawn is called before the settlement Promise executor exists. Node defers only a fixed set of spawn errnos (EACCES, EAGAIN, EMFILE, ENFILE, ENOENT) to an asynchronous error event, which the settlement path already turns into a worker-exit; every other errno throws SYNCHRONOUSLY from spawn. A pythonBin longer than the platform PATH_MAX passes the load-time validation (non-empty, no NUL) but makes spawn throw ENAMETOOLONG here — outside the executor — so run() REJECTED instead of resolving, violating resolve-don't-reject, and left this run's just-materialized staging directory on disk since only settle() removes it. The spawn call and the fd-3 narrowing are now wrapped: a synchronous throw removes the staging directory and resolves the same worker-exit class (python spawn error: …) the async error event produces.

Interpreter selection and the child environment settle at load

pythonBin resolves once at plugin load to an executable absolute path and is version-probed under the same scrubbed environment used for runs. The provider requires CPython 3.10 or newer and retains that exact path, so a later PATH or working-directory change cannot switch interpreters; an explicit path that is not an executable regular file, an unresolved basename, or an unsupported interpreter fails before ctx.codeRuntime registers. The synchronous probe has a fixed five-second deadline and sends SIGKILL at that deadline, so a wrapper that ignores SIGTERM cannot block plugin load. Each probe and run receives only TMPDIR: macOS system Python needs it to avoid emitting a startup warning into captured stderr, while credentials, PATH, HOME, and every other ambient host value remain unavailable to model code. If the validated executable disappears after activation, the ordinary spawn settlement still resolves worker-exit.

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 (PTC 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.writes 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.

An incompatible output-budget/addressSpaceMb pair is rejected at load

The child (py/bootstrap.py) builds, charges, and frames a maxLogBytes log entry or a maxValueBytes completion value under RLIMIT_AS, and both ledgers trigger on CHARACTER count against a serialized-BYTE budget. An astral character is one character but four bytes of CPython str storage and four UTF-8 bytes, and the heaviest path holds THREE such copies at once: a single sys.stdout.write(line + "\n") keeps the caller's text argument (alive for the whole write call, ~4×), the line slice handed to LogBuffer.push (~4×), and the text.encode("utf-8") copy _push_locked takes to charge and ship it (~4×) — a peak of ~12× the budget. The settlement flush_line path holds only two (its "".join(...) and that encode copy — it drops the pending chunks before pushing), so the newline path is the binding worst case. When a budget approaches addressSpaceMb, a LEGITIMATE near-budget output breaches the address space during that build-and-encode and dies as worker-exit instead of truncating (log) or failing as output-limit (value). Metering every child write against the address space at runtime is the wrong fix: an exact serialized-cost check on the hot path is either a full encode (the allocation being avoided) or a per-character Python loop (which burns the CPU budget — a 10 MB legitimate write hits SIGXCPU under cpuSeconds: 1). Both trade one resource bound for another. Instead src/index.ts rejects the incompatible pair at LOAD: each budget times OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (twelve — the three simultaneous ~4× copies of the newline path) must fit the address space LEFT after a fixed INTERPRETER_BASELINE_BYTES reservation for the interpreter's own footprint, with a >= so a budget whose worst-case peak exactly equals that room is rejected (that peak plus the reserved baseline is the whole address space, the RLIMIT_AS edge). flush_line was also made to drop the pending chunks BEFORE its push, matching the newline path's join-clear-push order, so it holds at most the join and its encode rather than three copies. The baseline is reserved SEPARATELY from the multiple because it is a fixed cost, not one that scales with the budget: folding it into the multiple would leave a budget sized right at addressSpaceMb / 12 admitted while its peak plus the interpreter still overran. Both maxLogBytes and maxValueBytes are gated symmetrically; the value path's build-and-encode is the same shape. The check runs on every platform, not just where RLIMIT_AS is enforced: the incompatibility is a property of the config values, so a Linux deployment OOMs regardless of the host that assembled the config, and a uniform load-time rejection is the fail-loud contract (Darwin skips only the runtime setrlimit). This eliminates the class at the config seam rather than patching the write path, so _LogStream keeps its original character-count buffering (a valid lower bound on serialized cost, memory-safe once the budget fits the address space). The value path enforces the same discipline in a second place: _check_done_value (the byte meter) and _encode_json_plain (the frame encoder) walk in O(DEPTH), not O(width). Each container pushes ONE cursor frame that pulls its children one at a time rather than one traversal tuple or stack entry per child — a flat [0] * 6_000_000 serializes to ~12 MB but a per-element walk allocates ~400 MB of bookkeeping (~28× the serialized size, far past the 12× the gate reserves), so a value the meter admits could OOM on the walk's own frames. With the cursor, the only width-proportional allocation is the output string the meter already bounded.

The host gate validates against the CONFIGURED addressSpaceMb, but a launch environment can inherit a STRICTER RLIMIT_AS (a ulimit -v wrapper below addressSpaceMb), which the bootstrap's _clamped correctly lowers the EFFECTIVE limit to — leaving the budgets sized for a ceiling the child never gets. So bootstrap.py re-checks both budgets against the effective clamped soft limit after applying it, mirroring the host gate's multiple and baseline, and raises at boot (caught by the setrlimit-phase handler and reported as exception, the same class as any other resource-limit-application failure) rather than letting a near-budget output OOM mid-run. The two child constants are kept in step with the host's by the shared reasoning, not a wire field.

One residual write-path copy is fixed alongside, independent of the config gate: _LogStream.write's newline branch buffered the whole unterminated tail after the last newline (text[pos:]) into _pending before the flush trigger could bound it, so an early newline followed by a huge tail ("\n" + "A" * 30 MiB) made a second full copy of the model's own string — the RLIMIT_AS death the path exists to avoid, and one the config gate does not cover because the tail can far exceed maxLogBytes. The tail is now sliced to a remaining + 4-character prefix (anything past remaining characters cannot be admitted, the char count being a lower bound on the serialized cost), which the flush trigger then rejects with the marker.

The completion value and error are pre-encoded at their validation point

In py/bootstrap.py, _done_with_value now returns the whole terminal frame as a PRE-ENCODED JSON string on the success path: the admitted value is serialized once here, at the validation point inside _run's try, as '{"type": "done", "value": ' + _encode_json_plain(value) + "}". The program can keep mutating a returned list/dict from a daemon thread or signal handler after it returns, so a second traversal held at a later point would be a TOCTOU — a concurrent mutation into a non-JSON type would let that later encode throw outside the settlement handler and downgrade a settled run host-side to worker-exit. Serializing once, inside the try that wraps this call, closes the window: if a concurrent mutation makes the encode throw, the exception handler classifies it as an exception, and once the string is produced the frame is written verbatim with no further touching of the live value. _run binds the _done_with_value ENTRY NAME into a local (done_with_value_bound) before the program runs, and _done_with_value itself binds _check_done_value and _encode_json_plain as DEF-TIME default arguments — so a __main__ rebind of the entry name or those two names after model execution cannot rewrite a legitimate success into an exception. The log ledgers (host logBudget and child _remaining) start ONE byte below the budget, reserving the serialized outer-array envelope (two brackets and n-1 commas over n entries' separators), so a result that exactly exhausts the ledger still serializes within the configured cap; the truncation marker remains envelope, not payload. Once a ledger has truncated, the host clears both stray pipes' buffered output wholesale (every later byte would be no-op'd by admit, so retaining it would spend host memory on output that can never be admitted); the child runs -u so sys.__stdout__/sys.__stderr__ writes are visible to stray capture immediately, and the settlement flush still drains the original std streams before the done frame (a guard against a buffered wrapper surviving a sys.__stdout__ = boom rebind). The constructor rejects a maxLogBytes below 64 (the smallest budget with one byte of room for the truncation marker's own serialized form); maxValueBytes keeps only the positive-integer requirement, since a completion can be a single byte and the done-frame envelope is seam protocol cost. The marker remains envelope, so a truncated run with admitted entries serializes to at most maxLogBytes + marker + envelope (recorded in the package README). A rebind of a transitive dep the encoder reaches (e.g. _dump_scalar/_dump_string/json/io — a non-exhaustive set) can still make the encode throw and downgrade a success to an exception, which is registered as an accepted residual in the package README.

send_done (a local function inside _run) writes the pre-encoded string through a BOUND channel.write_encoded, and encodes a dict error frame through a bound _encode_json_plain before writing it — it never calls channel.send_sync, whose body re-resolves self.write_encoded and the module-level _encode_json_plain at call time. _encode_json_plain and channel.write_encoded are bound into locals before the program runs, for the same reason flush_out/flush_err/safe_model_traceback are: the program runs as __main__, so import __main__; __main__.ProtocolChannel.send_sync = boom or __main__._encode_json_plain = boom would otherwise re-resolve the send/encode to a rebranded callable at call time and, when that replacement raises, skip the done frame and downgrade a settled verdict to a host-side worker-exit.

A budget flush retains an unfinished trailing multibyte sequence

Also in src/index.ts, flushStray(stray, retainPartialTail) withholds an unfinished multibyte tail from the decode on the BUDGET-triggered flush (the combined-cost threshold in captureStray): when the residual ends on a partial UTF-8 lead sequence (stray.utf8.expected > 0), the leading byte plus the continuations consumed so far (≤3 bytes) are detached from the frame as the new residual, and only the complete prefix is admitted and decoded. Nothing is admitted when the whole residual is a single unfinished sequence, so a legal, un-finished character is never rendered as U+FFFD in a released, un-truncated entry, and no bogus empty entry is pushed. The withheld tail is re-accrued from a FRESH stray.utf8 state — metering it against the post-flush expected > 0 state would charge the carried lead byte as an illegal continuation — so the next chunk continues the walk correctly and the pipe's cost/UTF-8 state is rebuilt over the retained tail. The end/closeDeadline paths pass false and decode the FULL residual unchanged, because there a trailing incomplete sequence is real truncated input and the U+FFFD is the honest render.

A late binding rejection returns before formatting the error

Also in src/index.ts, the binding-rejection catch branch now checks settled and returns BEFORE formatting messageOf(error). A rejection that arrives after maxWallMs, an abort, or dispose has already settled the run would otherwise have messageOf(error) run hostile toString/message getters — spending host heap and time on a run whose outcome is already fixed — before sendReply peeks at settled. Dropping the framed reply early spares that waste. The running loop's otherwise-mostly-linear reply drain also reads by a head cursor into the queue array instead of shift()ing each entry, so a large asyncio.gather of wide bindings awaiting fd 3's drain drains in linear time rather than O(n²) from repeated re-slicing.

A newline-free drip seals its fragments; the CPU soft limit is kept below the hard; the done frame falls back to a fixed literal; the reply queue clears consumed slots

In py/bootstrap.py, _LogStream now seals the pending-fragment list past a cap: a newline-free drip of one character per write would otherwise accumulate one list slot (and one str object) per call, and under a large maxLogBytes a 25 M single-character flood OOMs on its own accounting (plus the same-size list _push_bounded_prefix then builds) before the byte budget is reached. Past _PENDING_MAX_CHUNKS the current fragments are joined into ONE block moved to a _pending_blocks list (the character count is unchanged), bounding the live fragment count exactly as the host-side captureStray seal does; the join is only the ≤cap current fragments, never the whole accumulated buffer, so a large drip stays O(B) rather than re-copying the growing block O(B²/cap) times. The HOST-side open hold mirrors the same seal: a budget-sized single-character open flood (print('x', end='', flush=True) in a loop is an honest-child path) would otherwise accumulate one fragment array slot plus string object header per frame — ~30× overhead the byte cap cannot see, up to ~2 GB of host auxiliary heap near the maxLogBytes load ceiling. Past MAX_PENDING_CHUNKS the held fragments coalesce into openSealed; the closing-frame merge, truncateLogs, and the finish residual all read sealed + current fragments and clear the seal.

_clamped also lowers a clamped RLIMIT_CPU soft limit that EQUALS the hard by one unit (when the hard is at least 2). A ulimit -t N sets both, and with soft == hard the kernel checks the hard limit in the same tick and SIGKILLs a busy loop directly, so SIGXCPU is never delivered — and the host classifies a CPU overrun ONLY on signal === 'SIGXCPU', so a definite budget exhaustion would be misreported as a worker-exit. Lowering the soft one unit gives SIGXCPU a window to fire, so the overrun is reported as a timeout. This is scoped to RLIMIT_CPU (a one-byte soft differential on RLIMIT_AS would only misalign the child's applied limit with the host budget gate, with no signal to preserve). The hard >= 2 guard leaves a hard == 1 blind spot — a 1-second dual limit cannot lower the soft to 0, so a definite overrun there is still reported as worker-exit.

send_done wraps its encode+write in a try and, on any throw from a rebound transitive name (_dump_scalar/os), writes a fixed pre-encoded done frame via the _run-local bound _os_write/_memoryview/_FALLBACK_DONE_FRAME — so a settled exception verdict is never downgraded to a worker-exit, and the host still gets a verdict. The reply queue's head-cursor drain clears each consumed slot so a wide written payload is released immediately, bounding host memory to the current backlog under sustained fd-3 backpressure. The exception classes the settlement-path except clauses catch are likewise bound before any model code runs: _BaseException is a _run LOCAL and a closure cell in _make_failure_reporter; _RuntimeError, _BindingRejection, str, and bool are DEF-TIME default arguments of _pump_replies (a body-local X = X binding is too late — the model's top-level statements run before the pump's first step). A rebind of __main__.BaseException cannot make a program exception escape the handler and lose the done frame; a rebind of __main__.RuntimeError (or _BindingRejection/str/bool) cannot make a closed-loop scheduling failure escape the pump catch and strand every later reply to the wall clock.

Testing

  • tests/runtime.spec.ts rejects absent, non-executable, non-CPython, pre-3.10, and unresponsive interpreter configurations at load; changes PATH after activation to prove the resolved executable is frozen; removes that executable after activation to preserve the late worker-exit path; and asserts a running program sees TMPDIR but not PATH, HOME, or DEEPSEEK_API_KEY. The native-output case pins each source stream's order without requiring a total order across independent channels, and the Darwin resource-limit cases state or skip the platform-specific RLIMIT_AS behavior.
  • snapshots/session/ptc-python-turn replaces the headless PTC worker provider with the private Python provider through the real Loader, replays a Python run_code program over real bash bindings, and pins the Python SDK prompt, tool schema, dispatch events, captured log, and completion value.
  • 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 parse-cap - envelope bound (67108800) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via os.write under a raised maxLogBytes and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes b"one\ntwo\nthree" and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB maxLogBytes and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte \xff writes under a 3072-byte budget with Buffer.concat wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal ED A0 80 one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured A and a U+FFFD (exercising accrueStrayCost's cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one data callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a \uXXXX control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of jsonStringCostUpTo); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising accrueStrayCost's per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 log frame flooding 1000 \ud800 escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free os.write(1, …) calls under a raised budget with Buffer.concat wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past MAX_PENDING_CHUNKS). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a setsid orphan holding the pipes open, and asserts the diagnostic survives in logs (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts dispose() of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in live until its group is reaped), with an expect(afterDispose).toBeGreaterThan(0) guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own asyncio.run loop while the main coroutine yields with await asyncio.sleep, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop call_soon_threadsafe (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a ulimit -S -t wrapper that sets a CPU soft limit below cpuSeconds and asserts the applied RLIMIT_CPU soft is the inherited value, not the configured one (CPU rather than address space, since macOS ignores ulimit -v); a companion inherits a 1 s CPU soft, has the program trap SIGXCPU and busy-loop past it, and asserts the settlement recheck reports a timeout — proving the recheck uses the effective soft, not the configured cpuSeconds. A control-heavy-diagnostic case raises a NUL-flood exception under a small maxValueBytes and asserts the serialized frame fits (proving the diagnostic is metered by serialized cost). A tail-copy case (maxLogBytes: 256, addressSpaceMb: 384) has the program build a tail in a variable and write "\n" + tail where tail is 150 MiB — construction peaks at ~2× (~300 MiB, within the address space, so the model's own allocation succeeds and any OOM belongs to the defect path), and the pre-fix whole-tail re-buffer added a third ~150 MiB copy past 384 MiB; the sliced prefix lets the run truncate and complete (Linux-only RLIMIT_AS repro, macOS happy path — the fixture's own construction must fit the address space, a general rule for these RLIMIT_AS cases). An output-budget/address-space case asserts a maxLogBytes of 50 MB AND a maxValueBytes of 50 MB each reject at load against a 256 MiB addressSpaceMb (past the room left after the interpreter baseline when multiplied by the worst-case 12) while the default caps against 512 MiB load, gating both budgets symmetrically; a discriminating case asserts a 48 MiB maxLogBytes against a 512 MiB addressSpaceMb rejects — 48×8 = 384 MiB fits the 448 MiB budgetable (the old 8× multiple wrongly admitted it) but 48×12 = 576 MiB does not. The ~12× peak the multiple covers is the NEWLINE path's single near-budget write — the caller's own string, the line slice, and the encode copy live at once; the settlement flush is no longer the binding case, because flush_line drops the pending chunks before its push and so holds two copies rather than three. An inherited-RLIMIT_AS case runs the interpreter through a ulimit -v 131072 wrapper with a 32 MiB maxLogBytes the configured 512 MiB addressSpaceMb admits, and asserts the boot re-check rejects it as an exception whose message names the inherited RLIMIT_AS (the 128 MiB inherited limit leaves too little after the baseline; Linux-only, macOS ignores ulimit -v and the run proceeds). A non-integer-budget case asserts a fractional maxLogBytes/maxValueBytes rejects at load. A combined-peak case (maxLogBytes: 32 MiB, maxValueBytes: 32 MiB, addressSpaceMb: 512 — each budget admitted alone at 12×) writes ~33M newline-free astral characters (buffered, unflushed) then returns ~33M astral characters, and asserts the run settles as output-limit (the value is itself over its 32 MiB budget); pre-fix the unflushed log pending plus the value's build-and-encode peak added past the 512 MiB address space and OOM'd, so flushing the logs before framing the value is what lets the value check complete (Linux-only RLIMIT_AS repro; on macOS the over-budget value reports output-limit under both orders). A wide-value case (maxValueBytes: 20 MiB, addressSpaceMb: 384) returns [0] * 6_000_000 — ~12 MB of JSON, under the 20 MiB budget, so it must round-trip; pre-fix the O(width) walk allocated ~400 MB of per-element traversal tuples and encoder stack entries (~28× the serialized size, past the 12× the gate reserves) and OOM'd on a value the meter admitted, while the O(depth) cursor keeps the only width-proportional allocation the output string itself (Linux-only RLIMIT_AS repro; the fixture stays within the address space so it is honest on macOS too). A wide-BINDING-ARGUMENT case (addressSpaceMb: 384) calls a binding with [0] * 6_000_000 and asserts the length echoes back: _lossless_json_violation runs on model-built arguments that no child-side budget bounds first, and its per-member tuples measured 459.1 MiB against 0.0 MiB for the cursor. A backtracking case returns a 4 MiB string from a binding and asserts it round-trips: the old scalar regex retained engine state proportional to the string's width (146 MiB at 1 MiB, 557.8 MiB at 4 MiB, past the default 512 MiB), which raised MemoryError inside _pump_replies and stranded the call to the wall clock. A control-heavy metering case returns 8M NULs under a 16 MiB maxValueBytes and asserts output-limit, not exception: charging by counting instead of materializing the escaped form measured 19.1 MiB against 228.9 MiB for an identical byte count. An addressSpaceMb-baseline case asserts 64 MiB and 32 MiB reject at load with a message naming addressSpaceMb, rather than the budget loop's negative admissible limit. A process-identity case asserts the leader's start time reads stably on Linux and reports undefined on Darwin, the guard that keeps a recycled pgid from receiving this run's SIGTERM. A paced-replies case resolves eight 4 MiB values in one asyncio.gather round and asserts the frames round-trip; the peak the fix removes (32.0 MiB buffered → 0.0 MiB) lives in the host's fd-3 writable buffer, invisible through the seam, so this case pins the round-trip and the no-regression match but is measured for its peak only out-of-tree. A late-drop case settles the run on maxWallMs and resolves the pending binding afterwards, asserting a timeout result, an undefined value, and that the late path actually ran — the three assertions all hold pre-fix because sendReply already dropped after-settlement values, just later, so the case pins the ordering, not a seam-observable behavior. A binding-all-names case (rebinds every name the failure path uses) asserts a real ValueError survives send-done binding — a tested fix pinned by a case that rebinds __main__.ProtocolChannel.send_sync, __main__.ProtocolChannel.write_encoded, and __main__._encode_json_plain — the three names the shipped send_done would resolve late if it looked them up at call time — and pins the done frame against that call-time look-up skipping it; the done-value TOCTOU pre-encoding, the stray-UTF-8 budget-flush retention, and the late-rejection settled guard are counted among the ten no-fail-before fixes (reasons in the Problem section), not pinned by a fail-before test. A fragment-cap drip case writes 200 000 single-character newline-free sys.stdout.write calls and asserts the run completes with a truncation marker rather than a MemoryError (no-fail-before: the 25 M-scale OOM is not deterministically constructible in CI). A dual-limit CPU case runs the interpreter through a ulimit -t 2 wrapper and busy-loops past it, asserting a timeout (the soft limit is lowered to 1 so SIGXCPU fires, not a worker-exit). A transitive-name rebind case rebinds __main__._dump_scalar, __main__.os, __main__._os_write, __main__._memoryview, and __main__._FALLBACK_DONE_FRAME and asserts a done frame still lands as an exception, not a worker-exit (the real message is replaced by the fixed fallback literal). A BaseException-rebind case rebinds __main__.BaseException to RuntimeError and raises ValueError, asserting the run still reports an exception, not a worker-exit (the catch uses a pre-program local exception class). A RuntimeError-rebind closed-loop case rebinds __main__.RuntimeError to ValueError as the first program statement and drives the closed-loop worker pattern, asserting the pump survives the dead-loop reply and delivers the later binding (the pump's _RuntimeError is a def-time default argument, so it captures the original before the rebind). A _done_with_value-rebind case rebinds __main__._done_with_value to a raising function and returns a legitimate value, asserting the run still reports the success (the entry name is a _run local bound before the program runs). A sys.__stdout__-flush case writes through sys.__stdout__/sys.__stderr__ without an explicit flush and asserts both bytes appear in logs (the -u unbuffered child plus the settlement drain of the original std streams). The host closes the child's stdin write handle immediately after spawn (the program is an async body that reads nothing from fd 0; a live pipe would hold a host-side handle open past the run, letting a setsid-escaped descendant inheriting fd 0 keep the host process alive). The channel's frame readers bind their decode primitives (_decode_json_plain, os.read, _READ_CHUNK_BYTES, bytes, and len; asyncio.get_event_loop on the async reader) as def-time default arguments, so a __main__ rebind cannot kill the reply pump; _decode_json_plain itself captures json.loads/the two regexes/len/isinstance/str/list the same way. The reply pump's frame reader is a BOUND METHOD captured by _run before the program runs and passed into _pump_replies as an explicit argument (a body-local channel.read_frame_async lookup would resolve a rebound class attribute, since the pump starts after the program's top-level statements). send_done's frame-shape check uses _run's bound _str/_isinstance (a program rebinding __main__.isinstance cannot make a legitimate success fall into the fixed-literal fallback). _make_error_class captures Exception and setattr as def-time defaults, and dispatch binds _lossless_json_violation/asyncio.get_event_loop/the channel's send and write primitives into _run locals (the frame WRITE goes through def-time bound write_encoded+_encode_json_plain rather than send_sync's call-time body, and the log sink directly through the bound encode+write primitives, not send_sync) — a rebind of those names before the first binding call cannot break a legitimate call. compile(wrapped, ..., dont_inherit=True) stops the module's from __future__ import annotations from stringifying the program's type annotations. A basename pythonBin that does not resolve on the CURRENT process PATH now fails at LOAD ('does not resolve on PATH', like the empty/NUL checks): the child spawns with env: {}, so falling back to the bare name would let execvp silently start a system interpreter from the platform default PATH — a product-visible change from the old run-time ENOENT worker-exit to an early, loud configuration error. The bootstrap resets SIGXCPU to SIG_DFL and unblocks it before any model code runs: the child inherits the host's disposition and mask, and a host that ignores or blocks SIGXCPU would let a program run past the soft RLIMIT_CPU until the hard limit's SIGKILL — classifying a definite overrun as worker-exit instead of timeout. (The settle-time enforcer already restores SIG_DFL for a program that traps or masks the signal mid-run; this closes the inherited-state gap.) The float encoder's Decimal(repr(value)).normalize() runs on a fixed module-level _FLOAT_CONTEXT = Context(prec=28) (constructed before any model code): the process-global decimal context would otherwise let a legitimate program's getcontext().prec = 2 silently round the completion value's digits or traps[Inexact] = True make the encode raise, misclassifying a successful run as an exception. A regression case mutates both knobs and asserts a float completion round-trips exactly. The host caps an fd-3 frame's RAW length at 64 MiB (FRAME_PARSE_CAP_BYTES) before toString/JSON.parse: the 256 MiB wire ceiling bounds the bytes, not the decoded structure, and a compact wide frame near it could decode to far more host memory. maxLogBytes/maxValueBytes are load-bounded to that parser cap so an honest child's frames always fit; a model-constructed binding argument above it is dropped (registered in the README). A frame-cap case writes 65 MiB of As plus a newline and asserts a worker-exit with the protocol-frame-exceeded message (the pre-join counter rejects a newline-free single frame; the first-frame check rejects a newline-bearing one BEFORE the join, keeping the peak at one copy of the wire bytes — verified fail-before by reverting to the unconditional counter). A _decode_json_plain-rebind case asserts a binding reply still round-trips; a stdin-EOF case has the program read fd 0 and asserts it sees EOF immediately (the destroyed write handle), verified fail-before by disabling the destroy. A frame-cap case writes 65 MiB of As plus a newline and asserts a worker-exit with the protocol-frame-exceeded message (the pre-join counter rejects a newline-free single frame; the first-frame check rejects a newline-bearing one BEFORE the join, keeping the peak at one copy of the wire bytes — verified fail-before by reverting to the unconditional counter). A multi-frame case lets two within-cap frames whose combined buffer crosses the cap both survive (the first-frame check, not the byte counter, decides), and a sealing-threshold case writes 64 MiB of 4 KiB atomic newline-free writes plus 12289 more bytes before the first newline, asserting the run is a worker-exit (sealing is the ELSE half of the newline branch, so the newline-bearing chunk always reaches the first-frame check). A pythonBin case resolves a basename against a PATH whose first entry is relative ('.') and asserts the absolute entry is used. An exact-limit case (maxLogBytes: 64) writes a 60-character line (62-byte JSON + 1 separator = 63 = the reserved ledger) and a 61-character line (64 > 63), asserting the first is admitted and the second truncates to the marker — the outer-array envelope reservation, pinned at the exact boundary; a companion case asserts maxLogBytes: 61 rejects at construction. A syntax-label case asserts a parse-time syntax error carries File "<model>" (ast.parse passes the same source label as compile and the runtime traceback filter). A SIGXCPU-mask case masks SIGXCPU (pthread_sigmask), burns past the soft limit, and returns, asserting a timeout (the recheck unblocks before re-raising); a trap+mask companion installs a custom handler that re-masks and asserts the same timeout (SIG_DFL is restored before the unblock, so the pending signal kills inside the kernel).

Alternatives considered

Leave the boot-write /* v8 ignore */ and fix only the ordering. Rejected: the ignore is what let the TDZ regression ship uncaught. Removing it makes the catch a measured branch, so per-file 100% coverage now proves the failure path is exercised.

Fix the flush race by capturing more bound methods. Rejected: this is the approach that already failed. Binding a callable fixes reference resolution, not concurrent access to the mutable state the callable reads. Only mutual exclusion over the shared ledger closes the race.

Guard the residual with a size threshold (copy only large frames). Rejected: the branch runs once per newline-bearing read, the copy is bounded by the residual's own length (always a partial line), and a threshold adds a tunable and a second code path for no measurable saving. An unconditional right-sized copy is simpler and always correct.

Assert the residual memory effect through the seam. Rejected: the retained allocation is not observable through CodeRunResult, so a black-box test could not distinguish fixed from unfixed. Extracting detachResidual makes the backing-store invariant a deterministic unit test instead.

Reap the same-group survivor with a fire-and-forget unref'd SIGKILL timer alone. Rejected: an unref'd timer does not keep the host alive, so a host that exits within the grace window (a one-shot run, a config subprocess) never fires the SIGKILL and the survivor is reparented to init — the same "no subprocess outlives the fiber" violation in a different shape, and teardown's "await each child's exit" JSDoc would be false. Awaiting the group's death on a ref'd poll keeps the host alive exactly long enough to reap, at zero cost in the common empty-group case.

Assert the reap with process.kill(pid, 0) throwing ESRCH. Rejected: a SIGKILL'd process lingers as a zombie until its parent wait()s it, and in a container whose PID 1 does not reap orphans the signal-0 probe keeps succeeding, so the assertion would false-fail cross-environment. A heartbeat file that stops advancing detects "no longer executing," which a reaped process and a zombie both satisfy.

Complete the cross-loop Future with a plain set_result and rely on the GIL. Rejected: the GIL serializes bytecode but does not make asyncio.Future cross-loop-safe — completing a Future from a thread other than its loop's does not schedule its callbacks or wake the loop. call_soon_threadsafe on the owning loop is the documented mechanism.

Leave the SIGKILL timer armed after settlement (the earlier same-group fix). Rejected: an unref'd timer left to fire up to graceMs after the leader was reaped can kill(-pid) a RECYCLED pgid, striking an unrelated group; the danger is the kill that succeeds, which killGroup's ESRCH swallow cannot prevent. Clearing the timer once the group is confirmed empty bounds the reuse window to the genuine-survivor case, where the group is not empty to reuse.

Clamp rlimits by the inherited hard limit only. Rejected: that silently RAISES an inherited soft limit stricter than the request, loosening the very containment the clamp exists to preserve. Clamping each side against its own inherited bound (then pinning soft under hard) keeps the strictest of configured and inherited on both.

Bill the host-side capMessage backstop by serialized cost, matching the child's _cap_message. Rejected: the two caps guard different things. _cap_message's output re-crosses fd 3 as a JSON string, so its escaped width is what the frame ceiling bounds — serialized billing is required there. capMessage's output goes straight into CodeRunResult.error.message and never re-crosses a frame-bounded channel, so the honest measure of what it retains is the raw byte length of the model-visible string. An honest child has already capped by serialized cost and raw length ≤ serialized cost, so a well-formed message passes unchanged; a forged control-heavy message could serialize to ~6× its raw length, but since it travels no capped channel, billing it by that inflated wire width would truncate a legitimately-sized diagnostic for no containment gain. Each side's JSDoc documents the split and points at the other.

Push stray pipe output one entry per data chunk. Rejected: logs entries are joined with \n downstream, so a transport chunk boundary would become a model-visible newline — a single native write split across pipe reads would read back with spurious line breaks. Aggregating by real newline (raw-chunk buffer + split on 0x0a) matches the child's line-granular log frames; the ledger still bounds a newline-free flood by admitting-and-truncating the residual when it would cross the budget.

Enforce the fd-3 frame ceiling per-frame (split before the counter check) to avoid a batch-edge false reject. Rejected: the ceiling check reads the byte counter BEFORE any Buffer.concat, precisely so a hostile program cannot force ~2× the 64 MiB frame cap of host memory (the counter and the join are a second copy of everything held). Splitting first to bill a single frame would Buffer.concat an over-ceiling frame before rejecting it, reintroducing that doubling — two regression tests assert the pre-concat order for exactly this reason. The batch-edge false reject the per-frame order would fix (a legitimate near-cap frame whose newline-bearing chunk also carries the next frame's leading bytes nudging the counter over the ceiling for one pipe read) is reachable only when maxLogBytes/maxValueBytes is configured within one pipe read of the 64 MiB cap — orders of magnitude past the 32/64 KiB defaults. The memory-safety bound against hostile input at any config takes precedence over a false reject reachable only at a pathological near-ceiling config; the counter's over-count and this trade-off are documented at the check.

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. The seam's CodeRunResult.logs JSDoc reads "in order", which the surrounding text scopes to program-emission order WITHIN a stream — ordering ACROSS concurrent streams is inherently best-effort here, since no host-side flush order can reconstruct the true interleaving the kernel already lost, so preserving a residual's arrival order at the flush buys nothing. A fixed drain order is as valid as any. 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

Interpreter misconfiguration fails before the service is published, every run uses the executable selected at load, and the child receives only TMPDIR, removing macOS startup noise without exposing host credentials. The private provider remains absent from shipped profiles while a keyless Loader snapshot pins its source-checkout PTC composition.

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 eleven 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), the shared stdout/stderr budget (whose only seam-observable difference turns on nondeterministic cross-pipe arrival timing), the flush_line reorder (whose lowered peak stays within what the 12x gate already admits, so no config behaves differently), pacing binding replies (the 32.0 MiB → 0.0 MiB peak reduction lives inside the host's fd-3 writable buffer, unmeasurable through the seam), dropping a late binding resolution before snapshot (its three assertions all hold pre-fix, so it is not a fail-before case), the done-value TOCTOU pre-encoding (its concurrent-mutation race is not deterministically constructible through the seam, and the daemon-mutation regression's only assertion is probabilistic), the stray-UTF-8 budget-flush retention (a budget flush landing on a multibyte boundary is not schedulable through the seam — v8-ignored), and the late-rejection settled guard (a rejection arriving after settlement is not deterministically constructible from the seam), and the log-fragment seal (its 25 M-scale OOM is not deterministically constructible in CI), and the unknown-binding preview cap (its whole-target JSON.stringify peak is a transient allocation inside the reply path, unmeasurable through the seam) — so a future regression on the rest goes red.

中文

Problem

用于 PTC mode 的 CPython 子进程后端建立在 fd-3 帧协议之上,把每个程序结果都 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 并不能使其确定;它所强化的按管道计的内存界限确实由单管道洪泛测试覆盖),flush_line 的 join-清空-push 重排序(它把结算期冲刷的峰值从三份副本降到两份,但 12× 加载门本就覆盖了换行路径的三副本峰值,因此每个被门放行的配置在两种顺序下都落在地址空间之内、不存在透过 seam 可观测的差异——该内存效应在 Python 子进程内部,与共享预算那处一样无法透过 seam 度量),节流 binding 回复(它的树内用例只断言分帧后的回复仍能完整往返;它所移除的峰值位于宿主 fd-3 可写缓冲内部、透过 seam 不可见,因此 32.0 MiB → 0.0 MiB 的降幅只能在树外度量),以及在快照之前丢弃迟到的 binding 解析(它的三条断言在修复前同样成立,因为 sendReply 本就丢弃结算之后的值——只是比快照晚),完成值的 TOCTOU 预编码(与编码竞态的并发变异无法透过 seam 确定性构造——它的 daemon 变异回归只断言结果永不为 worker-exit,这种断言是概率性的、不具有判别力,因此按既有的"无 fail-before 测试且有理由"先例登记为无 fail-before)、stray UTF-8 预算冲刷的扣留(正好落在多字节边界上的预算冲刷无法透过 seam 调度;它被交叉标注为 v8-ignored),以及结算后到达的迟到拒绝的 settled 先查(在运行已经结算之后才到达的拒绝无法从 seam 确定性构造),以及日志分片封存(25 M 级单字符滴灌 OOM 无法在 CI 确定性构造;树内用例只断言其完成并截断),以及 unknown-binding 预览上限(完整 target 的 JSON.stringify 峰值是回复路径内的一次瞬时分配——它唯一透过 seam 可观测的痕迹是伪造近上限 global/name 时的峰值内存,无法透过 seam 度量;树内用例只断言运行完成)。

Decision

若干处相互独立的修正,各自位于拥有对应缺陷的包中。

unknown-binding 预览从 1 KiB 前缀转义

unknown-binding 回复用 JSON.stringify 对完整的限幅 target(global 加 . 加 name,各最多 maxValueBytes 个 code unit)构造消息——在控制字符密集字段下转义形式可达输入的约 6 倍,在 maxValueBytes 上限附近产生数亿字节峰值,这是任何敌意对等方边界都不会放行的。预览现在从 target 的 1 KiB 前缀转义(足以辨识 binding);capMessage 仍执行回复预算。

合并的 open 日志条目只计费一次,按片段分摊

未结束行的显式 flush() 发出带 open: true 的 log 帧,宿主把下一个帧追加到同一条目(print('a', end='', flush=True); print('b') 读回为一条 'ab' 条目而不是假换行)。拆分计费算术——首片段付引号加内容加分隔符、续接与闭合帧只付内容、宿主 cap logBudget - 1/logBudget + 2、低于 2 字节的 walk guard、子进程的 _open_started 键控——只登记一次,见 fd-3 协议 note 的 wire-contract 段。

Boot-write failure no longer rejects run()

在 src/index.ts 中,fd-3 引导帧写入是 run() 同步初始化阶段的最后一条语句。它的 catch 会调用 finish(),而 finish() 读取 wallTimer 和 onAbort,并通过 settle() 读取 live。这些绑定是 const,且声明在引导写入之后,因此在同步写入失败时,finish() 会在它们处于暂时性死区(temporal dead zone)时访问它们,从而抛出一个 ReferenceError。该错误逃出了 Promise executor 并 reject 了 run(),违反了 seam 的"结果一律 resolve"契约:调用方看到的是一个被抛出的错误,而不是 catch 构造的 worker-exit。现在引导写入代码块被放到 wallTimer、onAbort 和 live 初始化之后,并且那处曾把该分支从覆盖率中隐藏的 /* v8 ignore */ 已被移除,从而使该 catch 被纳入度量。

Log capture is serialized against settlement

在 py/bootstrap.py 中,主协程上的结算 flush_out()/flush_err() 会读取并清空各个流的 _pending 列表,并修改共享的 LogBuffer 账本。模型代码可能启动一些 daemon 线程,其 print/write 会并发地修改同一状态。捕获绑定方法(out_stream.flush_line)只解决了结算调用哪个可调用对象的问题,而没有解决它在执行途中读取什么的问题:一次交错的 flush 可能拼接一个正在其下被修改的 _pending 列表,从而破坏账本并丢失 done 帧,使该次运行一直拖到墙钟超时。现在 LogBuffer 持有一把由两个流共享的可重入锁;_LogStream.write 和 flush_line,以及 LogBuffer.push,都会获取该锁,因此整个读-改-写过程在多线程间是原子的。

Fd-3 residual is copied, not viewed

同样在 src/index.ts 中,在对待处理 fd-3 分片的 Buffer.concat 结果按换行符做循环之后,剩余的不完整行被以它被切出的 subarray 视图形式向前传递。视图会使整个 concat 的底层分配保持存活,因此一个大帧后面跟着一个极小的尾部片段,会钉住整整一帧大小的内存,而 pendingBytes(被设为该片段的长度)报告的值远小于实际保留的内存。现在,残余数据通过导出的 detachResidual 辅助函数被分离到一个大小恰当的新 Buffer 中,从而让 concat 分配得以被回收,并使 pendingBytes 成为一个诚实的度量值。

Output-cap load bound is ceiling minus envelope, not divided by six

那处在加载期拒绝比单个 fd-3 帧所能承载更大的 maxLogBytes/maxValueBytes 的检查,会把帧上限除以六以应对最坏情况下的转义膨胀。但这两项预算都是以已转义的序列化字节来计量的:宿主日志账本通过 jsonStringCostUpTo 按序列化开销计费(它走到上限而不分配转义后的副本),checkDoneValue 度量的是转义后的形式,而生产侧的 _cap_message 同样按序列化开销设上限,因此一个在上限之内被放行的载荷在传输时最多占用 cap + envelope;转义已经包含在计费之内,不能再被乘一次。现在该上界为 FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES(接收路径在解码前拒绝原始长度超过 64 MiB parse cap 的帧——本次运行以 worker-exit 结算——因此预算不得超过诚实子进程的帧能穿过该解析器的值),未使用的 MAX_JSON_ESCAPE_EXPANSION 常量已被删除。旧的上界并非不安全(它是放行不足),但它静默地禁止了合法的大上限。这同一处加载检查还会拒绝一个非整数的 maxLogBytes/maxValueBytes:子进程通过 int(...) 读取每一项预算,而 int(...) 会对浮点数向下取整,因此 maxLogBytes: 3.5 会在子进程侧截断在 3 字节,而宿主却把小数部分也计入——两侧因此强制着不同的公开配置。在加载期拒绝该浮点数使两侧保持一致,与 worker 后端相符。

Same-group survivors are reaped before the fiber goes quiescent

模型程序可能在子进程自己的进程组里(没有 setsid,因此 kill(-pid) 能到达它)留下一个后代,它忽略 SIGTERM,但释放了继承而来的 stdout/stderr/fd-3 管道。随后 leader 退出,由于管道已被抽空,它的 close 触发,于是结算在那个后代仍存活时运行。kill() 在 SIGTERM 之后装设一个 unref 的 SIGKILL 定时器;本次修复是,当有一次升级正在进行时,settle() 既不立即 resolve 该次运行的 finished promise,也不立即把该运行从 live 中移除。取而代之的是,当 killing 被置位且进程组尚未为空时(process.kill(-pid, 0) 不抛出 ESRCH),它在一个 ref 的定时器上轮询该进程组,以 graceMs + CLOSE_REAP_MARGIN_MS 为界,仅当进程组已清空后才把该运行从 live 移除并 resolve finished。这个 ref 的轮询是承重部分:它让宿主事件循环保持存活,直到 SIGKILL 真正回收了该进程组,因此即使是一个短命的宿主(一次性的 headless 运行、一个配置子进程)也无法退出并把存活者 reparent 给 init。把 live 的移除推迟,正是让一个与刚返回的 run() 竞争的 dispose() 仍会 await 该存活者的原因:若在回收之前就把运行从 live 移除,teardown 会快照到一个空集合并在后代仍存活时返回。在正常情况下(leader 是唯一成员),第一次探测返回 ESRCH,结算以零附加延迟完成收尾。teardown() 会 await 每次运行的 finished,因此 dispose 是真正完全停稳的,与其 JSDoc 相符——包括对一个已经 resolve 的运行也是如此。

结算还会在进程组被确认为空的那一刻取消 SIGKILL 定时器(正常路径,以及轮询看到存活者已消失时)。让它继续处于装设状态会暴露一个 PID 复用隐患:一个在 leader 被回收后仍挂起长达 graceMs 的 kill(-pid),可能在内核复用了 leader 的 pid 之后击中一个被回收(recycled)的 pgid,从而 SIGKILL 掉一个无关的进程组(killGroup 吞掉 ESRCH 并无帮助——危险恰恰是那次针对被复用进程组成功执行的 kill)。在空进程组探测时清除它,把复用窗口收窄到只剩真正存在存活者的情形,此时进程组不可能为空以供复用。

被清除的定时器覆盖不到的那段窗口,由 killGroup 内部的身份校验封死。它发出的每个信号都是裸 process.kill(-child.pid, sig)——与 child.kill() 不同,它没有 handle 守卫——因此在 leader 被回收到 close 触发之间的那段间隔里(实测有一个持有管道的后代时可达 3039 毫秒),信号会打到一个被复用的 pgid 上。所以 leader 的启动时刻在 spawn 时读取一次(/proc/<pid>/stat 第 22 字段),并在每次发信号前重读,有两条裁定:读数存在且不同意味着该数字现在属于另一个进程,于是扣下信号;读数缺失意味着 leader 已被回收,而这正是每次升级的常态——它的 /proc 条目已消失,而它曾领导的进程组仍可能持有本次 teardown 要回收的那个存活者——于是信号照常发出。缺失也是无 /proc 平台上的恒定读数,那里守卫处于惰性状态、保持原有行为。把缺失读作身份不符并非假想:第一版就是这样做的,它扣下了宽限期的 SIGKILL 与轮询截止分支的 SIGKILL,导致三个同组心跳用例在 Linux coverage lane 上变红,而在 Darwin 上因读取器恒返回 undefined 而通过。

回收轮询还会处理宿主事件循环被阻塞、越过两个定时器的情形。如果一次同步计算从轮询被调度之前一直占住事件循环、直到越过它的截止时间,那么当事件循环恢复时,轮询定时器和宽限窗口的 SIGKILL 定时器都已逾期,而 Node 会先运行更早调度的轮询——因此宽限窗口的 SIGKILL 可能从未触发。为此截止时间分支会自己发送 SIGKILL(若定时器已运行则该操作幂等),而不是取消尚未触发的升级,随后再额外给予一个 CLOSE_REAP_MARGIN_MS,并持续轮询直到进程组被确认为空,因为仅凭信号投递就收尾会在进程组仍在消亡时宣告完全停稳。因此等待的外层上界为 graceMs + 2 * CLOSE_REAP_MARGIN_MS。若这段额外余量耗尽而进程组仍非空,一个最终的硬性上界会收尾;该分支带有一处 /* v8 ignore */,因为它仅在一个被 SIGKILL 的存活者作为僵尸进程滞留且从未被 wait()——一个 PID 1 不回收孤儿进程的容器——时才可达,而这无法在各 CI 平台上确定性地构造出来。该 ignore 的理由陈述的是这种环境依赖性,而不是声称该分支不可能运行,并交叉引用 Alternatives 中以同样理由否决 signal-0 回收断言的那一条。

RLIMIT clamps against the inherited soft limit, not only the hard

在 py/bootstrap.py 中,_clamped 仅用继承而来的 HARD 限制来约束一个请求的 (soft, hard) rlimit 对。一个继承了低于请求值的软限制的部署——比如继承 (100, 200)、请求 (150, 160)——会拿回 (150, 160),把有效软限制从 100 抬高到 150:对 RLIMIT_AS 而言这放松了内存上限,对 RLIMIT_CPU 而言它推迟了 SIGXCPU,两者都违反了"取配置值与继承值中最严格者"。现在 _clamped 用每一侧各自继承而来的对应值来约束该侧(RLIM_INFINITY 不施加任何上限),随后把 soft 钉在 hard 之下,因此 setrlimit 绝不会看到一个倒置的对。结算时的 CPU 复查(die_if_cpu_exhausted)遵循同一规则:它把已消耗的 CPU 与实际生效的、被夹紧的 cpu_soft 比较,而不是与配置的 cpuSeconds 比较,因此一个捕获 SIGXCPU、在返回前消耗超过更严格的继承软限制的程序会被报告为 timeout,而非误判为成功。 复查会在解除程序屏蔽的 SIGXCPU(pthread_sigmask(SIG_UNBLOCK, ...),import 期捕获)之前先恢复 SIG_DFL:一个既安装自定义 handler 又屏蔽信号的程序,否则会在 unblock 的瞬间让那个挂起的 handler 以模型代码身份运行(可重新屏蔽或抛出),所以信号被释放时处置必须已是 SIG_DFL;SIG_DFL 在前时,挂起的信号在内核内直接致死、无字节码窗口,而 kill 重投递是给从未挂起情形的兜底。SIGXCPU 诊断不再把配置的 cpuSeconds 说成实际生效的预算——在一个更严格的继承软限制之下那个数字是错的——而是报告 CPU 时间是在"至多配置的 N 秒"处被耗尽,这一表述无论哪个限制先触发都成立。

并发 binding 回复对 fd 3 做节流

sendReply 忽略了 proto.write 的 false 返回值,因此一个在一轮 asyncio.gather 中解析多个大值的程序,会把每条回复都在同一个 turn 内编码、并全部排入 fd 3 的可写缓冲。binding 回复在 seam 层没有字节上限可以约束它,而且这个失败杀掉的是宿主进程而不是让本次运行失败:在 highWaterMark 为 64 KiB 的管道上实测,八条 4 MiB 回复会同时缓冲 32.0 MiB。现在回复走一个队列,一次编码并写出一帧,管道写满时等待 drain——同样形状实测峰值为 0.0 MiB。编码放在循环内部,因此一条运行已不再需要的排队回复会被 settled 检查丢弃,根本不会被序列化。同一个 settled 谓词还在 await fn(...) 之后、snapshotJsonValue 之前守护回复回调,因此一个在结算之后才 resolve 的宽值会在走完它的宽度之前被丢弃——宿主不会为一个结果已定的 run 展开一个迟到的宽值。

节流不改变任何模型可观测的行为。子进程通过一个持续读取 fd 3 的 pump、按 id 把每条回复匹配到它自己的 call,因此到达顺序从来不可观测,而各 binding 本身仍然并发执行——只有宿主的峰值内存与冲刷时延改变。这也正是为什么串行化并不构成对 seam 并发契约的收窄,而那恰是最初推迟此项的理由;那个理由是错的。

Binding replies complete on the calling loop's thread

同样在 py/bootstrap.py 中,一个绑定回复 Future 是在运行 dispatch 的那个事件循环上创建的。当模型通过 asyncio.run(tools.x(...)) 从一个工作线程调用某个绑定时,该 Future 属于该线程的事件循环,而不是 _pump_replies 读取回复的主事件循环。asyncio.Future 不是线程安全的:从另一个线程完成它并不会唤醒它自己的事件循环,因此直接的 set_result/set_exception 会让那个正在等待的线程被搁置,该次运行退化为墙钟超时。现在每个待处理条目都会在记录 Future 的同时记录其 Future 所属的事件循环,_pump_replies 通过该事件循环的 call_soon_threadsafe 来完成它。共享的 pending/next_id 状态由一把 threading.Lock 保护,该锁跨越 id 认领、fd-3 写入和计数器推进这三步持有,因此并发调用方无法以违反宿主所要求的 id 顺序来交错帧。对一个已经关闭的事件循环(工作线程已结束、在回复到达前放弃了它的调用)调用 call_soon_threadsafe 会抛出 RuntimeError;该调度被包裹起来,使这个已无意义的回复被丢弃,而不是让异常终结 pump 任务并搁置此后的每一个回复。

The blocking frame reader reads in chunks, not byte by byte

ProtocolChannel.read_frame(用于 boot 和 run 握手帧)过去通过在无缓冲(buffering=0)fd 上的 FileIO.readline() 读取,这会为每个字节发起一次 os.read(1)。run 帧在 RLIMIT_CPU 生效之后才到达,因此一个合法的数兆字节程序会在 ast.parse 运行之前,在数以百万计的单字节系统调用中烧掉数秒 CPU——有可能仅在读取这一步就耗尽预算。现在它以 _READ_CHUNK_BYTES 为单位分块读取,写入异步读取器已经使用的那同一个 _pending 残余缓冲区(包裹用的 os.fdopen 对象已被移除;两个读取器都直接调用 os.read(self._fd, ...)),因此读取开销微不足道,并且越过换行符的预读也为下一帧保留了下来。两个读取器都跟踪一个持续推进的扫描偏移(find(b"\n", scanned)),使一个跨多个分块累积起来的大帧只被扫描一次,而不是每来一个分块就从索引 0 重新扫描——分块式重扫会把逐字节的开销换成同一大帧路径上 O(N²) 的 memchr 开销。

Synchronous spawn failure resolves worker-exit, not reject

同样在 src/index.ts 中,spawn 是在结算 Promise 的 executor 存在之前被调用的。Node 只把一组固定的 spawn errno(EACCES、EAGAIN、EMFILE、ENFILE、ENOENT)推迟为一个异步的 error 事件,而结算路径已经把它转成一个 worker-exit;其余每一个 errno 都会从 spawn 同步抛出。一个长度超过平台 PATH_MAX 的 pythonBin 能通过加载期校验(非空、无 NUL),却会让 spawn 在此处抛出 ENAMETOOLONG——在 executor 之外——因此 run() 会 reject 而不是 resolve,违反了"只 resolve、不 reject",并且由于只有 settle() 才会移除本次运行刚物化出来的暂存目录,它会把该目录留在磁盘上。现在 spawn 调用和 fd-3 收窄被包裹起来:一次同步抛出会移除暂存目录,并 resolve 与异步 error 事件所产生的同一类 worker-exit(python spawn error: …)。

解释器选择与子进程环境在加载期固定

pythonBin 在插件加载期解析为一个可执行绝对路径,并在与运行时相同的受限环境中完成版本探测。提供方要求 CPython 3.10 或更高版本并保留该确切路径,因此后续 PATH 或工作目录变化不能切换解释器;不是可执行普通文件的显式路径、无法解析的裸名或不受支持的解释器都会在 ctx.codeRuntime 注册前失败。同步探测有固定的五秒期限,并在期限到达时发送 SIGKILL,因此忽略 SIGTERM 的包装脚本不能阻塞插件加载。每次探测与运行只接收 TMPDIR:macOS 系统 Python 需要它来避免向被捕获的 stderr 发出启动警告,而凭证、PATH、HOME 与其他宿主环境值均不会进入模型代码。若已校验的可执行文件在激活后消失,普通 spawn 结算仍 resolve 为 worker-exit。

Stray pipe output is aggregated by line, not by transport chunk

同样在 src/index.ts 中,原生 stdout/stderr 字节(C 扩展写入、越过管道缓冲区的 os.write)过去每来一个 Node data 分片就被推入 logs 一条条目。logs 条目在下游(PTC 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 中被丢弃。

An incompatible output-budget/addressSpaceMb pair is rejected at load

子进程(py/bootstrap.py)在 RLIMIT_AS 之下构建、计费并分帧一条 maxLogBytes 的日志条目或一个 maxValueBytes 的完成值,而两个账本都是按字符计数、对照一个序列化字节预算触发的。一个星芒面字符是一个字符,但占 CPython str 存储的四个字节以及四个 UTF-8 字节,且最重的路径峰值时有三份这样的副本同时存活:一次 sys.stdout.write(line + "\n") 会持有调用方的 text 实参(在整个 write 调用期间存活,约 4 倍)、交给 LogBuffer.push 的行切片(约 4 倍)、以及 _push_locked 为计费和发送而取的 text.encode("utf-8") 副本(约 4 倍)——峰值约为预算的 12 倍。结算期的 flush_line 路径只持有两份(它的 "".join(...) 与那份 encode 副本——它在 push 之前先丢弃 pending 分块),因此换行路径才是起约束作用的最坏情况。当一项预算逼近 addressSpaceMb 时,一次合法的、接近预算的输出会在那次构建加编码期间突破地址空间,并作为 worker-exit(日志)而不是截断而终止,或作为 output-limit(值)而失败。在运行时对每次子进程写入按地址空间计量是错误的修复:热路径上一次精确的序列化开销检查,要么是一次完整的 encode(正是要避免的那次分配),要么是一个逐字符的 Python 循环(它会烧掉 CPU 预算——一次 10 MB 的合法写入会在 cpuSeconds: 1 之下触发 SIGXCPU)。两者都是拿一种资源界限换另一种。取而代之,src/index.ts 在加载期(LOAD)拒绝这个不兼容的组合:每项预算乘以 OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE(十二——换行路径三份同时存在的约 4 倍副本)必须放得进为解释器自身占用预留一份固定的 INTERPRETER_BASELINE_BYTES 之后剩下的地址空间,并用一个 >=,使得一项其最坏情况峰值恰好等于那片余量的预算也会被拒绝(该峰值加上预留的基线正好是整个地址空间,即 RLIMIT_AS 边界)。flush_line 也被改为在 push 之前先丢弃 pending 分块,与换行路径的 join-清空-push 顺序一致,使它至多只持有 join 及其 encode 副本,而非三份副本。该基线是与倍数分开(SEPARATELY)预留的,因为它是一项固定开销,而非随预算伸缩的开销:把它折进倍数会让一项恰好为 addressSpaceMb / 12 大小的预算被放行,而其峰值加上解释器仍会越界。maxLogBytes 和 maxValueBytes 都被对称地门控;值路径的构建加编码是同一形态。该检查在每个平台上都运行,而不仅在强制 RLIMIT_AS 的平台上:这种不兼容是那些配置值的属性,因此一个 Linux 部署无论由哪个宿主组装配置都会 OOM,而一致的加载期拒绝正是 fail-loud 契约(Darwin 仅跳过运行时的 setrlimit)。这在配置 seam 处消除了这一类问题,而不是给写入路径打补丁,因此 _LogStream 保留它原有的按字符计数的缓冲(一个对序列化开销有效的下界,一旦预算放进地址空间就是内存安全的)。值路径在第二处施加同样的纪律:_check_done_value(字节计量器)与 _encode_json_plain(帧编码器)都以 O(DEPTH) 而非 O(width) 遍历。每个容器只压入一个游标帧、逐个拉取子元素,而不是每个子元素一个遍历元组或栈条目——一个扁平的 [0] * 6_000_000 序列化后约 12 MB,但逐元素遍历会分配约 400 MB 的簿记(约为序列化尺寸的 28 倍,远超门预留的 12 倍),于是一个被计量器放行的值可能因遍历自身的帧而 OOM。改用游标后,唯一与宽度成正比的分配就是计量器已界定的输出字符串。

宿主门控是对照配置的(CONFIGURED)addressSpaceMb 校验的,但一个启动环境可能继承一个更严格的(STRICTER)RLIMIT_AS(一个低于 addressSpaceMb 的 ulimit -v 包装层),而 bootstrap 的 _clamped 会正确地把有效(EFFECTIVE)限制降到该值——从而让这些预算是按一个子进程永远得不到的上限来定尺寸的。因此 bootstrap.py 在应用该有效被夹紧的软限制之后,会对照它重新检查两项预算,镜像宿主门控的倍数与基线,并在引导期抛出(被 setrlimit 阶段的处理器捕获,并作为 exception 上报——与任何其他资源限制应用失败同属一类),而不是任由一次接近预算的输出在运行途中 OOM。这两个子进程侧常量与宿主侧的保持一致,靠的是共享的推理,而不是一个 wire 字段。

在此之外还一并修复了一处残余写入路径的复制,它与配置门控相互独立:_LogStream.write 的换行分支会在冲刷触发器能够对其设界之前,先把最后一个换行符之后整个未结束的尾部(text[pos:])缓冲进 _pending,因此一个早出现的换行符后跟一个巨大的尾部("\n" + "A" * 30 MiB)会对模型自身的字符串再做一份完整副本——正是这条路径存在所要规避的那次 RLIMIT_AS 死亡,而且是配置门控无法覆盖的一次,因为该尾部可能远超 maxLogBytes。现在该尾部被切到一个 remaining + 4 字符的前缀(超过 remaining 字符的任何内容都无法被准入,因为字符计数是序列化开销的下界),随后冲刷触发器会用标记将它拒绝。

完成值与错误在其校验点处预编码

在 py/bootstrap.py 中,_done_with_value 现在会在成功路径上把整个终止帧作为一个已预编码的 JSON 字符串返回:被准入的值在这里、在 _run 的 try 之内的校验点处恰好序列化一次,即 '{"type": "done", "value": ' + _encode_json_plain(value) + "}"。程序在返回之后仍可能从 daemon 线程或信号处理器继续变异它返回的 list/dict,因此在一个更晚的点上做第二次遍历会构成一次 TOCTOU——如果一次变异让一个并发变异的后续编码在结算处理器之外抛出,就会把一次已结算的运行在宿主侧降级成 worker-exit。在这里、在包裹该调用的 try 之内恰好序列化一次,就关上了这个窗口:如果一次并发变异导致编码抛出,异常处理器会把它如实分类为 exception;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。_run 在程序运行前把 _done_with_value 的入口名绑成局部(done_with_value_bound),而 _done_with_value 自身把 _check_done_value 与 _encode_json_plain 绑定为 def 期默认参数——因此模型执行后对入口名或这两个名字的 __main__ 重绑无法把一个合法成功改写为 exception。日志账本(宿主 logBudget 与子进程 _remaining)从预算低 1 字节起算,预留序列化外层数组的外壳(两条括号与 n-1 个逗号,覆盖 n 条目的分隔符),因此恰好耗尽账本的结果序列化后仍在配置上限之内。 一旦账本已截断,宿主会整体清空两条 stray 管道的缓冲输出(之后的每个字节都会被 admit 变成 no-op,保留它只会把宿主内存花在永远无法准入的输出上);子进程以 -u 运行,使 sys.__stdout__/sys.__stderr__ 的写入对 stray 捕获立即可见,而结算 flush 仍在 done 帧前排空原始 std 流(防御 sys.__stdout__ = boom 重绑后残留的缓冲包装)。构造器拒绝低于 64 的 maxLogBytes(能为截断标记自身序列化形式留出一字节余量的最小预算);maxValueBytes 只保留正整数要求,因为完成值可以只有一字节、且 done 帧外壳是 seam 协议成本。标记仍是 envelope,因此带已放行条目的截断运行序列化后至多为 maxLogBytes + marker + envelope(已记录在包 README)。但编码器到达的一个传递依赖(例如 _dump_scalar/_dump_string/json/io——非穷举清单)重绑仍可让编码抛出、把成功降级为 exception,这在包 README 中被登记为已接受残余。

send_done(_run 内部的一个局部函数)通过绑定的 channel.write_encoded 写出已预编码的字符串,并在写之前用绑定的 _encode_json_plain 编码一个 dict 错误帧——它绝不经 channel.send_sync,因为后者的函数体会在调用时刻重新解析 self.write_encoded 和模块级的 _encode_json_plain。_encode_json_plain 与 channel.write_encoded 在程序运行前就被绑定进局部变量,理由与 flush_out/flush_err/safe_model_traceback 被绑定相同:程序以 __main__ 运行,因此 import __main__; __main__.ProtocolChannel.send_sync = boom 或 __main__._encode_json_plain = boom 本会在调用时刻把发送/编码重新解析成被替换的可调用对象,当该替换抛出时跳过 done 帧、把已结算的结论降级成宿主侧的 worker-exit。

预算触发的冲刷会扣留下一个未完成的多字节尾序列

同样在 src/index.ts 中,flushStray(stray, retainPartialTail) 在预算触发的冲刷(captureStray 中的合并成本阈值)上会把一个未完成的多字节尾部从解码中扣留:当残余以部分 UTF-8 前导序列结束(stray.utf8.expected > 0)时,前导字节加上迄今已消耗的续字节(≤3 字节)会从该帧中分离出来作为新的残余,只有完整的前缀被准入并解码。当整个残余就是单个未完序列时什么都不准入,因此一个合法、未完成的字符绝不会在一个被放行、未截断的条目里被渲染成 U+FFFD,也不会推进一条虚假的空条目。被扣留的尾部会从一个全新的 stray.utf8 状态重新累计——若用冲刷后 expected > 0 的状态来计量它,会把那些被承载下来的前导字节当作非法续字节计费——从而下一个分块能正确地继续推进,且该管道在保留的尾部之上重建其成本/UTF-8 状态。end/closeDeadline 路径传入 false,原样解码整个残余,因为在那里一个不完整的尾序列是真实的坏输入,U+FFFD 才是如实呈现。

迟到的 binding 拒绝在格式化错误之前就返回

同样在 src/index.ts 中,binding 拒绝的 catch 分支现在会在格式化 messageOf(error) 之前检查 settled 并返回。一次在 maxWallMs、abort 或 dispose 已经把该运行结算之后才到达的拒绝,本会让 messageOf(error) 在这之前运行敌意的 toString/message getter——为一个结局已定的运行花费宿主堆与时间——然后 sendReply 才去窥探 settled。及早丢弃这一条已分帧的回复省下了这笔开销。运行中那条本就大致线性的回复排空改用队头游标按数组下标读取、而非 shift() 逐项弹出,因此一大轮等待 fd 3 的 drain 的宽 binding 的 asyncio.gather 会以线性时间排空,而不是因反复切片退化成 O(n²)。

无换行滴灌会封存其分片;CPU 软限制保持在硬限制之下;done 帧回退到固定字面量;回复队列清空已消费槽位

在 py/bootstrap.py 中,_LogStream 现在会在待处理分片列表越过一个上限时封存它:无换行、每次 write 一个字符的滴灌会每次调用累积一个 list 槽位(以及一个 str 对象),在一个大的 maxLogBytes 下,25 M 次单字符洪泛会在字节预算达到之前,于其自身记账上 OOM(加上 _push_bounded_prefix 随后构造的同规模列表)。越过 _PENDING_MAX_CHUNKS 后,当前分片被 join 成一个块并移入 _pending_blocks 列表(字符数不变),把存活的碎片数量限制在宿主侧 captureStray 封存所做的同等水平;该 join 只针对 ≤cap 的当前分片,从不针对整个累积缓冲,因此大的滴灌保持 O(B),而不是以 O(B²/cap) 次反复复制不断增长的块。宿主侧 open hold 镜像同样的封存:预算内的单字符 open 洪泛(print('x', end='', flush=True) 循环是诚实子进程可达路径)否则会为每帧累积一个片段数组槽位加字符串对象头——约 30× 字节计数看不到的开销,在 maxLogBytes 装载上限附近最高约 2 GB 宿主辅助堆。越过 MAX_PENDING_CHUNKS 后持有的片段并入 openSealed;闭合帧合并、truncateLogs 与 finish 残段都读取 sealed 加当前片段并清空封存。

_clamped 还会把钳制出的、与硬限制相等的 RLIMIT_CPU 软限制降低一个单位(当硬限制至少为 2 时)。ulimit -t N 会同时设置两者,而当 soft == hard 时,内核会在同一 tick 检查硬限制并直接 SIGKILL 一个忙循环,因此 SIGXCPU 永远不会送达——而宿主只在 signal === 'SIGXCPU' 时把 CPU 超限分类为超时,所以一次确定的预算耗尽会被误报为 worker-exit。把软限制降低一个单位给 SIGXCPU 一个触发窗口,因此超限会被报告为超时。这仅限定于 RLIMIT_CPU(在 RLIMIT_AS 上的一字节软差异只会让子进程实际应用的限制与宿主预算门失步,没有需要保留的信号)。hard >= 2 守卫留下了 hard == 1 盲区——一个 1 秒的双限制无法把软限制降到 0,因此那里的确定超限仍被报告为 worker-exit。

send_done 将其 encode+write 包进 try,任何来自被重绑的传递名(_dump_scalar/os)的抛出都会写入一条固定的预编码 done 帧,经由 _run 局部绑定的 _os_write/_memoryview/_FALLBACK_DONE_FRAME——因此一个已结算的 exception 判决绝不会被降级为 worker-exit,宿主仍会拿到一个判决。回复队列的头游标排空会清除每个已消费槽位,因此一个已写出的宽 payload 会被立即释放,把宿主内存限制在持续的 fd-3 背压下的当前积压量。结算路径各 except 子句所捕获的异常类同样在任何模型代码运行之前绑定:_BaseException 是 _run 的局部与 _make_failure_reporter 的闭包单元;_RuntimeError、_BindingRejection、str 与 bool 是 _pump_replies 的 def 期默认参数(函数体内的 X = X 绑定太晚——模型顶层语句会先于泵体首步执行)。重绑 __main__.BaseException 无法让程序异常逃出处理器、丢失 done 帧;重绑 __main__.RuntimeError(或 _BindingRejection/str/bool)无法让闭环调度失败逃出泵的捕获、把每条后续回复搁浅到墙钟超时。

Testing

  • tests/runtime.spec.ts 在加载期拒绝缺失、不可执行、非 CPython、低于 3.10 或无响应的解释器配置;在激活后更改 PATH,证明已解析的可执行文件保持固定;在激活后删除该文件,保留迟到的 worker-exit 路径;并断言运行中的程序能看到 TMPDIR,但看不到 PATH、HOME 或 DEEPSEEK_API_KEY。原生输出用例分别固定每个来源流内的顺序,而不要求独立通道之间存在总顺序;Darwin 资源限制用例明确说明或跳过平台特有的 RLIMIT_AS 行为。
  • snapshots/session/ptc-python-turn 通过真实 Loader 把 headless PTC worker 提供方替换为私有 Python 提供方,经真实 bash binding 重放一个 Python run_code 程序,并固定 Python SDK prompt、tool schema、dispatch event、捕获日志与完成值。
  • 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 用例断言 parse-cap - envelope 上界(67108800)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 maxLogBytes 之下,通过 os.write 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 b"one\ntwo\nthree",断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 maxLogBytes 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 \xff 写入,并对 Buffer.concat 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 ED A0 80 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 A 和一个 U+FFFD(覆盖 accrueStrayCost 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 data 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 \uXXXX 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 jsonStringCostUpTo 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 accrueStrayCost 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 \ud800 转义洪泛的 fd-3 log 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 os.write(1, …) 调用,并对 Buffer.concat 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 MAX_PENDING_CHUNKS 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 setsid 孤儿进程,断言该诊断在 logs 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 dispose(),只有在该存活者停止执行之后才返回(证明该运行会一直留在 live 中,直到它的进程组被回收),并带有一个 expect(afterDispose).toBeGreaterThan(0) 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 await asyncio.sleep 让出时,从一个工作线程自己的 asyncio.run 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,随后在一个后续绑定之前回答它——断言 pump 在关闭事件循环上的 call_soon_threadsafe 之后仍然存活(由宿主门控的顺序使其具有确定性,未修复时会把后续绑定拖到墙钟上挂起)。inherited-soft-limit 用例通过一个 ulimit -S -t 包装脚本运行解释器,将 CPU 软限制设为低于 cpuSeconds,并断言实际应用的 RLIMIT_CPU 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 ulimit -v)。一个配套用例继承 1 秒的 CPU 软限制,让程序捕获 SIGXCPU 并忙循环越过它,断言结算复查报告 timeout——证明复查用的是实际生效的软限制,而不是配置的 cpuSeconds。一个 control-heavy-diagnostic 用例在一个较小的 maxValueBytes 之下抛出一个 NUL 洪泛异常,断言序列化后的帧能放得下(证明该诊断是按序列化开销计量的)。一个 tail-copy 用例(maxLogBytes: 256、addressSpaceMb: 384)让程序在一个变量里构建一个尾部并写入 "\n" + tail,其中 tail 为 150 MiB——构建峰值约 2 倍(约 300 MiB,落在地址空间之内,因此模型自身的分配会成功,任何 OOM 都属于缺陷路径),而修复前的整尾重新缓冲会加上第三份约 150 MiB 的副本、越过 384 MiB;切片后的前缀让该次运行得以截断并完成(仅 Linux 的 RLIMIT_AS 复现,macOS 走顺利路径——fixture 自身的构建必须放进地址空间,这是这些 RLIMIT_AS 用例的一条通用规则)。一个 output-budget/address-space 用例断言一个 50 MB 的 maxLogBytes 和一个 50 MB 的 maxValueBytes 各自对照一个 256 MiB 的 addressSpaceMb 在加载期被拒绝(乘以最坏情况的 12 之后超过解释器基线之后剩下的余量),而默认的各项上限对照 512 MiB 则加载成功,对两项预算对称地门控;一个具区分力的用例断言一个 48 MiB 的 maxLogBytes 对照一个 512 MiB 的 addressSpaceMb 被拒绝——48×8 = 384 MiB 放得进 448 MiB 的可预算余量(旧的 8× 倍数会错误放行),但 48×12 = 576 MiB 放不进。该倍数覆盖的约 12× 峰值来自换行路径上一次接近预算的写入——调用方自己的字符串、行切片与 encode 副本同时存活;结算期 flush 已不再是承重者,因为 flush_line 在 push 之前就丢弃了 pending 分块,因此只持有两份副本而非三份。一个 inherited-RLIMIT_AS 用例通过一个 ulimit -v 131072 包装层运行解释器,配以一个配置的 512 MiB addressSpaceMb 所允许的 32 MiB maxLogBytes,断言引导期的重新检查把它作为 exception 拒绝、且其消息点名了继承的 RLIMIT_AS(128 MiB 的继承限制在基线之后剩下的太少;仅 Linux,macOS 忽略 ulimit -v,该次运行会继续)。一个 non-integer-budget 用例断言一个小数的 maxLogBytes/maxValueBytes 在加载期被拒绝。一个 combined-peak 用例(maxLogBytes: 32 MiB、maxValueBytes: 32 MiB、addressSpaceMb: 512——每项预算单独都被 12× 门放行)写入约 33M 个不含换行符的星芒面字符(缓冲、未冲刷)后返回约 33M 个星芒面字符,断言该次运行以 output-limit 结算(该值本身就超过它 32 MiB 的预算);修复前未冲刷的日志 pending 加上值的构建加编码峰值会一起越过 512 MiB 地址空间而 OOM,因此在分帧值之前先冲刷日志正是让值检查得以完成的原因(仅 Linux 的 RLIMIT_AS 复现;在 macOS 上超预算的值在两种顺序下都报 output-limit)。一个宽完成值用例(maxValueBytes: 20 MiB、addressSpaceMb: 384)返回 [0] * 6_000_000——JSON 约 12 MB、低于预算,因此必须成功往返;修复前 O(width) 的遍历为每个元素分配遍历元组与编码器栈项(约为序列化尺寸的 28×,超出门保留的 12×),在一个计量器已放行的值上 OOM,而 O(depth) 游标使唯一按宽度分配的只剩输出字符串本身。一个宽 binding 实参用例(addressSpaceMb: 384)以 [0] * 6_000_000 调用 binding 并断言长度回传:_lossless_json_violation 运行在模型构造的实参上,子进程侧没有任何字节预算先行约束,其逐元素元组实测 459.1 MiB,而游标为 0.0 MiB。一个回溯用例从 binding 返回一个 4 MiB 字符串并断言其成功往返:旧的标量正则保留的引擎状态与字符串宽度成正比(1 MiB 时 146 MiB,4 MiB 时 557.8 MiB,超过默认的 512 MiB),会在 _pump_replies 内抛出 MemoryError 并把该次调用搁置到墙钟。一个 control-heavy 计费用例在 16 MiB 的 maxValueBytes 之下返回 8M 个 NUL,断言得到 output-limit 而非 exception:以计数替代物化转义形式来计费,在字节数完全相同的前提下实测 19.1 MiB 对 228.9 MiB。一个 addressSpaceMb 下界用例断言 64 MiB 与 32 MiB 在加载期被拒绝,且消息点名 addressSpaceMb,而不是预算循环给出的负数上限。一个进程身份用例断言 leader 的启动时刻在 Linux 上可稳定读取、在 Darwin 上报告 undefined,这正是使被复用的 pgid 不会收到本次运行 SIGTERM 的那道守卫。一个 paced-replies 用例在一轮 asyncio.gather 中 resolve 八条 4 MiB 的值,断言这些帧能够往返;本修复移除的峰值(32.0 MiB 缓冲 → 0.0 MiB)位于宿主 fd-3 可写缓冲内部、透过 seam 不可见,因此该用例钉住的是往返与无回归匹配,其峰值只能在树外度量。一个 late-drop 用例让该次运行在 maxWallMs 上结算,随后才 resolve 那个 pending 的 binding,断言得到一个 timeout 结果、一个 undefined 值、且迟到路径确实被执行过——这三条断言在修复前也全部成立,因为 sendReply 本就丢弃结算之后的值、只是更晚,因此该用例钉住的是顺序,而非一个透过 seam 可观测的行为。一个"绑定全部用名"用例(rebinds every name the failure path uses)断言一个真实的 ValueError 在 send-done 绑定之后仍然存活——这是一个有测修复,由一个逐名重绑 __main__.ProtocolChannel.send_sync、__main__.ProtocolChannel.write_encoded 与 __main__._encode_json_plain 的用例钉住——这三个名字正是 shipped 的 send_done 若做调用时刻查找时会迟解析的那三个——并钉住 done 帧不被一次调用时刻的查找跳过;而完成值的 TOCTOU 预编码、stray UTF-8 预算冲刷的扣留与结算后到达的迟到拒绝的 settled 先查,都被计入那十处无 fail-before 修复(理由见 Problem 段),不由 fail-before 测试钉住。 一个 fragment-cap 滴灌用例写入 200 000 次单字符无换行的 sys.stdout.write 调用,断言该次运行以截断标记完成而非 MemoryError(no-fail-before:25 M 规模的 OOM 无法在 CI 中确定性构造)。一个 dual-limit CPU 用例通过一个 ulimit -t 2 包装脚本运行解释器并忙循环越过它,断言得到 timeout(软限制被降到 1,因此 SIGXCPU 触发,而非 worker-exit)。一个传递名重绑用例重绑 __main__._dump_scalar、__main__.os、__main__._os_write、__main__._memoryview 与 __main__._FALLBACK_DONE_FRAME,断言仍有一帧 done 以 exception 落地,而非 worker-exit(真实消息被固定兜底字面量替换)。 一个 BaseException 重绑用例把 __main__.BaseException 重绑为 RuntimeError 并抛出 ValueError,断言该次运行仍报告 exception,而非 worker-exit(catch 用的是程序运行前的局部异常类)。一个 RuntimeError 重绑闭环用例把 __main__.RuntimeError 重绑为 ValueError 作为程序首条语句,并驱动闭环 worker 模式,断言泵存活于死循环回复、投递后续 binding(泵的 _RuntimeError 是 def 期默认参数,因此在重绑前捕获原始值)。 一个 _done_with_value 重绑用例把 __main__._done_with_value 重绑为一个抛出函数并返回合法值,断言该次运行仍报告成功(入口名是程序运行前绑定的 _run 局部)。 一个 sys.__stdout__ flush 用例不经显式 flush 直接通过 sys.__stdout__/sys.__stderr__ 写入,断言两个字节都出现在 logs 中(-u 无缓冲子进程加上结算对原始 std 流的排空)。 宿主在 spawn 后立即关闭子进程的 stdin 写句柄(程序是不读 fd 0 的 async 函数体;存活的管道会在运行结束后继续持有宿主侧句柄,让继承 fd 0 的 setsid 逃逸后代拖住宿主进程)。通道的帧读取器把解码原语(_decode_json_plain、os.read、_READ_CHUNK_BYTES、bytes,以及 len;异步读取器还有 asyncio.get_event_loop)绑定为 def 期默认参数,因此 __main__ 重绑无法杀死回复泵;_decode_json_plain 自身以同样方式捕获 json.loads/两个正则/len/isinstance/str/list。回复泵的帧读取器是 _run 在程序运行前捕获的绑定方法,以显式参数传入 _pump_replies(函数体内的 channel.read_frame_async 查找会解析被重绑的类属性,因为泵在程序顶层语句之后才启动)。 send_done 的帧形判别使用 _run 绑定的 _str/_isinstance(程序重绑 __main__.isinstance 无法让合法成功落入固定字面量兜底)。 _make_error_class 把 Exception 与 setattr 捕获为 def 期默认值,dispatch 把 _lossless_json_violation/asyncio.get_event_loop/通道的 send 与 write 原语绑定进 _run 局部(帧写入走 def 期绑定的 write_encoded+_encode_json_plain,而非 send_sync 的调用期函数体;日志 sink 直接走绑定的 encode+write 原语,而非 send_sync)——在首次 binding 调用前重绑这些名字无法破坏合法调用。compile(wrapped, ..., dont_inherit=True) 阻止本模块的 from __future__ import annotations 把程序的类型注解字符串化。裸名 pythonBin 在 CURRENT 进程 PATH 上无法解析时现在于 LOAD 期失败('does not resolve on PATH',与空/NUL 检查一致):子进程以 env: {} spawn,回退到裸名会让 execvp 从平台默认 PATH 静默启动一个调用方从未要求的系统解释器——这是从旧的运行期 ENOENT worker-exit 到早期、响亮的配置错误的可见行为变更。bootstrap 在任何模型代码运行前把 SIGXCPU 重置为 SIG_DFL 并解除屏蔽:子进程继承宿主的处置与掩码,忽略或屏蔽 SIGXCPU 的宿主会让程序越过软 RLIMIT_CPU 一直跑到硬限的 SIGKILL——把确定的超限分类成 worker-exit 而非 timeout。(结算期 enforcer 已为在运行中 trap 或屏蔽信号的程序恢复 SIG_DFL;这里补上继承态的缺口。)浮点编码器的 Decimal(repr(value)).normalize() 运行在模块加载期构造的固定 _FLOAT_CONTEXT = Context(prec=28) 上(在任何模型代码之前):进程全局 decimal context 否则会让合法程序的 getcontext().prec = 2 静默舍入完成值的数字,或让 traps[Inexact] = True 使编码抛异常、把成功运行误判为 exception。一个回归用例同时改动两个旋钮并断言浮点完成值精确往返。 宿主在 toString/JSON.parse 之前把 fd-3 帧的原始长度限制在 64 MiB(FRAME_PARSE_CAP_BYTES):256 MiB 线上上限约束的是字节而非解码后的结构,接近它的紧凑宽帧解码后可能占用远超线上字节的宿主内存。maxLogBytes/maxValueBytes 在加载期被限制到该解析器上限,因此诚实子进程的帧总能放得下;模型构造的超限 binding 实参被丢弃(已登记在 README)。一个帧上限用例写入 65 MiB 的 A 加一个换行,断言得到携带 protocol-frame-exceeded 消息的 worker-exit(pre-join 计数拒绝无换行的单帧;第一帧检查在 join 之前拒绝带换行的帧,使峰值保持在线上字节的一份拷贝——通过回退到无条件计数验证 fail-before)。一个 _decode_json_plain 重绑用例断言 binding 回复仍能往返;一个 stdin-EOF 用例让程序读取 fd 0 并断言它立即看到 EOF(被销毁的写句柄),通过禁用销毁验证 fail-before。一个帧上限用例写入 65 MiB 的 A 加一个换行,断言得到携带 protocol-frame-exceeded 消息的 worker-exit(pre-join 计数拒绝无换行的单帧;第一帧检查在 join 之前拒绝带换行的帧,使峰值保持在线上字节的一份拷贝——通过回退到无条件计数验证 fail-before)。一个多帧用例让两个都在上限内、但合并缓冲越过上限的帧都存活(由第一帧检查而非字节计数决定);一个 sealing 阈值用例写入 64 MiB 的 4 KiB 原子无换行写,再加首个换行前的 12289 字节,断言该次运行为 worker-exit(sealing 是换行分支的 ELSE 半支,因此带换行的 chunk 总是抵达第一帧检查)。一个 pythonBin 用例把一个裸名解析到 PATH 首项为相对条目(.)的路径,断言使用绝对条目。 一个 exact-limit 用例(maxLogBytes: 64)写入一个 60 字符行(62 字节 JSON + 1 分隔符 = 63 = 预留后的账本)与一个 61 字符行(64 > 63),断言前者放行、后者截断为仅标记——钉住外层数组外壳预留的精确边界;一个配套用例断言 maxLogBytes: 61 在构造期被拒绝。一个语法标签用例断言解析期语法错误携带 File "<model>"(ast.parse 与 compile 及运行期 traceback 过滤使用同一来源标签)。一个 SIGXCPU 屏蔽用例屏蔽 SIGXCPU(pthread_sigmask)、越过软限并返回,断言得到 timeout(复查在重投递前解除屏蔽);一个 trap+mask 配套用例安装一个重新屏蔽的自定义 handler 并断言同样的 timeout(SIG_DFL 在 unblock 前恢复,因此挂起信号在内核内致死)。

Alternatives considered

保留引导写入处的 /* v8 ignore */,只修复顺序。 已否决:正是那处 ignore 让这个 TDZ 回归得以未被发现地进入代码库。移除它使该 catch 成为被度量的分支,因此按文件计的 100% 覆盖率现在能证明该失败路径确实被执行。

通过捕获更多绑定方法来修复 flush 竞态。 已否决:这正是已经失败过的做法。绑定一个可调用对象解决的是引用解析,而不是对该可调用对象所读取的可变状态的并发访问。只有对共享账本施加互斥才能消除该竞态。

用大小阈值来保护残余数据(只复制大帧)。 已否决:该分支在每次包含换行符的读取时运行一次,复制的规模受残余数据自身长度约束(始终是一个不完整行),而阈值会引入一个可调参数和第二条代码路径,却换不来任何可度量的节省。无条件地做大小恰当的复制更简单,且始终正确。

通过 seam 断言残余数据的内存效应。 已否决:被保留的分配透过 CodeRunResult 不可观测,因此黑盒测试无法区分已修复与未修复。转而抽取出 detachResidual,把底层存储的不变量变成一个确定性的单元测试。

仅用一个发后不理的 unref SIGKILL 定时器来回收同进程组存活者。 已否决:unref 的定时器不会让宿主保持存活,因此一个在宽限窗口内退出的宿主(一次性运行、一个配置子进程)永远不会触发 SIGKILL,存活者被 reparent 给 init,这是同一个"没有子进程存活得比 fiber 更久"的违规换了个形态,而且 teardown 的"await 每个子进程退出"的 JSDoc 会变为不实。在一个 ref 的轮询上 await 进程组的消亡,让宿主恰好保持存活足够长以完成回收,在常见的空进程组情形下代价为零。

用 process.kill(pid, 0) 抛出 ESRCH 来断言回收。 已否决:一个被 SIGKILL 的进程会作为僵尸进程滞留,直到它的父进程 wait() 它,而在一个 PID 1 不回收孤儿进程的容器里,signal-0 探测会持续成功,因此该断言会在跨环境时误报失败。一个停止推进的心跳文件检测的是"不再执行",而被回收的进程和僵尸进程都满足这一点。

用一个普通的 set_result 完成跨事件循环的 Future 并依赖 GIL。 已否决:GIL 序列化字节码,但并不使 asyncio.Future 跨事件循环安全:从一个并非其事件循环所属的线程完成一个 Future,不会调度它的回调,也不会唤醒该事件循环。在拥有该 Future 的事件循环上调用 call_soon_threadsafe 才是有文档记载的机制。

在结算之后让 SIGKILL 定时器继续处于装设状态(早先的同进程组修复)。 已否决:一个被留待在 leader 被回收后长达 graceMs 才触发的 unref 定时器,可能 kill(-pid) 一个被回收(recycled)的 pgid,击中一个无关的进程组;危险是那次成功执行的 kill,而 killGroup 吞掉 ESRCH 无法阻止它。在进程组被确认为空后清除该定时器,把复用窗口收窄到真正存在存活者的情形,此时进程组不为空以供复用。

只用继承而来的硬限制来约束 rlimit。 已否决:那会静默地抬高一个比请求更严格的继承软限制,放松了该约束本应保持的那种收束。用每一侧各自继承而来的界来约束该侧(随后把 soft 钉在 hard 之下),在 soft 和 hard 两者上都保持配置值与继承值中的最严格者。

按序列化开销对宿主侧的 capMessage 兜底做计费,与子进程的 _cap_message 相符。 已否决:这两处上限守护的是不同的东西。_cap_message 的输出会作为一个 JSON 字符串再次穿过 fd 3,因此帧上限约束的是它转义后的宽度——那里必须按序列化计费。capMessage 的输出直接进入 CodeRunResult.error.message,绝不会再次穿过一个受帧上限约束的通道,因此对它所保留内容的诚实度量是模型可见字符串的原始字节长度。一个诚实的子进程已经按序列化开销设过上限,而原始长度 ≤ 序列化开销,因此一条格式良好的消息会原样通过;一条伪造的、控制字符密集的消息可能序列化到其原始长度约 6 倍,但由于它不经过任何受上限约束的通道,按那个被抬高的传输宽度对它计费只会截断一条尺寸合法的诊断,而换不来任何收束上的收益。每一侧的 JSDoc 都记录了这一区分,并指向另一侧。

每来一个 data 分片就把散逸的管道输出推入一条条目。 已否决:logs 条目在下游会用 \n 拼接,因此一个传输分片边界会变成一个模型可见的换行符——一次被拆散在多次管道读取中的原生写入会带着无端的换行回读。按真正的换行符聚合(原始分片缓冲 + 在 0x0a 处切分)与子进程的按行粒度的 log 帧相符;账本仍然通过在残余数据将要越过预算时把它准入并截断,来约束一场不含换行符的洪泛。

逐帧强制 fd-3 帧上限(在计数器检查之前先切分)以避免一次批次边缘的误拒。 已否决:帧上限检查在任何 Buffer.concat 之前读取字节计数器,正是为了让一个敌意程序无法迫使宿主内存达到 64 MiB 帧上限的约 2 倍(计数器与那次拼接是所持全部内容的第二份副本)。先切分以对单个帧计费,会在拒绝一个超上限的帧之前就 Buffer.concat 它,从而重新引入那种翻倍——正是出于这个原因,有两个回归测试断言了先计数后拼接的顺序。逐帧顺序本会修复的那次批次边缘误拒(一个合法的接近上限的帧,其携带换行符的分片同时也带上了下一帧的起始字节,在一次管道读取中把计数器推过上限)只有当 maxLogBytes/maxValueBytes 被配置到距 64 MiB 帧上限一次管道读取以内时才可达——比 32/64 KiB 的默认值高出好几个数量级。在任何配置下都抵御敌意输入的内存安全边界,优先于一个仅在病态的接近上限配置下才可达的误拒;计数器的超额计数与这一权衡都记录在该检查处。

当合并预算被越过时,按残余数据到达顺序冲刷两个散逸管道。 已否决:stdout 与 stderr 是相互独立的 OS 流,它们的 data 事件本就彼此之间、以及与子进程自己的 fd-3 log 帧之间不确定地交错。seam 的 CodeRunResult.logs JSDoc 写着「in order」,周围的文字把它限定为一条流之内的程序发出顺序——跨并发流的顺序在这里本质上是尽力而为,因为没有任何宿主侧的冲刷顺序能够重建内核已经丢失的真实交错,因此在冲刷处保留一条残余数据的到达顺序换不来任何东西。一个固定的排空顺序与任何顺序一样有效。跟踪一个逐残余数据的到达计次以先排空较早的管道,会增加一个分支,它的两侧只在两个 OS 管道的相对时机上才触发,而 os.sched_yield 并不使之具有确定性,因此该分支无法在不写一个不稳定测试的情况下被覆盖——有成本却没有可观测的契约收益。

在运行时按地址空间对子进程日志账本计量,而不是在加载期拒绝该配置。 已否决:对每次子进程写入做一次精确的序列化开销检查,要么是一次完整的 encode——正是一次超大写入负担不起、而账本那处廉价预检本就为规避它而存在的那次分配——要么是一个逐字符的 Python 循环,它会烧掉 CPU 预算(一次 10 MB 的合法写入会在 cpuSeconds: 1 之下触发 SIGXCPU)。每一种运行时做法都是在热路径上拿内存界限换另一种资源界限。地址空间的突破是 maxLogBytes/addressSpaceMb 组合的属性,而不是任何一次具体写入的属性,因此在加载期一次性拒绝这个不兼容的组合,能在没有任何逐次写入代价的情况下消除整类问题,并保留 _LogStream 原有的按字符计数的缓冲,它一旦预算放进地址空间就是内存安全的。

Consequences

解释器误配置会在服务发布前失败,每次运行都使用加载期选定的可执行文件,并且子进程只接收 TMPDIR,从而消除 macOS 启动噪声而不暴露宿主凭证。私有提供方仍不进入已发布 profile,同时由一个 keyless 真实 Loader 快照固定其源码检出 PTC 组合。

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 可观测的差异取决于不确定的跨管道到达时机),flush_line 重排序(它降低后的峰值仍落在 12× 门本就放行的范围内,因此没有配置会有不同表现)、节流 binding 回复(32.0 MiB → 0.0 MiB 的峰值降幅位于宿主 fd-3 可写缓冲内部、透过 seam 不可度量),在快照之前丢弃迟到的 binding 解析(它的三条断言在修复前同样成立,因此不是一处缺代码即变红的用例),完成值的 TOCTOU 预编码(它的并发变异竞态无法透过 seam 确定性构造,而 daemon 变异回归的唯一断言是概率性的),stray UTF-8 预算冲刷的扣留(落在多字节边界上的预算冲刷无法透过 seam 调度——v8-ignored),以及结算后到达的迟到拒绝的 settled 先查(结算之后才到达的拒绝无法从 seam 确定性构造),以及日志分片封存(其 25 M 规模的 OOM 无法在 CI 中确定性构造),以及 unknown-binding 预览上限(其完整 target 的 JSON.stringify 峰值是回复路径内的一次瞬时分配,无法透过 seam 度量)——因此其余各处未来若发生回归都会变红。