Commit Graph
48 Commits
Author SHA1 Message Date
Chinesezjc 2a9a917853 fix(code-runtime-python): drop a late binding resolution before snapshotting it
`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.
2026-08-31 14:31:48 +08:00
Chinesezjc 0a46bb3414 style(code-runtime-python): keep the teardown v8-ignore under the line limit
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.
2026-08-31 14:30:02 +08:00
Chinesezjc 68f61b2e2f test(code-runtime-python): exempt the two single-platform teardown arms from coverage
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.
2026-08-31 14:30:02 +08:00
Chinesezjc 2ad93da755 fix(code-runtime-python): treat an absent start-time reading as reaped, not recycled
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.
2026-08-31 14:30:02 +08:00
Chinesezjc 2e3cf144d5 docs(code-runtime-python): correct the claims the new backend invalidated
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.
2026-08-31 14:28:26 +08:00
Chinesezjc e6b547bef4 fix(code-runtime-python): guard teardown, log prefix, and settlement flush
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.
2026-08-31 14:26:23 +08:00
Chinesezjc 9a8663cc4c fix(code-runtime-python): flush logs before framing the completion value
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).
2026-08-31 14:24:59 +08:00
Chinesezjc 9d9525549d fix(code-runtime-python): raise the output-budget worst-case multiple to 12 and reject the boundary
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.
2026-08-31 14:22:37 +08:00
Chinesezjc 436a97a12d fix(code-runtime-python): reserve the interpreter baseline in the budget gate and re-check against the clamped RLIMIT_AS
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.
2026-08-31 14:22:37 +08:00
Chinesezjc d9307ae2a4 fix(code-runtime-python): size the output-budget/address-space gate by worst-case Unicode and gate both budgets
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.
2026-08-31 14:22:37 +08:00
Chinesezjc 2df88b5bbe fix(code-runtime-python): reject an oversized maxLogBytes at load instead of metering log capture at runtime
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.
2026-08-31 14:22:37 +08:00
Chinesezjc c24e1e991b fix(code-runtime-python): charge structurally-valid-but-illegal UTF-8 and newline-path logs by decoded cost
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.
2026-08-31 14:22:37 +08:00
Chinesezjc dbff8ffba3 fix(code-runtime-python): charge illegal UTF-8 by its U+FFFD width on both log paths
The host stray-capture cost function charged illegal UTF-8 bytes (0x80-0xC1,
0xF5-0xFF, and orphaned multibyte leads) the raw 1, but toString('utf8')
renders each as U+FFFD (3 serialized bytes). A b"\xff" flood was undercounted
threefold, so the residual grew to a full budget's worth of raw bytes before
flushing and, near a large maxLogBytes, expanded toward a ~1 GiB peak in the
flush's concat plus toString. Replace serializedBufferCost with accrueStrayCost,
a cross-chunk UTF-8 walker that charges each byte its decoded serialized width;
carry its sequence state on each StrayBuffer.

The child _LogStream had the same-family bug: its early-flush trigger compared
_pending_chars (character count) against remaining (a serialized-byte budget),
so a 30M-NUL newline-free flood stayed under a 50 MB char trigger yet encoded to
~180 MB at settlement, breaching RLIMIT_AS as worker-exit. Track _pending_cost
via the _JSON_BYTE_COST table and trigger on it; keep _pending_chars for the
char-based slice bounds.

Correct the note's surrogate claim (only the string-walking jsonStringCostUpTo
charges a lone surrogate six bytes; the byte walker never sees one). Shrink the
post-truncation fixture below PIPE_BUF for a deterministic single callback. List
the shared stdout/stderr budget as a third honest fail-before exception
(cross-pipe arrival timing is nondeterministic). Add illegal-UTF-8,
broken-multibyte, and child-log-flood regression tests; sync the zh pair.
2026-08-31 14:22:37 +08:00
Chinesezjc e76b3baf9e fix(code-runtime-python): meter stdout and stderr stray residual against one shared budget
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').
2026-08-31 14:22:37 +08:00
Chinesezjc f29b4b1cb9 fix(code-runtime-python): seal stray fragments, flush by serialized cost, charge lone surrogates fully
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.
2026-08-31 14:22:37 +08:00
Chinesezjc 8093d22164 fix(code-runtime-python): bound stray capture by serialized cost, chunk-scan, and flush on destroy
The line-aggregating stray capture from the previous round regressed three
ways the review caught. Rewrite it on the fd-3 reader's raw-Buffer-chunk
shape: accumulate chunks with a byte counter and split on the raw 0x0a byte,
so a large newline-free write no longer re-copies the residual and re-scans
from index 0 per chunk (both O(N^2)). Meter each admitted entry by serialized
cost through a new jsonStringCostUpTo that walks to the cap and stops, so a
near-budget control-char-dense line never allocates the sixfold-inflated
JSON.stringify result the old ledger did (the critical: ~1.6 GiB transient
under a large maxLogBytes). Flush the residual explicitly in the closeDeadline
handler before it destroys the streams, so a setsid escapee's path (which
fires no end) does not drop a leader's final newline-free diagnostic.

Harden the sync-spawn leak assertion to a set difference against a pre-run
snapshot, immune to a parallel worker's concurrent tmpdir create/delete.

Decline the round-2 request to enforce the fd-3 ceiling per-frame: the counter
check must precede Buffer.concat to prevent ~2x memory doubling (two
regression tests assert this), and the batch-edge false reject it would fix is
reachable only at a maxLogBytes/maxValueBytes configured within one pipe read
of the 256 MiB ceiling, far past the defaults. Documented at the check and in
the note Alternatives.

Add flood, NUL-flood, short-escape, and closeDeadline-flush regression tests
(restoring per-file 100% coverage); update the Agent Note and zh pair.
2026-08-31 14:21:57 +08:00
Chinesezjc 44203f3fa7 fix(code-runtime-python): resolve worker-exit on sync spawn failure; aggregate stray output by line
Wrap spawn and the fd-3 narrowing so a synchronous throw (ENAMETOOLONG on
an over-PATH_MAX pythonBin, EMFILE) removes the run's staging directory and
resolves the same worker-exit class as the async error event, instead of
rejecting run() and leaking the directory.

Aggregate native stdout/stderr by real newline rather than by Node data
chunk: logs entries are joined with "\n" downstream, so a newline-free
write larger than one pipe read no longer reads back with spurious breaks.
The ledger still bounds a newline-free flood.

Track a running scan offset in both frame readers so a large frame
accumulated across chunks is scanned once, not re-scanned from 0 per chunk.

Reword the deadline hard-bound v8-ignore to state its real environment
dependence (PID-1-doesn't-reap container, zombie survivor) and cross-ref
the note's rejected signal-0 alternative; fix settle comments that quoted
the pre-qualification teardown contract; document the capMessage vs
_cap_message billing split on both sides; guard the dispose-after-resolve
heartbeat assertion against a vacuous 0===0 pass; reuse
_TRUNCATION_MARKER_BYTES; note the abandoned-call pending-entry bound.

Update the Agent Note Decision/Testing/Alternatives/Consequences for the
above and record the confirmed-empty finalize as a second honest
fail-before exception; sync the zh pair.
2026-08-31 14:21:57 +08:00
Chinesezjc 1103e36c22 fix(code-runtime-python): confirm group death at the deadline; chunk the frame read
The reap-poll deadline arm sent SIGKILL then finalized immediately, declaring
quiescence on mere signal delivery while the group was still dying. It now keeps
polling for the group to actually empty (bounded by one more reap margin) after
its self-sent SIGKILL, so `finished` resolves only on a confirmed-empty group.

ProtocolChannel.read_frame read the boot/run handshake frames through
FileIO.readline() on the unbuffered fd — one os.read(1) per byte, so a
multi-megabyte program burned CPU (RLIMIT_CPU already in force for the run frame)
in millions of syscalls before ast.parse. It now reads in chunks into the same
_pending buffer the async reader uses; the wrapping os.fdopen is gone. read_frame
is this PR's own code (e7f22ed3), not the protocol layer. The chunked read is a
syscall-count improvement with no cross-platform-deterministic failure to assert,
noted as such in the Agent Note.
2026-08-31 14:21:57 +08:00
Chinesezjc 28f747d775 test(code-runtime-python): cover the reply-pump closed-loop guard; align setsid docs
The closed-loop reply-pump guard now ships with a deterministic regression test:
a worker thread abandons a binding so its loop closes, the host answers that call
before a later binding, and the pump must survive the closed-loop
call_soon_threadsafe to deliver the later reply (host-gated ordering makes it
deterministic; unguarding the pump hangs the later binding to the wall clock).

Align the quiescence self-description with the shipped setsid limitation:
teardown()'s JSDoc and the Agent Note's Problem line now qualify "no subprocess
outlives the fiber" to subprocesses that stay in the child's process group, with
a setsid()-escape exception pointing at the README. Tighten the setsid-orphan
fixture's self-timeout to 5s and its upper-bound assertion to <4000ms so a failed
deadline backstop is a sharper red. Register the new regression tests in the note.
2026-08-31 14:21:57 +08:00
Chinesezjc bea8708b5d fix(code-runtime-python): reject a non-integer maxLogBytes/maxValueBytes at load
The child reads these byte budgets through int(...), which silently floors a
float, so maxLogBytes: 3.5 would truncate at 3 bytes child-side while the host
meters and marks at 3.5 — the two sides enforcing different public config. Gate
them to integers at load, as the worker backend does; correct the stale comment
that claimed the int()-truncated caps needed no gate. Adds a regression test.
2026-08-31 14:21:57 +08:00
Chinesezjc 63c49c8a90 fix(code-runtime-python): meter the exception diagnostic by serialized cost
Raising maxValueBytes' load bound to ceiling-envelope assumed both budgets are
metered in serialized (JSON-escaped) bytes, which held for completion values and
logs but not the diagnostic: _cap_message capped by raw UTF-8, so a control-heavy
message near maxValueBytes could serialize sixfold and breach the fd-3 frame
ceiling — the silent worker-exit inversion the load check prevents. _cap_message
now accumulates per-byte serialized cost (new _JSON_BYTE_COST table) and cuts the
prefix that fits. Also reword the host SIGXCPU timeout message to name cpuSeconds
as the configured ceiling rather than a budget a stricter inherited RLIMIT_CPU
soft may undercut. Adds a control-heavy-diagnostic regression test.
2026-08-31 14:21:57 +08:00
Chinesezjc e0d5d8d097 fix(code-runtime-python): send SIGKILL at the reap-poll deadline, not cancel it
The group-reap poll folded its deadline arm into the empty-group arm, so a host
event loop blocked past graceMs + CLOSE_REAP_MARGIN_MS would run the overdue
poll before the grace SIGKILL timer: the group is still non-empty, the deadline
has passed, and the shared arm cancelled the never-fired SIGKILL and finalized —
releasing a SIGTERM-ignoring same-group survivor for good. Split the arms: empty
group cancels the moot timer and finalizes; deadline-with-non-empty-group sends
SIGKILL itself (idempotent if the timer already ran) before finalizing. Adds a
regression test that busy-blocks the loop past both timers and asserts the
survivor's heartbeat freezes.
2026-08-31 14:21:57 +08:00
Chinesezjc b1ce014035 fix(code-runtime-python): restore per-file branch coverage on the reap poll
The group-reap poll's deadline arm (Date.now() >= deadline) is a backstop that
SIGKILL emptying the reachable group never reaches, leaving one uncovered branch
under the per-file 100% gate. Mark it v8-ignore with the reason and drop the
always-true graceTimer-defined guard inside pollGroup (it runs only when killing
is set, so kill() has armed the timer).
2026-08-31 14:21:57 +08:00
Chinesezjc ecdb79824b fix(code-runtime-python): keep a completed run in live until its group is reaped
settle() dropped the run from `live` eagerly, before the grace-window SIGKILL
reaped a same-group survivor. A dispose() racing a just-resolved run() then
snapshotted an empty `live` and returned while the descendant was still alive,
so teardown's "no subprocess outlives the fiber" (and its JSDoc) was false for
that window. The run now stays in `live` until the process-group poll confirms
the group empty, at which point it is both dropped from `live` and its finished
promise resolved. Adds a regression test asserting dispose() of a completed run
with a same-group survivor returns only after the survivor stops executing.
2026-08-31 14:21:57 +08:00
Chinesezjc ff604dc876 fix(code-runtime-python): clear stale SIGKILL timer and clamp inherited soft rlimit
Two further review findings on the CPython backend:
- The grace-window SIGKILL timer was left armed after settlement, so on a
  normal completion a kill(-pid) could fire up to graceMs later and strike a
  recycled pgid once the kernel reused the leader's pid. settle() now clears
  the timer the moment the process group is confirmed empty (the normal path
  and when the poll sees the survivor gone), bounding the reuse window to the
  genuine-survivor case where the group cannot be empty to reuse.
- _clamped bounded rlimits by the inherited hard limit only, silently raising
  an inherited soft limit stricter than the request (loosening RLIMIT_AS or
  deferring RLIMIT_CPU SIGXCPU). It now clamps each side against its own
  inherited counterpart and pins soft under hard, keeping the strictest of
  configured and inherited. Adds an inherited-soft-limit regression test.

Agent Note expanded to seven fixes with the two new rejected alternatives;
zh pair re-recorded.
2026-08-31 14:21:19 +08:00
Chinesezjc 6cb70e6e69 fix(code-runtime-python): reap same-group survivors and fix cross-loop bindings
Two review findings on the CPython backend:
- Disposal could return while a same-group descendant that ignores SIGTERM
  but releases the inherited pipes was still alive: the leader's close fired
  and the previous fix relied on an unref'd SIGKILL timer that a short-lived
  host never fires, reparenting the survivor to init. settle() now withholds
  the run's finished promise on a ref'd process-group poll until the SIGKILL
  has emptied the group (bounded by graceMs + margin, zero-cost when already
  empty), so teardown's "await each child's exit" holds.
- A binding called from a model worker thread via asyncio.run created its
  reply Future on that thread's loop, but _pump_replies completed it directly
  from the main loop; asyncio.Future is not thread-safe across loops, so the
  call hung to the wall clock. Replies now complete via the owning loop's
  call_soon_threadsafe, and a lock serializes the id claim/write/advance.

Tests: the same-group reap case now asserts a heartbeat file stops (robust
whether the killed descendant is reaped or a zombie, so it holds where PID 1
does not wait() orphans); a cross-loop case runs a binding from a worker
thread and asserts the reply round-trips instead of timing out. Agent Note
expanded to all six fixes with rejected alternatives; zh pair re-recorded.
2026-08-31 14:21:19 +08:00
Chinesezjc 9a05c0075f fix(code-runtime-python): reap same-group children and correct log-budget bound
Address review findings on the CPython backend:
- CRITICAL: a model program could leave a descendant in the child's own
  process group that ignores SIGTERM but releases the inherited pipes, so
  the leader's `close` fired and settle() cancelled the pending SIGKILL
  before it escalated — run()/dispose() returned while that child lived.
  kill() now unrefs the grace timer and settle() no longer clears it, so
  the SIGKILL reaches the whole group; killGroup swallows ESRCH when the
  group is already gone (the normal case). Adds a real-subprocess
  regression test.
- WARNING: the maxLogBytes/maxValueBytes load bound divided the frame
  ceiling by 6 for escape expansion, but both budgets are metered in
  already-escaped serialized bytes, so a payload occupies at most
  cap+envelope on the wire. Bound is now ceiling-envelope; drop the unused
  escape constant.
- Narrow the runtime.spec.ts header to "no subprocess mocks" (it mocks
  node:fs.copyFileSync for staging-failure cases).
- Use full-width punctuation in the README.zh.md prose per translation
  rules; re-record the pair.
2026-08-31 14:21:19 +08:00
Chinesezjc c388169cff feat(code-runtime-python): add the CPython subprocess backend
Land the PythonCodeRuntime implementation on top of the fd-3 protocol
seam: python3 -I per run, binding namespace over fd 3, RLIMIT_CPU/AS,
wall-clock timer, and SIGTERM->grace->SIGKILL process-group teardown,
with the real-subprocess integration suite.

Fixes three defects surfaced on the source PR's review before they ship:
- boot-write failure resolved a worker-exit through finish()/settle()
  that read wallTimer/onAbort/live in their TDZ, rejecting run() instead;
  the boot write now runs after those bindings and the v8-ignore that hid
  the branch is removed.
- log capture serialized against settlement with no lock while model
  daemon threads keep writing; LogBuffer now owns one shared re-entrant
  lock taken by write/flush_line/push.
- the fd-3 line residual was a subarray view pinning the whole joined
  frame; it is copied into a right-sized Buffer via detachResidual so
  pendingBytes measures what is retained.
2026-08-31 14:14:32 +08:00
Chinesezjc 3057f3bb1b docs(code-runtime-python): state the empty invariant's real reason
packages/AGENTS.md:18 requires a package-specific "No runtime invariant:"
reason on an empty installer. This one described a process-boundary
implementation and real-subprocess integration tests that the package does
not carry — it ships the wire-protocol codec and its Python mirror, covered
by protocol.spec.ts and protocol-mirror.e2e.ts.
2026-08-17 18:32:18 +08:00
Chinesezjc d1700c8a01 docs(code-runtime-python): scope the module JSDoc to the current contract
The barrel's module comment described where a later implementation would sit
relative to this seam, which docs/AGENTS.md:38 keeps out of durable prose.
State what the module exports instead.
2026-08-17 18:07:24 +08:00
Chinesezjc 5d3afb192d Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-protocol
Resolutions:

- docs/module-graph.md, docs/config-catalog.md: both are generated files.
  Regenerated with gen-module-graph and gen-config-catalog on the merged
  tree instead of hand-merging the conflict hunks.
- scripts/verify-package-readme-model-experience.ts: both sides appended
  registry entries; kept all four.

Adapted this package to conventions master introduced while the branch was
open: version 0.0.1-rc.1 with publishConfig and repository metadata, the
workspace: protocol for peer dependencies, and the @deepseek-ai/cordis
rescope in package.json and src/invariant.ts.
2026-08-11 15:53:38 +08:00
Chinesezjc ea1b494614 docs(code-runtime-python): drop forward references this layer does not own
Two comments described facts that belong to later layers of the stack:

- The workspace-constraints whitelist comment described a bootstrap the host
  spawns by path. This layer's py/ holds only protocol.py, the wire-vocabulary
  mirror, and nothing here spawns it. State what the whitelist entry actually
  covers: the Python source ships as-is rather than built.
- checkDoneValue's JSDoc claimed maxValueBytes "defaults to 32 KiB". This
  package defines no config and no default; maxValueBytes is a required boot
  frame field. Name it as the budget instead, so the prose cannot drift when
  the owning implementation picks a default.

Comment-only; the bound argument is unchanged.
2026-08-11 14:44:39 +08:00
Chinesezjc 5dad49f4db docs(code-runtime-python): name the roster aliases by subset direction, align metering prose
Rename UnionCoversRoster/RosterCoversUnion to UnionSubsetOfRoster/RosterSubsetOfUnion so
the names read in the same direction as their extends clauses, share the python3 -I -B
flags between the two mirror probes, and align the README and Agent Note prose with the
checkDoneValue JSDoc: the escaped-size scan is the metering itself, not deferred work.
Regenerate docs/module-graph.md, which listed code-runtime-python twice.
2026-08-07 15:37:19 +08:00
Chinesezjc 203bfca0ea docs(code-runtime-python): trim metering prose and cover encodeJsonPlain depth
- Drop the review-history narrative from checkDoneValue's JSDoc (the "in the
  previous implementation … now avoids" clause); state the current contract only.
- Assert encodeJsonPlain on the same 100k-deep value the metering test uses:
  its headline contract is stack-safety (JSON.stringify would throw), but no
  test exercised the encoder on a deep value.
2026-08-07 13:27:54 +08:00
Chinesezjc 4674d8fa92 fix(code-runtime-python): verify union<->roster both ways, stop pycache writes, refresh metering prose
Address the latest review round:

- WireFrameShapesCoverUnions checked only union ⊆ roster, so removing a frame
  from a message union (e.g. dropping ReplyErr from ReplyMessage) left the check
  true while the public TS union diverged from the wire. Replace it with a
  bidirectional equivalence between MessageFrames and the roster's message-frame
  value types (nested Namespace/ErrorClass/DoneErrorField excluded): both a frame
  added to a union without a roster entry and a frame removed from a union now
  fail typecheck (both verified).
- The mirror e2e's python3 probes imported protocol.py without -B, writing
  py/__pycache__/*.pyc into the (un-ignored) source tree. Add -B to both.
- Refresh the metering prose (checkDoneValue JSDoc + README both sides + Agent
  Note both sides): the incremental-work list no longer says "per-key
  JSON.stringify" now that jsonStringBytesUpTo scans without stringifying;
  re-record the README and Agent Note i18n pairings.
2026-08-07 13:27:54 +08:00
Chinesezjc f9ab1edc68 fix(code-runtime-python): meter escaped string bytes without allocating, bind frame roster to the unions
Two review findings on checkDoneValue's metering and the wire-mirror binding:

- The string/key byte check used a decoded-length lower bound and then called
  JSON.stringify, which materializes the ~6x escaped copy before the over-budget
  check — the hundreds-of-MB spike the metered walk exists to avoid. Add
  jsonStringBytesUpTo, a non-allocating scan that computes the exact escaped
  UTF-8 size (matching JSON.stringify byte for byte, including surrogate pairs
  vs lone surrogates) and bails the instant it crosses the remaining budget; use
  it for both string values and object keys.
- The frame roster in WIRE_FRAME_FIELD_ROLES was hand-written, so a frame added
  to ChildToHost/ReplyMessage without a roster entry slipped past. Introduce
  WireFrameShapes (name -> interface) as the canonical roster the roles map is
  bound against, plus a WireFrameShapesCoverUnions compile-time assertion that
  every message-union member appears in it (verified: adding a frame to a union
  without a WireFrameShapes entry fails typecheck).
2026-08-07 13:27:54 +08:00
Chinesezjc 4de8c914c9 refactor(code-runtime-python): export PROTOCOL_FD and tidy the mirror binding
Address the remaining review findings on the wire-mirror layer:

- Export PROTOCOL_FD from protocol.ts as the TS-side source of truth the host
  wires, and assert the Python constant against it in the mirror e2e instead of
  a bare literal 3, so an fd drift on either side is caught.
- Correct the FrameFieldRoles JSDoc to point at the actual assertion site
  (WIRE_FRAME_FIELD_ROLES's satisfies clause, not WIRE_FRAME_FIELDS).
- Drop the redundant explicit type annotation on WIRE_FRAME_FIELDS (the trailing
  `as` cast already types it; Object.fromEntries returns an index signature).
- Refresh the mirror-test comment to describe the roles-map binding (a TS-side
  add/remove/rename/optionality-flip fails typecheck; a Python-side change fails
  the comparison).
2026-08-07 13:27:54 +08:00
Chinesezjc adba2e305a fix(code-runtime-python): make the wire-field binding exhaustive over interface keys
The array-based FrameFields<T> only checked that listed names were members of
the frame's keys, so a field added to a TS interface (e.g. LogMessage.seq?)
left the existing arrays a valid subset — typecheck passed, and since the
constant and Python both lacked the field the mirror test passed too. The
JSDoc's claim that runtime covered this was false.

Replace it with WIRE_FRAME_FIELD_ROLES, a per-frame map keyed by field name
(`Record<RequiredKeys<T>, 'required'> & Record<OptionalKeys<T>, 'optional'>`),
so every interface key MUST appear with a matching required/optional tag: an
added field, a removed field, a rename, or an optionality flip all fail
typecheck at the roles map (verified). WIRE_FRAME_FIELDS is projected from it
as the sorted arrays the mirror test still compares to the Python TypedDicts.

Also drop the `export` added to the promoted frame interfaces (Namespace,
ErrorClass, RunMessage, DoneErrorField, ReplyOk, ReplyErr) — nothing outside
protocol.ts imports them, so the barrel surface is unchanged and knip stays
clean.
2026-08-07 13:27:54 +08:00
Chinesezjc 4d49406bd5 fix(code-runtime-python): bind the wire-field mirror to TS required/optional keys
The previous mirror binding (FrameFields<keyof T>) only checked membership: it
could not see a TS-side optionality flip (truncated? -> truncated leaves keyof
unchanged) or a field added on one side, so the "depends on the TS
declaration" claim was overstated.

- Promote the inline frame shapes (Namespace, ErrorClass, DoneErrorField,
  RunMessage, and the two Reply variants) to named interfaces so every frame
  binds uniformly.
- Derive FrameFields from RequiredKeys<T>/OptionalKeys<T>, so `required` and
  `optional` each accept only that side's keys. An optionality flip or a rename
  now fails typecheck (verified: flipping LogMessage.truncated to required
  errors at the constant).
- Enumerate EVERY public TypedDict in py/protocol.py in the mirror e2e (not a
  name list taken from the TS side) and assert both the frame roster and each
  frame's required/optional sets by exact equality, so a frame or field present
  on only one side of the wire fails the test.
2026-08-07 13:27:54 +08:00
Chinesezjc be839a8e53 fix(code-runtime-python): count non-lossless bytes and bind the mirror gate to TS types
Two gaps from the previous round's fixes:

- checkDoneValue flagged a non-lossless number but skipped counting its encoded
  bytes, so a value over budget ONLY through that number classified as
  non-lossless instead of over-budget (e.g. [Infinity] at cap 3, whose encoding
  is 10 bytes). Count the scalar's bytes even when flagging, so the budget check
  wins as the JSDoc promises. Add cap-3 regression cases.

- The mirror e2e compared the Python TypedDict keys against a hand-written
  constant, so a field change on the TS side alone would not fail it, and the
  reply frames were not probed at all. Introduce WIRE_FRAME_FIELDS in
  protocol.ts, bound to each frame interface's key set via `satisfies` (a
  renamed/removed field breaks typecheck — verified), and drive the mirror test
  from it, now covering ReplyOk/ReplyErr too. The test therefore fails on
  one-sided drift from either language.
2026-08-07 13:27:54 +08:00
Chinesezjc 8a60b5f005 feat(code-runtime-python): make the TypedDict wire mirror an executable gate
Address the two standing review suggestions in this layer rather than deferring
them to PR #4:

- Extend tests/protocol-mirror.e2e.ts to read each py/protocol.py TypedDict's
  required/optional key set and assert it against the wire field names
  src/protocol.ts declares (global included, via functional TypedDict). The
  round-12 class of drift — a renamed/dropped field, or one side making a field
  optional the other requires — now fails a test instead of relying on review.
  Field types remain review-guarded (no mechanical TS/Python equivalent).
- Drop the forward references to PR #4's internal mechanisms from this layer's
  prose: the "256 MiB frame ceiling" figure and the "(index.ts)" fd-3 pinning
  citation become an abstract "host-side inbound frame-size cap" so the JSDoc,
  spec, README, and Agent Note describe only what this layer owns.

Update both README sides and the Agent Note (both languages) to state the
mirror is now executable, and re-record their i18n pairings.
2026-08-07 13:27:54 +08:00
Chinesezjc 146a9d9f61 fix(code-runtime-python): make checkDoneValue over-budget precedence order-independent
checkDoneValue returned non-lossless the instant it hit a non-finite/negative-
zero number, before finishing the budget metering. A value that is BOTH over-
budget and non-lossless then classified by member order: `["<huge>", 1e400]`
gave non-lossless while `[1e400, "<huge>"]` gave over-budget — the same value,
two verdicts — which would drive the consumer to emit invalid-output vs
output-limit non-deterministically, contradicting the JSDoc promise that an
over-budget value is rejected as over-budget regardless. Record the number
violation in a flag and let metering finish; return non-lossless only once the
whole value is confirmed within budget. Add a regression test asserting both
member orders classify as over-budget.
2026-08-07 13:27:54 +08:00
Chinesezjc b4487485c2 docs(code-runtime-python): correct ownValues allocation claim
ownValues' JSDoc claimed the generator avoids a "second full-breadth
allocation before a single value is examined", but for...in still materializes
the key-name enumeration when the loop starts — the same JS limitation the
checkDoneValue rewrite now acknowledges. What the generator genuinely saves is
the extra VALUE array Object.values/Object.entries would copy; state that
precisely rather than implying sublinear startup.
2026-08-07 13:27:54 +08:00
Chinesezjc ae8070d799 fix(code-runtime-python): stop overclaiming O(cap) object metering
checkDoneValue cannot bound object width sublinearly: JS has no lazy own-key
iterator (for...in materializes the key set), and done.value is already
JSON.parse'd before the check runs, so the frame's width is paid upstream. The
genuine width bound is the host's fixed 256 MiB fd-3 receive buffer (a later
stack layer). Reword the JSDoc and branch comments to claim only what holds —
the traversal caps the INCREMENTAL allocation the check would add (escaped
strings, enqueued children, per-key stringify) and refuses over-budget before
those secondary allocations — and drop the mid-count micro-check that JS cannot
honor. Replace the Proxy test (whose ownKeys allocated a 2M array, proving
nothing) with assertions that an over-budget string/array/object is refused
before its escaped copy or child enqueue.
2026-08-07 13:27:54 +08:00
Chinesezjc 104cd5f975 fix(code-runtime-python): bound checkDoneValue object metering in O(cap)
The object branch counted every own key before applying the size bound, so a
forged done.value with millions of keys and a small cap forced an O(frame)
walk — contradicting the O(cap) guarantee the comment promised and able to
block the host event loop. Bail mid-count the instant the running minimum
encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now
-redundant post-count check the loop subsumes. Add a Proxy-based test proving
a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap.

Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via
scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify.
2026-08-07 13:27:54 +08:00
Chinesezjc 98ebe1315d fix(code-runtime-python): reject -0 call ids and document done value/error
- Drop a CALL frame whose id is negative zero: it passes Number.isFinite but
  the reply re-serializes it as `0`, colliding with a real call id `0`. The
  honest child never issues `-0`.
- Document that validateChildFrame preserves a forged done frame's value and
  error together on purpose, so consumers must check error before value.
2026-08-07 13:27:54 +08:00
Chinesezjc f0d669883f fix(code-runtime-python): close coverage gap and tighten the wire mirror
- Cover the log-frame `truncated` rebuild branch: assert a literal-true flag
  rides along and any other value (1, string, false) is dropped, closing the
  protocol.ts branch the coverage gate flagged.
- Correct encodeJsonPlain's JSDoc: it matches compact JSON.stringify EXCEPT on
  a beyond-safe-range integral double, where it emits the exact BigInt digits
  (`...846976`) rather than the rounded `...847000` — the divergence the
  "emits exact digits" test pins.
- Declare py/protocol.py's `global`-bearing frames (Namespace, CallMessage)
  with functional TypedDict syntax so they carry the real wire key instead of
  a `global_` attribute the wire never sends, and split optional-field messages
  (Namespace/LogMessage/DoneMessage) into a required base plus a total=False
  subclass so `type` and other required fields cannot be dropped. Widen
  HostToChild to include the boot and run frames the host sends before replies.
- Reword the mirror e2e's py/ directory assertion to describe the source-tree
  layout it actually checks.
2026-08-07 13:27:54 +08:00
Chinesezjc e0f22aeaad feat(code-runtime-python): add the fd-3 frame protocol
Introduce @deepseek-ai/dsh-code-runtime-python with the versionless
JSON-lines protocol between the Node host and the CPython subprocess:
the host-side hostile-frame codec (validateChildFrame, encodeJsonPlain,
checkDoneValue, hasUnsafeIntegerToken, hasNonLosslessNumber,
logTruncationMarker) and the Python-side wire-vocabulary mirror
(py/protocol.py).

This is the protocol layer of the code-runtime-python stack, split from
#436 and based on the multi-language seam extension. The PythonCodeRuntime
implementation and its Python JSON codec land in the backend-core PR on
top of this branch.

Ship the minimal buildable package skeleton (package.json, tsconfig,
tsdown, barrel index, invariant companion, bilingual README) because the
workspace-constraint, coverage, and invariant-topology gates require the
package to exist and build the moment its directory does; the backend-core
PR extends those files rather than creating them.

Align py/protocol.py with src/protocol.ts (the round-12 review of #436
found LogMessage.truncated, DoneMessage.error.kind, and Namespace.errorClass
stale) and guard the two runtime-executed surfaces (PROTOCOL_FD and the log
truncation marker) with a real-python3 cross-language mirror e2e test.
2026-08-07 13:27:54 +08:00