The review's remaining warning: an explicit flush of an unterminated line
(print(..., end='', flush=True)) pushed a full log frame, so the following
print() landed in a second entry and logs.join('\n') rendered 'a\nb' for what
the program printed as one line — a model-visible output defect. The flush
frame now carries an flag (LogMessage gains the optional field on both
sides and in the mirror test), the host holds it and appends the next log frame
to the same entry, and finish() admits the residual if the run ends with it
still open. The settlement note registers the decimal-context fix from the
previous commit.
The review's premise that the group-emptied arm could not be pinned was
incorrect; the same-group reap case already exercises it. This adds the missing
seam-observable case: dispose() while a setsid orphan holds the pipes and the
run is unresolved — settle kills the child, the group empties (the orphan is in
its own session), and the poll finalizes promptly instead of waiting out the
60 s grace. The v8 ignore on that arm is removed.
The review's remaining coverage gap: the group-emptied arm of pollGroup depends
on the close-driven settle winning the race against the grace SIGKILL, a timing
interleaving no seam-observable test pins deterministically (the same-group
cases assert the settle and the reap, not this exact interleaving) — the arm
now carries a v8 ignore with that reason. The load-check comment and the
FRAME_ENVELOPE_BYTES JSDoc say rejects-as-worker-exit instead of drops.
The forged-second-boot-ack regression makes the re-entry guard covered, so its
v8 ignore is removed. Doc drift: the python README and run() JSDoc state the
resolve-with-value/resolve-with-error contract without inversion; the README
Known Limitations gains the setsid-escaped-orphan entry (the settlement note
referenced it); the settlement note drops the stale drops/discard phrasing and
the two 256 MiB references; the fd-3 protocol zh note no longer claims the
codec is undelivered; the code-runtime seam README (en + zh) says both
backends ship. Pairings re-recorded.
The resolvePythonBin directory branch now has a regression: a PATH whose first
entry is an executable DIRECTORY named python3 is skipped for a later real
interpreter (fail-before: without the isFile guard the directory would be
chosen and spawn would fail). The boot-ack gate's forged-second-ack re-entry
guard and its write-failure branch are covered by v8 ignore comments (the
honest child sends exactly one ack; the write failure needs the child to exit
between ack and write).
The review's two behavior items: the run frame was written back-to-back with
the boot frame (the seam contract puts run after boot-ack, which confirms the
namespaces were accepted); it now goes out from the boot-ack handler, so a
boot failure cannot race the run frame. resolvePythonBin now requires the
candidate to be a regular file — a directory passes X_OK and would otherwise
shadow a later real interpreter. Doc spots: the load-time overflow message
says worker-exit (not stranding to the wall clock), the run JSDoc spells out
the resolve-with-error contract, the PATH-stub test removes the stale v8
ignore, and the README's binding-value bullet names serialization cost.
The review's three stale-comment items in index.ts: the orphan JSDoc above
FRAME_PARSE_CAP_BYTES (left over from the deleted receive ceiling), the
pre-join comment's change narration and its reference to a no-longer-existing
higher ceiling, and the first-frame comment's mention of a per-line cap check
that no longer exists. Test comments for the pythonBin and sealing-threshold
cases are weakened to their observable claims (both orders reject an over-cap
frame; the pythonBin case pins the contract, not a worker-exit distinction).
The review's doc drift items: the orphan receive-ceiling JSDoc, the frame-ceiling
references in index.ts/bootstrap.py/tests, and the README's 'dropped, stranding
to the wall clock' phrasing (the run now settles as a worker-exit) are all
updated to the 64 MiB FRAME_PARSE_CAP_BYTES semantics; the README notes the
>64 MiB binding-argument residual as a worker-exit trip of the same cap. A
regression case resolves a basename pythonBin against a PATH whose first entry
is relative ('.') and asserts the absolute entry is used.
The review's remaining code items:
- resolvePythonBin now skips RELATIVE PATH segments (a bare 'bin' or '.'): the
returned candidate must be absolute, because spawn() resolves a relative
pythonBin against the host CWD, outside the seam contract.
- A deterministic-ish regression pins the sealing-threshold corner: 64 MiB of
4 KiB (<= PIPE_BUF, atomic) newline-free writes plus 12289 more A's before
the first newline make the first frame exceed FRAME_PARSE_CAP_BYTES; the
newline-bearing chunk reaches the first-frame check (sealing is the ELSE
half of the newline branch), so the run reports worker-exit with the
protocol-frame-exceeded message.
The review's sealing corner: the fragment-count seal ran before the newline
branch and did not exclude a newline-bearing chunk, so the 1024th chunk (the
first to carry a newline) was concatenated into a sealed block, pendingChunks
was emptied, sawNewline stayed false, and the first-frame check was skipped for
a join that then contained the newline. Sealing now runs as the ELSE half of
the newline branch, so a newline-bearing chunk always reaches the join and its
first-frame check, and the invariant 'sealed blocks hold newline-free prefixes
only' is true — which is what makes the removed per-line check genuinely dead.
The pre-join counter (single unframed line) and the first-frame check
(newline-bearing chunk) reject any frame past FRAME_PARSE_CAP_BYTES before the
join, so every line reaching this loop is within the cap by construction — the
per-line check was dead code and its continue branch could never fire, failing
the per-file 100% coverage gate.
The pre-join check charged the whole unframed buffer, which legitimately holds
several frames each within FRAME_PARSE_CAP_BYTES: a first frame of exactly the
cap followed by a second frame crossed the counter and was misreported as a
worker-exit. The pre-join rejection now fires only while the held bytes are a
single unframed line (this chunk carries no newline); once a newline arrives,
a FIRST-FRAME check measures the bytes up to the first newline across the held
chunks (including sealed blocks) and rejects only that frame before the join —
keeping the peak at one copy of its wire bytes — while later frames in the
same buffer are handled by the restored per-line check. Regression cases: a
72 MiB newline-free buffer is rejected pre-join (fail-before: joining would
have doubled it); two within-cap frames whose combined buffer crosses the cap
both survive (fail-before: the unconditional counter check turns it red).
The unframed-buffer counter guard runs before every join and guarantees each
line is within FRAME_PARSE_CAP_BYTES, so the line-loop cap check was dead code
(its continue branch could never fire, failing the per-file 100% coverage gate
on index.ts). Removed with a comment explaining the invariant.
The review's remaining critical: the fd-3 data handler checked the unframed
counter against the 256 MiB wire ceiling, so a single 64-256 MiB frame was
fully Buffer.concat-joined (a second copy) and only then dropped in the line
loop — the peak-memory doubling the pre-join check exists to prevent, for a
frame the parser is guaranteed to discard. The counter is now checked against
FRAME_PARSE_CAP_BYTES before the join; the regression case asserts a worker-exit
with 'protocol frame exceeded' (fail-before: reverting to the ceiling turns it
green, proving the join path). FRAME_CEILING_BYTES is removed.
The rejection-cap fix now has its regression: a completion value whose class
name is 70 MiB of Ns asserts invalid-output, not worker-exit (fail-before:
uncapping the diagnostic turns it red).
The settlement note (en + zh) updates the remaining stale bound text, and the
fd-3 protocol note (en + zh) no longer claims protocol-only exports or a
missing Python codec. Pairings re-recorded.
The review's remaining items:
- _done_with_value's rejection branch now caps the _check_done_value diagnostic
through _cap_message (a reason embedding a hostile class name could otherwise
push the done frame past the host's 64 MiB parse cap, misreporting an
invalid-output run as a worker-exit).
- The settlement note (en + zh) updates three stale facts (load bound is now
parse-cap minus envelope at 67108800; the sink goes directly through the
bound primitives); the fd-3 protocol note (en + zh) no longer claims the
package ships protocol without the runtime; FRAME_ENVELOPE_BYTES' JSDoc and
_cap_message's docstring follow the new bound.
Pairings re-recorded.
The review found the 64 MiB parse cap contradicted the load-time budget bound:
maxLogBytes/maxValueBytes could be configured up to ceiling - envelope (~256 MiB),
but the receive path silently dropped any frame past the 64 MiB parser cap, so an
honest child's budget-internal done frame under such a config would be discarded
and the run stranded to the wall clock. The load bound is now parse-cap -
envelope, so a configured budget always fits through the parser; the boundary
test moves to 64 MiB - 64. The >64 MiB model-constructed binding-argument drop
is registered as an accepted residual in the README (en + zh).
Addresses the review's remaining two items:
- FRAME_PARSE_CAP_BYTES (64 MiB) drops an fd-3 frame whose raw length exceeds
it BEFORE toString/JSON.parse: the 256 MiB wire ceiling bounds the bytes, not
the decoded structure, and a compact wide frame near that ceiling could decode
to far more host memory. A regression test writes a 65 MiB log frame plus a
normal one and asserts the oversized frame is dropped while the trailing frame
still lands in logs (fail-before: without the cap the oversized text is parsed
and admitted, truncating the ledger so the trailing frame is dropped). The
forged-oversized lower-bound test's frame is reduced to stay under the cap
while still exercising the truncation path.
- The log sink writes through the def-time bound encode+write primitives (not
send_sync, whose body resolves _encode_json_plain and self.write_encoded at
call time), so a rebind cannot break a log frame.
The review's three remaining items:
- A regression test rebinds __main__.ProtocolChannel.read_frame_async and asserts
a binding reply still round-trips (the pump's reader is a bound method
captured by _run before the program runs).
- The settlement note (en + zh) records that send_done's frame-shape check uses
_run's bound _str/_isinstance.
- The staging-removal comment no longer claims teardown retries tracked state:
teardown deliberately does not sweep staging, so a removal failure is the one
case the gone-by-settlement contract degrades on.
Pairing re-recorded.
The boot-write-failure fake child carries no stdin at runtime, so the optional
call is the documented guard; the static type (ChildProcessWithoutNullStreams)
says stdin is non-null, which trips the no-unnecessary-condition lint.
The boot-write-failure path's fake child carries no stdin handle, so the
unconditional destroy threw inside the spawn error handler and mislabeled the
worker-exit. Use the optional-call form; the no-stdin branch is exercised by
that same test.
Addresses the review's two remaining items:
- 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, so a setsid-escaped descendant
inheriting fd 0 could keep the host process from exiting even after the
closeDeadline forced settlement. The child (and any descendant) reads EOF on
fd 0 and no host handle survives.
- read_frame/read_frame_async bind their decode primitives (_decode_json_plain,
os.read, _READ_CHUNK_BYTES, bytes) as def-time default arguments, and
_decode_json_plain itself captures json.loads, its two regexes, and len the
same way, so a __main__ rebind cannot kill the reply pump and strand every
pending Future to the wall clock. _decode_json_plain and its regexes moved
before the ProtocolChannel class so the defaults resolve at class-definition
time. A regression test rebinds _decode_json_plain and asserts a binding reply
still round-trips.
Note (en + zh) registers both mechanisms; pairings re-recorded.
Addresses the review's two carried warnings and the comment suggestion:
- Once the ledger truncates, every arm that marks it (admit()'s two ceilings and
the child-marker frame arm) now clears both stray pipes' buffered output
wholesale, so the end-path flushStray sees empty buffers instead of
concat+decoding doomed data near a 256 MiB maxLogBytes; captureStray's newline
loop re-checks the flag before re-retaining the residual.
- The child runs with -u (unbuffered), so sys.__stdout__/sys.__stderr__ writes
are visible to stray capture immediately; the settlement flush still drains
the original std streams before the done frame as a guard. A regression test
writes through sys.__stdout__/sys.__stderr__ without an explicit flush and
asserts both bytes land in logs. C-ext stdio remains an accepted residual,
recorded in the README Known Limitations (en + zh).
- The ledger-comment arithmetic now states the exact boundary (serializes to
exactly maxLogBytes; without the reserved byte it would be maxLogBytes + 1)
in both host and child.
Note (en + zh) registers the stray-clear and -u/settlement-drain mechanisms and
the new test; pairings re-recorded; corpus passes 1029.
The review flagged the change-narrative wording 'degrades to the pre-existing
behavior' (prohibited by docs/AGENTS.md) in four spots — README en/zh, the
readProcessStart JSDoc, and the test comment — and the incomplete :77 residual
sentence ('can still' with no verb complement). Reword the four to a direct
statement of current behavior (killGroup signals the pgid without the identity
re-check on macOS), complete the residual sentence with the actual consequence,
and re-record both pairings. Corpus-wide verify-translation-pairing passes 1029.
The review found the 62 floor off by two (the marker's fixed prefix is 51
characters counting both square brackets, so marker(62) serializes to 63) and
the constructor error over-claiming a bound the marker-as-envelope design does
not deliver. Fixes:
- MIN_LOG_BYTES is 64 (marker-only serialization fits with one byte of room);
the JSDoc arithmetic counts the brackets; the rejection test pins 63; the
forged-frame test uses 11 NULs (69 escaped) at 64.
- The constructor error now states the marker-only guarantee, and the README
Known Limitations (en + zh) records the real bound: a truncated run with
admitted entries serializes its logs to maxLogBytes + marker + envelope.
- The SIGXCPU-mask tests burn with time.process_time() instead of wall-clock
perf_counter, so a contended CI runner cannot under-burn the budget.
- The settlement note (en + zh) records the 64 floor and the marker envelope
bound, including the zh pre-encode section that the earlier pass missed.
- The README constructor-rejection list names the maxLogBytes floor.
Pairings re-recorded; corpus-wide verify-translation-pairing passes 1004.
Addresses the review's two code warnings and one suggestion:
- die_if_cpu_exhausted now restores SIG_DFL BEFORE unblocking SIGXCPU: a program
that installed a custom handler AND masked the signal would otherwise have
that pending handler run at the unblock (in model code, re-masking or raising)
and escape the re-raise; with SIG_DFL first the pending signal kills inside
the kernel with no bytecode window. A trap+mask combined regression test pins
it (the mask-only case was already covered).
- The constructor rejects budgets too small to honor: maxLogBytes must fit the
truncation marker plus the serialized outer-array envelope (floor 64), and
maxValueBytes must at least represent the smallest JSON completion (floor 4,
matching the worker backend). The exact-limit test moves to the 64 floor and
a rejection test pins the floors.
- The pthread_sigmask None-guard comment cites the real rationale (defensive
against stripped CPython builds; win32 is refused at construction), not the
unreachable Windows path.
Addresses the review's two remaining code warnings and the three suggestions:
- Log ledgers (host and child) start one byte below the budget, reserving the
serialized outer-array envelope (two brackets and n-1 commas over n entries'
separators); the exact-zero test moves to maxLogBytes 104 and a new exact-limit
case pins that maxLogBytes 5 admits ['a'] (5 bytes) while 4 truncates to the
marker alone.
- die_if_cpu_exhausted unblocks SIGXCPU (pthread_sigmask SIG_UNBLOCK, captured at
import, None-guarded for Windows) before re-delivering it, so a program that
masks SIGXCPU, burns past the soft limit, and returns is still classified as a
timeout; a regression test pins the masked path.
- ast.parse passes filename="<model>" so parse-time syntax diagnostics carry the
same source label as compile and runtime tracebacks; the syntax-error test
asserts the label.
- The NUL-escape test comments use the true six-byte JSON escape \u0000 instead
of the caret notation; the README Known Limitations (en + zh) records that
PID-reuse protection is inert on macOS; a combined-rebind regression test pins
BaseException plus the traceback reporter rebinding together.
Addresses the bot's v16 review on the settlement-path code:
- critical: _LogStream._pending now seals the fragment list past a chunk cap
(like the host captureStray seal), so a newline-free single-character drip no
longer accumulates one list slot per write and OOMs on its own accounting.
- _clamped lowers a soft==hard result by one unit (when hard >= 2) so a
dual-limit ulimit -t leaves SIGXCPU a window to fire and a definite CPU
overrun is reported as a timeout, not a 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 import-time captured os.write, so a settled exception verdict is never
downgraded to worker-exit.
- drainReplies clears the consumed replyQueue slot so a wide written payload is
released immediately, bounding host memory to the current backlog under
sustained fd-3 backpressure.
Tests added for each (fragment cap drip, dual-limit CPU overrun, transitive-name
rebind done frame).
Each shift() re-slices the remaining array, so draining a large gather of
wide bindings awaiting fd 3's drain was O(n^2). Reading by a head index into
the array keeps the drain linear; the finally still discards everything.
Addresses the follow-up review findings on the settlement-path fixes:
- send_done now routes both the pre-encoded VALUE frame and the dict ERROR
frame through a bound _encode_json_plain + bound write_encoded, never through
channel.send_sync (whose body re-resolves self.write_encoded and the module
_encode_json_plain at call time) — a program rebinding ProtocolChannel.
write_encoded or __main__._encode_json_plain no longer skips the done frame.
- flushStray retention re-accrues the withheld multibyte tail from a FRESH
utf8 state (previously metering the carried lead against the post-flush
expected>0 state charged it as an illegal continuation), and skips admitting
when the whole residual drained into the retained tail so no bogus empty
entry is pushed.
Corrections to the settlement-path review fixes:
- send_done was invoking channel.send_sync / channel.write_encoded via a late
method look-up, which a program running as __main__ could rebind through
__main__.ProtocolChannel.send_sync before the failure path ran — a rebound
send that raises then skipped the done frame and downgraded a settled
exception to worker-exit. Bind both channel methods into locals before the
program runs, mirroring the pre-existing binding of flush_out/flush_err/
safe_model_traceback.
- Restructure flushStray so the mid-sequence budget-flush retention arm is a
self-contained v8-ignored branch and the covered default path decodes the
full residual (not schedulable-through-the-seam boundary).
Pace-free completion framing, stray UTF-8 flush, and late-rejection guards:
- Pre-encode the completion value at its validation point so send_done never
re-walks a live value a mutating daemon thread could change (TOCTOU); a
mutation-induced encode throw is then classified as 'exception', not a
host-side worker-exit.
- Budget-triggered stray flush retains an incomplete multibyte UTF-8 tail
(<=3 bytes) as residual instead of decoding a legal, split character to
U+FFFD in an admitted entry; the end/closeDeadline paths still full-decode.
- Check 'settled' before formatting a late binding rejection's message, so a
hostile message getter cannot stall or exhaust a run that already settled.
- Document _check_done_value's first-to-trip ruling in its docstring.
- Rewrite ProtocolChannel.send_sync around a shared write_encoded that the
done frame's pre-encoded string path uses.
The drain loop's `if (settled) break` needs the run to settle in the window
between two queued frames. A file probe on the concurrent-replies case shows the
queue does reach depth 11, but the wall clock never lands inside that window, so
the branch is not schedulable from a test; a case written to force it passed
without ever executing the line, so it is removed rather than left as coverage it
does not provide. The branch carries a v8 ignore naming what is unreachable.
`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 against 0.0 MiB once paced.
Replies now go through a queue that encodes and writes one frame at a time,
awaiting `drain` when the pipe is full. 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.
This was previously deferred on the grounds that serializing would narrow the
seam's concurrency contract. That reasoning was wrong: 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 still run concurrently. Only the
host's peak memory and the flush timing change. The README entry recording the
deferral is removed and the Agent Note records the mechanism instead.
`sendReply` already refuses to write after the run settled, but only after
`snapshotJsonValue` walked and copied the resolution. Binding resolution carries
no seam-level byte cap, so a binding resolving a wide value after `maxWallMs`,
an abort, or dispose settled the run spent host heap building a frame that was
then discarded. The check moves ahead of the snapshot.
Also in this change:
- `readProcessStart` moved after `messageOf`. Inserting it between `messageOf`'s
JSDoc and its body left that function undocumented and the orphaned block
reading as a second doc for the reader; `verify-export-jsdoc` does not catch it
because `messageOf` is not exported.
- The README pair adds the disposed-runtime rejection to `run()`'s public
contract, which `src/index.ts` has enforced all along.
- Known Limitations records three deferred constraints that until now existed
only in review discussion: the combined log-and-value peak the load gate does
not model, the host-side per-member expansion of a wide binding reply (owned by
`packages/core/session`, and shared with the worker-thread backend), and the
absence of fd-3 backpressure for concurrent replies.
- The Agent Note's same-group section records the teardown identity guard and its
two rulings, including why an ABSENT start-time reading proceeds rather than
withholding the signal, and that reading it as a mismatch is what turned the
three same-group heartbeat cases red on Linux.
The directive carried its whole justification inline at 203 characters, past the
140 the @stylistic/max-len rule allows (imports and template-literal messages
are exempt; a line comment is not). The reasoning moves to the lines above and
the directive keeps a short pointer, since a v8 ignore must stay on one line.
The PID-reuse guard has two arms no single OS can execute: the non-Linux early
return in readProcessStart (the Linux coverage lane always takes the read path)
and the refusal arm, which needs a real pid recycled into a new group leader
between spawn and teardown -- no test can schedule that. The coverage lane
reported 99.53% statements / 99.14% branches on src/index.ts for exactly these
two.
Both carry a v8 ignore naming what cannot be reached and why, the convention
this file and subprocess-local already use for platform defenses. The reader
itself stays covered by the process-identity test rather than being exempted
wholesale.
The PID-reuse guard refused to signal whenever the current reading differed
from the one taken at spawn, including when it was ABSENT. On Linux a reaped
leader has no /proc/<pid>/stat, so every teardown after the leader exited
skipped SIGTERM/SIGKILL while the group it led still held survivors -- the
exact case the process-group teardown exists to reap. Three same-group survivor
tests went red on the coverage lane; they pass on Darwin because the reader
always returns undefined there, leaving the guard inert.
Only a present-and-different reading now blocks the signal. Verified on the
self-hosted Linux box: a reaped leader with live survivors allows the signal, a
pid whose start time differs still blocks it, and a live matching process is
signalled.
Adding a published Python backend and reordering `flush_line` left several
owning documents stating things that are no longer true.
`src/invariant.ts` justified its empty installer with "ships only the fd-3
wire-protocol codec", which the subprocess execution path contradicts. The
reason now states the actual one: every relation this backend maintains lives
in the CPython child or on the fd-3 wire, so no same-process event sequence is
observable from a listener -- the same shape the sibling worker-thread backend
uses.
The seam's `PORTABLE_RESERVED_WORDS` and `language` JSDoc, the code-runtime
README pair, and docs/subsystems/code-runtime both said only TypeScript has a
published backend. Corrected in all four, with the generated cordis catalog
regenerated for the `language` change.
The note attributed the 12x multiple to the settlement flush holding three
copies. That stopped being true when `flush_line` was reordered to drop the
pending chunks before its push: the binding worst case is the newline path's
single near-budget write. Corrected in the note (both sides) and in the test
comment that repeated it.
The note's Testing section now registers the cases this stack added, and the
Chinese side receives the O(depth) entry it never got plus the new ones -- it
had drifted from the English.
`INTERPRETER_BASELINE_BYTES` argued 64 MiB from a RESIDENT set while RLIMIT_AS
bounds address space. It now cites the bootstrap's own measurement (30.23 MiB
of mappings for `python3 -I`), making 64 MiB roughly twice the measured
baseline.
Also: a hardcoded `(:232-235)` comment reference becomes a reference by name,
a "which now walks in O(depth) too" change narrative becomes a current-state
statement, and a stray double blank line is removed.
Four independent corrections in the run lifecycle.
`killGroup` signalled `-child.pid` with a raw `process.kill`. Node keeps the
numeric `child.pid` after the leader is reaped and only clears its internal
handle, so `child.kill()` refuses while the raw call does not; `close` can
trail `exit` by seconds when a pipe-holding descendant keeps the streams open.
A recycled pgid could therefore receive this run's SIGTERM and armed SIGKILL.
`groupEmpty()` does not cover it: it reports whether the group has members, not
whether they are ours, and it first runs after the signal. The leader's start
time is now read at spawn and re-checked before each signal, matching the
position packages/subprocess/subprocess-local already states
("ProcessIdentity ... preventing teardown escalation after PID reuse"). Kept
local rather than depending on that package, which would add an architectural
edge. Linux reads /proc; Darwin has no /proc, so the reader reports undefined
and the guard degrades to the previous behavior instead of forking `ps` on a
teardown path.
`_push_bounded_prefix` built `(*self._pending, extra)`, copying every pending
reference into a same-size tuple before the bounded loop. For a
single-character drip that is a second pointer array as large as the list:
measured +80 MiB of tuple over a 40 MiB list for 5.2M chunks, the allocation
the bounded prefix exists to avoid. It now iterates the list in place and
handles `extra` in the loop's `else`; 4000 randomized inputs produce byte-identical
prefixes.
The settlement `flush_out()`/`flush_err()` ran outside any guard while `done`
was already decided, so a flush raising under memory pressure skipped
`send_done` and downgraded a child-classified `exception` into a host-side
`worker-exit`. Both are now wrapped, swallowing only the log tail.
The boot re-check's `if effective_soft != RLIM_INFINITY` was dead: `_clamped`
is asked for a finite `addr_bytes` on both sides and each branch returns that
value or a `min` with an inherited bound, so RLIM_INFINITY is unreachable. The
guard could only ever have skipped the re-check it claimed to protect.
The load gate bounds maxLogBytes and maxValueBytes independently against the
address space, but the child framed the completion value (materializing its
escaped form to meter it, then encoding the frame) while a newline-free log tail
still sat unflushed in _pending. Those two peaks added, so two budgets each
admitted alone could together breach RLIMIT_AS and die as worker-exit instead of
settling. The success path now flushes both log streams before _done_with_value
runs; the trailing flush stays for the exception path and is an idempotent no-op
after a successful settle. A combined-peak regression test (32 MiB each against
512 MiB) asserts the over-budget value reports output-limit rather than OOMing.
Also corrects the worst-case-multiple JSDoc and Agent Note: after 1088d6f03d
made flush_line drop pending before its push, the settlement-flush path holds
two copies, not three, so the newline path is the sole 12x worst case. The
reorder is recorded as a called-out untested fix (the 12x gate already admits
only configs safe under both flush orders).
The load-time output-budget/addressSpaceMb gate used a worst-case multiple of 8,
assuming two simultaneous ~4x astral copies (the built string and its encode).
Three are live at the peak: on the newline path a single write holds the caller's
text argument, the line slice handed to push, and push's encode copy; the
settlement flush_line path held the pending chunks, their join, and that encode
copy. A budget admitted at 8x (e.g. maxLogBytes 48 MiB against addressSpaceMb 512)
could still OOM the child. The multiple is now 12, the strict `>` is `>=` so a
budget whose peak exactly equals the room left after the interpreter baseline is
rejected (that peak plus the baseline is the whole address space), and flush_line
drops the pending chunks before its push to match the newline path's
join-clear-push order. The child re-check mirror and both note sides move in step;
config-catalog is regenerated from the updated field JSDoc.
The output-budget/address-space gate's 8x multiple had no room for the
interpreter's own footprint, so a budget sized right at addressSpaceMb/8 was
admitted while its worst-case peak plus the interpreter overran RLIMIT_AS
(e.g. 15 MiB maxLogBytes against 128 MiB). Reserve a fixed
INTERPRETER_BASELINE_BYTES (64 MiB) before the multiple claims the rest, so each
budget times 8 must fit the room LEFT after the baseline.
The host gate validates against the CONFIGURED addressSpaceMb, but a launch
environment can inherit a stricter RLIMIT_AS (a ulimit -v wrapper below
addressSpaceMb) that _clamped lowers the effective limit to, leaving the budgets
sized for a ceiling the child never gets. bootstrap.py now 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 rather than letting a
near-budget output OOM mid-run.
Add regression tests for both (the load gate against a 256 MiB address space
covering both budgets, and a ulimit -v wrapper for the inherited-limit re-check);
register the tail-copy test in the note Testing section; sync the zh pair. Merges
origin/feat/code-runtime-python-protocol to resolve the DIRTY base.
The load-time addressSpaceMb gate used a 1/8 fraction derived for ASCII, but the
child ledgers trigger on character count against a serialized-byte budget: an
astral character is one character yet ~4 bytes stored and ~4 encoded, live at
once, so the true worst-case peak is ~8x the budget, not ~2x. Replace the
fraction with an explicit OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (8)
and a strict `>`, and gate maxValueBytes the same way as maxLogBytes — the value
path builds and encodes a near-budget completion under the same RLIMIT_AS, so
the incompatible pair was previously admitted there too.
Slice the newline branch's unterminated tail to a budget-sized prefix: it
buffered the whole text[pos:] before the flush trigger could bound it, so an
early newline plus a huge tail made a second full copy of the model's string —
an RLIMIT_AS death the config gate cannot cover since the tail can far exceed
maxLogBytes.
Disclose the cross-field constraint in the maxLogBytes/maxValueBytes/addressSpaceMb
JSDoc (regenerating config-catalog); refresh the note's stale
Buffer.byteLength(JSON.stringify) reference; reconcile the arrival-order rebuttal
with the seam's "in order" logs JSDoc (within-stream, cross-stream best-effort).
Extend the load-rejection test to both budgets and add a tail-copy regression;
sync the zh pair.
The child log ledger encodes an admitted entry to UTF-8 once to charge its
serialized cost, so a maxLogBytes approaching addressSpaceMb lets a legitimate
near-budget log entry breach RLIMIT_AS and die as worker-exit instead of
truncating. Two runtime fixes were tried and both traded one resource bound for
another: an exact serialized-cost check is either a full encode (the allocation
being avoided) or a per-character Python loop that burns the CPU budget (a 10 MB
write hits SIGXCPU under cpuSeconds:1). The breach is a property of the
maxLogBytes/addressSpaceMb pair, not any write, so reject the incompatible pair
at load — maxLogBytes must stay within one eighth of the addressSpaceMb byte
count — and revert _LogStream to its original character-count buffering, which
is memory-safe once the budget fits the address space. The check runs on every
platform since the incompatibility is a config-value property, not a runtime one.
Replace the child-flood regression tests (which asserted the reverted runtime
behavior) with a load-rejection test. The host-side accrueStrayCost UTF-8
per-lead validation and its tests are unaffected. Update the note and zh pair.
accrueStrayCost accepted any 0x80-0xBF continuation, so a CESU-8 surrogate
(ED A0 80) or overlong (E0 80 80) — structurally well-formed but illegal, and
as cheap to flood as 0xFF — was charged its structural width 3 while
toString('utf8') renders each byte as its own U+FFFD (cost 9). Validate each
lead's first-continuation range (WHATWG E0/ED/F0/F4 bounds) and charge 3 per
byte of any sequence outside it, folding a broken prefix to one U+FFFD.
The child _LogStream newline path had the same char-vs-serialized gap the
newline-free trigger had: its per-line fit checks (first reconstructed line and
each subsequent line) compared character count against the serialized-byte
budget, so a control-char line passed and _logs.push encoded it whole, breaching
RLIMIT_AS. Route every check through _fragment_cost_upto, which sums per-char
costs from _json_char_cost over a start/end sub-range without slicing or
encoding and stops at the budget.
Decline arrival-order stray flushing: the two pipes' data events interleave
nondeterministically and logs carries no cross-pipe ordering guarantee, so a
fixed drain order is as valid as any and an arrival-tick branch could not be
covered without a flaky test.
Add CESU-8/overlong, newline-path-flood, and all-lead-class reassembly
regression tests; fix the note's now-inaccurate CESU/illegal-byte claims and a
fixture byte-count comment; sync the zh pair.
The host stray-capture cost function charged illegal UTF-8 bytes (0x80-0xC1,
0xF5-0xFF, and orphaned multibyte leads) the raw 1, but toString('utf8')
renders each as U+FFFD (3 serialized bytes). A b"\xff" flood was undercounted
threefold, so the residual grew to a full budget's worth of raw bytes before
flushing and, near a large maxLogBytes, expanded toward a ~1 GiB peak in the
flush's concat plus toString. Replace serializedBufferCost with accrueStrayCost,
a cross-chunk UTF-8 walker that charges each byte its decoded serialized width;
carry its sequence state on each StrayBuffer.
The child _LogStream had the same-family bug: its early-flush trigger compared
_pending_chars (character count) against remaining (a serialized-byte budget),
so a 30M-NUL newline-free flood stayed under a 50 MB char trigger yet encoded to
~180 MB at settlement, breaching RLIMIT_AS as worker-exit. Track _pending_cost
via the _JSON_BYTE_COST table and trigger on it; keep _pending_chars for the
char-based slice bounds.
Correct the note's surrogate claim (only the string-walking jsonStringCostUpTo
charges a lone surrogate six bytes; the byte walker never sees one). Shrink the
post-truncation fixture below PIPE_BUF for a deterministic single callback. List
the shared stdout/stderr budget as a third honest fail-before exception
(cross-pipe arrival timing is nondeterministic). Add illegal-UTF-8,
broken-multibyte, and child-log-flood regression tests; sync the zh pair.
stdout and stderr each checked their pending serialized cost against the full
logBudget independently, so both could retain nearly a budget's worth of
newline-free residual at once — double the intended peak, up to ~512 MiB near
the ceiling. The flush threshold now reads the COMBINED cost of both pipes and
flushes both when it crosses, since they share one ledger.
Remove the post-truncation admit() v8-ignore: captureStray's per-line loop
makes that branch deterministically reachable within one data callback (a chunk
whose first newline-terminated line exhausts the budget hits it on the second),
so it is measured by a new regression test rather than ignored.
Refresh two stray-output test comments that still named the removed
StringDecoder; the raw-chunk buffer reassembles a split multibyte sequence by
concatenating before it decodes, and the end flush renders a stranded partial
as U+FFFD via toString('utf8').
Three follow-ups the review caught in the stray-capture rewrite, plus a cost
undercount shared with the log ledger.
Seal the stray fragment list into blocks past MAX_PENDING_CHUNKS, mirroring the
fd-3 reader: a program pacing single-byte os.write(1, ...) calls otherwise
accumulates one live Buffer per write, and the per-object overhead no byte
count sees exhausts the host heap far below the budget.
Flush the residual by its running SERIALIZED cost (serializedBufferCost, a
per-byte lower bound) rather than raw byte count: a control-char-dense
newline-free flood serializes several-fold, so a raw-byte threshold let it grow
to a full budget's worth of raw bytes — up to ~6x what the ledger admits —
before flushStray concat/decoded the whole ~256 MiB residual at once.
Charge a lone surrogate its full six escaped bytes (\uXXXX under ES2019
well-formed JSON.stringify) in both jsonStringCostUpTo and serializedBufferCost,
not the three bytes Buffer.byteLength reports for U+FFFD: a forged log frame
flooding \ud800 escapes was undercharged by half and admitted ~2x maxLogBytes.
Key the sync-spawn leak assertion off the exact bootstrap path from the mocked
spawn's argv, immune to a sibling worker's concurrent staging. Refresh the
stale load-check comment that named the replaced JSON.stringify mechanism.
Add lone-surrogate, stray-sealing, and companion regression tests (per-file
100% coverage); update the Agent Note and zh pair.