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.