docs(code-runtime-python): register paced-replies and late-drop in the settlement note
This commit is contained in:
1 parent
441ebd0433
commit
117ca8cb67
3 files changed
+10
-10
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md
|
||||
2026-07-31-code-runtime-python-settlement-fixes.md: ed0532b1792fa9d99f3b13bf12ecf20f73a9102c
|
||||
2026-07-31-code-runtime-python-settlement-fixes.zh.md: 70106c28bbbbc65243d3693d7fbd5ad81415fa96
|
||||
2026-07-31-code-runtime-python-settlement-fixes.md: c7347ec19cf2e6039f04bf8c3ec4b2162ee3f758
|
||||
2026-07-31-code-runtime-python-settlement-fixes.zh.md: 6f15cf9ca06ef2ea70685dd084e8034e377a2d4a
|
||||
+4
-4
@@ -6,7 +6,7 @@ English | [中文](2026-07-31-code-runtime-python-settlement-fixes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The CPython subprocess backend for Code Mode, built on the [fd-3 frame protocol](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md), resolves every program outcome as a `CodeRunResult`, rejects `run()` only for seam misuse, and disposes to quiescence so no subprocess that stays in the child's process group outlives the fiber (a descendant that escapes the group with `setsid()` is the documented exception — see the package README's Known Limitations). A sequence of review passes surfaced defects that broke those contracts in ways unit coverage did not catch — each hid behind a `/* v8 ignore */`, a captured-callable that read as a fix but was not, a memory effect invisible through the seam, a load-time bound that double-counted, a process-group escalation that a survivor could outlast, a cross-event-loop completion that silently deadlocked, a synchronous throw outside the settlement path, or a transport boundary rendered as a log boundary. Most behavioral fixes ship with a test that fails without them; four do not, and are called out as such — the chunked frame read (a syscall-count improvement with no cross-platform-deterministic failure), the confirmed-empty finalize (its only seam-observable effect, a frozen heartbeat, freezes the instant SIGKILL is delivered, which the pre-fix finalize-on-delivery code also produced, and the discriminating probe is the signal-0 check the Alternatives reject as cross-environment-unreliable), the shared stdout/stderr budget (its only seam-observable difference is which entry boundary a mid-stream flush lands on, and that depends on the relative arrival timing of two independent OS pipes, which `os.sched_yield` does not make deterministic; the per-pipe memory bounds it strengthens ARE covered by the single-pipe flood tests), and the `flush_line` join-clear-push reorder (it lowers the settlement-flush peak from three copies to two, but the 12x load gate already covers the three-copy newline path, so every gate-admitted config stays within the address space under both orders and no seam-observable difference exists — the memory effect is inside the Python child, unmeasurable through the seam like the shared-budget case).
|
||||
The CPython subprocess backend for Code Mode, built on the [fd-3 frame protocol](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md), resolves every program outcome as a `CodeRunResult`, rejects `run()` only for seam misuse, and disposes to quiescence so no subprocess that stays in the child's process group outlives the fiber (a descendant that escapes the group with `setsid()` is the documented exception — see the package README's Known Limitations). A sequence of review passes surfaced defects that broke those contracts in ways unit coverage did not catch — each hid behind a `/* v8 ignore */`, a captured-callable that read as a fix but was not, a memory effect invisible through the seam, a load-time bound that double-counted, a process-group escalation that a survivor could outlast, a cross-event-loop completion that silently deadlocked, a synchronous throw outside the settlement path, or a transport boundary rendered as a log boundary. Most behavioral fixes ship with a test that fails without them; six do not, and are called out as such — the chunked frame read (a syscall-count improvement with no cross-platform-deterministic failure), the confirmed-empty finalize (its only seam-observable effect, a frozen heartbeat, freezes the instant SIGKILL is delivered, which the pre-fix finalize-on-delivery code also produced, and the discriminating probe is the signal-0 check the Alternatives reject as cross-environment-unreliable), the shared stdout/stderr budget (its only seam-observable difference is which entry boundary a mid-stream flush lands on, and that depends on the relative arrival timing of two independent OS pipes, which `os.sched_yield` does not make deterministic; the per-pipe memory bounds it strengthens ARE covered by the single-pipe flood tests), and the `flush_line` join-clear-push reorder (it lowers the settlement-flush peak from three copies to two, but the 12x load gate already covers the three-copy newline path, so every gate-admitted config stays within the address space under both orders and no seam-observable difference exists — the memory effect is inside the Python child, unmeasurable through the seam like the shared-budget case), pacing binding replies (its in-tree case only asserts the framed replies still round-trip; the peak it removes lives inside the host's fd-3 writable buffer, unseen through the seam, so the 32.0 MiB → 0.0 MiB reduction is measurable only out-of-tree), and dropping a late binding resolution before snapshot (its three assertions all also hold pre-fix, because `sendReply` already dropped after-settlement values — just later than the snapshot).
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -44,7 +44,7 @@ In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/
|
||||
|
||||
### 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.
|
||||
`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.
|
||||
|
||||
@@ -76,7 +76,7 @@ One residual write-path copy is fixed alongside, independent of the config gate:
|
||||
|
||||
- `tests/boot-write-failure.spec.ts` mocks `spawn` so the fd-3 pipe throws on the boot write — the one path a real subprocess cannot be coerced into — and asserts `run()` resolves a `worker-exit` rather than rejecting. A sibling case makes the mocked `spawn` throw SYNCHRONOUSLY and asserts `run()` still resolves a `worker-exit` and removes its staging directory, keyed off the exact bootstrap path the mocked `spawn` received in its argv so a sibling worker's concurrent staging cannot flake it. Both are isolated in this spec so the real-subprocess suite is untouched.
|
||||
- `tests/residual-detach.spec.ts` unit-tests `detachResidual`: the carried copy equals the residual, owns a backing store sized to its own length (fixture kept above Node's Buffer pool threshold), and does not share the source frame's `ArrayBuffer`.
|
||||
- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the applieLine truncated
|
||||
- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the applieLine truncated
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -110,4 +110,4 @@ One residual write-path copy is fixed alongside, independent of the config gate:
|
||||
|
||||
## Consequences
|
||||
|
||||
The seam's resolve-don't-reject contract holds on the boot-write path and the synchronous-spawn-failure path, both with measured coverage, and neither strands a staging directory. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush, and stray native output is delimited by its own newlines rather than by transport chunks. Fd-3 residual memory is bounded by the actual retained bytes, and both frame readers scan an accumulating frame once rather than quadratically. The output caps admit every value a frame can carry and reject a non-integer budget at load. Disposal is genuinely quiescent against a same-group survivor — bounded by `graceMs + 2 * CLOSE_REAP_MARGIN_MS`, zero-cost when the group is already empty, with the SIGKILL timer cleared once the group empties so a stale kill cannot strike a recycled pgid — RLIMIT enforcement keeps the strictest of configured and inherited on both soft and hard (and the SIGXCPU diagnostic no longer names a budget the host cannot guarantee), bindings called from model-created threads complete instead of timing out, and the handshake frame reader no longer burns the CPU budget on a large program. Every behavioral fix carries a test that fails without it, except the four called out in the Problem section — the chunked frame read (a syscall-count improvement), the confirmed-empty finalize (whose only seam-observable effect freezes at signal delivery, which the pre-fix code also produced), the shared stdout/stderr budget (whose only seam-observable difference turns on nondeterministic cross-pipe arrival timing), and the `flush_line` reorder (whose lowered peak stays within what the 12x gate already admits, so no config behaves differently) — so a future regression on the rest goes red.
|
||||
The seam's resolve-don't-reject contract holds on the boot-write path and the synchronous-spawn-failure path, both with measured coverage, and neither strands a staging directory. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush, and stray native output is delimited by its own newlines rather than by transport chunks. Fd-3 residual memory is bounded by the actual retained bytes, and both frame readers scan an accumulating frame once rather than quadratically. The output caps admit every value a frame can carry and reject a non-integer budget at load. Disposal is genuinely quiescent against a same-group survivor — bounded by `graceMs + 2 * CLOSE_REAP_MARGIN_MS`, zero-cost when the group is already empty, with the SIGKILL timer cleared once the group empties so a stale kill cannot strike a recycled pgid — RLIMIT enforcement keeps the strictest of configured and inherited on both soft and hard (and the SIGXCPU diagnostic no longer names a budget the host cannot guarantee), bindings called from model-created threads complete instead of timing out, and the handshake frame reader no longer burns the CPU budget on a large program. Every behavioral fix carries a test that fails without it, except the six called out in the Problem section — the chunked frame read (a syscall-count improvement), the confirmed-empty finalize (whose only seam-observable effect freezes at signal delivery, which the pre-fix code also produced), the shared stdout/stderr budget (whose only seam-observable difference turns on nondeterministic cross-pipe arrival timing), and the `flush_line` reorder (whose lowered peak stays within what the 12x gate already admits, so no config behaves differently), pacing binding replies (the 32.0 MiB → 0.0 MiB peak reduction lives inside the host's fd-3 writable buffer, unmeasurable through the seam), and dropping a late binding resolution before snapshot (its three assertions all hold pre-fix, so it is not a fail-before case) — so a future regression on the rest goes red.
|
||||
+4
-4
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`,并且会 dispose 到完全停稳,从而没有任何留在子进程自己进程组内的子进程存活得比 fiber 更久(一个用 `setsid()` 逃出该进程组的后代是有文档记载的例外——见该包 README 的 Known Limitations)。一连串审查暴露出一些缺陷,它们以单元测试覆盖率无法捕获的方式破坏了这些契约:每一个都藏在一处 `/* v8 ignore */` 之后、一个读起来像修复但实际并非修复的捕获可调用对象之后、一处透过 seam 不可见的内存效应之后、一处重复计数的加载期上界之后、一处存活者能够熬过的进程组升级之后、一处静默死锁的跨事件循环完成之后、一处位于结算路径之外的同步抛出之后,或者一处被当作日志边界处理的传输边界之后。大多数行为修复都附带一个在缺少它时会失败的测试;有四处没有,并被如此标注——分块读取帧(一处系统调用次数的改进,没有可跨平台确定性断言的失败)、确认为空后的收尾(它唯一透过 seam 可观测的效应,即一个冻结的心跳,会在 SIGKILL 被投递的瞬间冻结,而修复前"投递即收尾"的代码也会产生同样的结果,用于区分的探测手段是 Alternatives 以跨环境不可靠为由否决的 signal-0 检查)、共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异,是一次流中冲刷落在哪条条目边界上,而这取决于两条相互独立的 OS 管道的相对到达时机,`os.sched_yield` 并不能使其确定;它所强化的按管道计的内存界限确实由单管道洪泛测试覆盖),以及 `flush_line` 的 join-清空-push 重排序(它把结算期冲刷的峰值从三份副本降到两份,但 12× 加载门本就覆盖了换行路径的三副本峰值,因此每个被门放行的配置在两种顺序下都落在地址空间之内、不存在透过 seam 可观测的差异——该内存效应在 Python 子进程内部,与共享预算那处一样无法透过 seam 度量)。
|
||||
用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`,并且会 dispose 到完全停稳,从而没有任何留在子进程自己进程组内的子进程存活得比 fiber 更久(一个用 `setsid()` 逃出该进程组的后代是有文档记载的例外——见该包 README 的 Known Limitations)。一连串审查暴露出一些缺陷,它们以单元测试覆盖率无法捕获的方式破坏了这些契约:每一个都藏在一处 `/* v8 ignore */` 之后、一个读起来像修复但实际并非修复的捕获可调用对象之后、一处透过 seam 不可见的内存效应之后、一处重复计数的加载期上界之后、一处存活者能够熬过的进程组升级之后、一处静默死锁的跨事件循环完成之后、一处位于结算路径之外的同步抛出之后,或者一处被当作日志边界处理的传输边界之后。大多数行为修复都附带一个在缺少它时会失败的测试;有六处没有,并被如此标注——分块读取帧(一处系统调用次数的改进,没有可跨平台确定性断言的失败)、确认为空后的收尾(它唯一透过 seam 可观测的效应,即一个冻结的心跳,会在 SIGKILL 被投递的瞬间冻结,而修复前"投递即收尾"的代码也会产生同样的结果,用于区分的探测手段是 Alternatives 以跨环境不可靠为由否决的 signal-0 检查)、共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异,是一次流中冲刷落在哪条条目边界上,而这取决于两条相互独立的 OS 管道的相对到达时机,`os.sched_yield` 并不能使其确定;它所强化的按管道计的内存界限确实由单管道洪泛测试覆盖),以及 `flush_line` 的 join-清空-push 重排序(它把结算期冲刷的峰值从三份副本降到两份,但 12× 加载门本就覆盖了换行路径的三副本峰值,因此每个被门放行的配置在两种顺序下都落在地址空间之内、不存在透过 seam 可观测的差异——该内存效应在 Python 子进程内部,与共享预算那处一样无法透过 seam 度量),节流 binding 回复(它的树内用例只断言分帧后的回复仍能完整往返;它所移除的峰值位于宿主 fd-3 可写缓冲内部、透过 seam 不可见,因此 32.0 MiB → 0.0 MiB 的降幅只能在树外度量),以及在快照之前丢弃迟到的 binding 解析(它的三条断言在修复前同样成立,因为 `sendReply` 本就丢弃结算之后的值——只是比快照晚)。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -44,7 +44,7 @@ Status: implemented
|
||||
|
||||
### 并发 binding 回复对 fd 3 做节流
|
||||
|
||||
`sendReply` 忽略了 `proto.write` 的 `false` 返回值,因此一个在一轮 `asyncio.gather` 中解析多个大值的程序,会把每条回复都在同一个 turn 内编码、并全部排入 fd 3 的可写缓冲。binding 回复在 seam 层没有字节上限可以约束它,而且这个失败杀掉的是**宿主进程**而不是让本次运行失败:在 highWaterMark 为 64 KiB 的管道上实测,八条 4 MiB 回复会同时缓冲 32.0 MiB。现在回复走一个队列,一次编码并写出一帧,管道写满时等待 `drain`——同样形状实测峰值为 0.0 MiB。编码放在循环内部,因此一条运行已不再需要的排队回复会被 `settled` 检查丢弃,根本不会被序列化。
|
||||
`sendReply` 忽略了 `proto.write` 的 `false` 返回值,因此一个在一轮 `asyncio.gather` 中解析多个大值的程序,会把每条回复都在同一个 turn 内编码、并全部排入 fd 3 的可写缓冲。binding 回复在 seam 层没有字节上限可以约束它,而且这个失败杀掉的是**宿主进程**而不是让本次运行失败:在 highWaterMark 为 64 KiB 的管道上实测,八条 4 MiB 回复会同时缓冲 32.0 MiB。现在回复走一个队列,一次编码并写出一帧,管道写满时等待 `drain`——同样形状实测峰值为 0.0 MiB。编码放在循环内部,因此一条运行已不再需要的排队回复会被 `settled` 检查丢弃,根本不会被序列化。同一个 `settled` 谓词还在 `await fn(...)` 之后、`snapshotJsonValue` 之前守护回复回调,因此一个在结算之后才 resolve 的宽值会在走完它的宽度之前被丢弃——宿主不会为一个结果已定的 run 展开一个迟到的宽值。
|
||||
|
||||
节流不改变任何模型可观测的行为。子进程通过一个持续读取 fd 3 的 pump、按 id 把每条回复匹配到它自己的 `call`,因此到达顺序从来不可观测,而各 binding 本身仍然并发执行——只有宿主的峰值内存与冲刷时延改变。这也正是为什么串行化并不构成对 seam 并发契约的收窄,而那恰是最初推迟此项的理由;那个理由是错的。
|
||||
|
||||
@@ -76,7 +76,7 @@ Status: implemented
|
||||
|
||||
- `tests/boot-write-failure.spec.ts` 对 `spawn` 做 mock,使 fd-3 管道在引导写入时抛出异常(这是真实子进程无法被迫进入的唯一路径),并断言 `run()` resolve 出一个 `worker-exit` 而非 reject。一个同级用例让被 mock 的 `spawn` 同步抛出,并断言 `run()` 仍然 resolve 出一个 `worker-exit`,且会移除它的暂存目录——以被 mock 的 `spawn` 在其 argv 中收到的确切引导路径为准,因此一个同级 worker 的并发暂存不会让它变得不稳定。两者都被隔离在这个 spec 中,因此真实子进程测试套件不受影响。
|
||||
- `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储(fixture 保持在 Node 的 Buffer 池阈值之上),并且不与源帧的 `ArrayBuffer` 共享。
|
||||
- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,Line truncated
|
||||
- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,Line truncated
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -110,4 +110,4 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
seam 的"只 resolve、不 reject"契约在引导写入路径和同步 spawn 失败路径上都得以成立,两者的覆盖率都是被度量的,且两者都不会遗留一个暂存目录。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁,并且散逸的原生输出由它自己的换行符来分隔,而不是由传输分片来分隔。fd-3 残余数据的内存受实际保留的字节数约束,并且两个帧读取器都以一次而非平方级的方式扫描一个不断累积的帧。输出上限放行一个帧所能承载的每一个值,并在加载期拒绝一个非整数的预算。dispose 面对同进程组存活者是真正完全停稳的(以 `graceMs + 2 * CLOSE_REAP_MARGIN_MS` 为界,在进程组已为空时代价为零,并且一旦进程组清空就清除 SIGKILL 定时器,从而一次滞留的 kill 无法击中一个被回收的 pgid),RLIMIT 强制在 soft 和 hard 两者上都保持配置值与继承值中的最严格者(并且 SIGXCPU 诊断不再把一个宿主无法保证的预算说出来),并且从模型创建的线程调用的绑定会完成而不是超时,而且握手帧读取器不再在一个大程序上烧掉 CPU 预算。每处行为修复都附带一个在缺少它时会失败的测试,除了 Problem 一节点出的那四处——分块读取帧(一处系统调用次数的改进)、确认为空后的收尾(它唯一透过 seam 可观测的效应会在信号投递时冻结,而修复前的代码也会产生同样的结果)、共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异取决于不确定的跨管道到达时机),以及 `flush_line` 重排序(它降低后的峰值仍落在 12× 门本就放行的范围内,因此没有配置会有不同表现)——因此其余各处未来若发生回归都会变红。
|
||||
seam 的"只 resolve、不 reject"契约在引导写入路径和同步 spawn 失败路径上都得以成立,两者的覆盖率都是被度量的,且两者都不会遗留一个暂存目录。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁,并且散逸的原生输出由它自己的换行符来分隔,而不是由传输分片来分隔。fd-3 残余数据的内存受实际保留的字节数约束,并且两个帧读取器都以一次而非平方级的方式扫描一个不断累积的帧。输出上限放行一个帧所能承载的每一个值,并在加载期拒绝一个非整数的预算。dispose 面对同进程组存活者是真正完全停稳的(以 `graceMs + 2 * CLOSE_REAP_MARGIN_MS` 为界,在进程组已为空时代价为零,并且一旦进程组清空就清除 SIGKILL 定时器,从而一次滞留的 kill 无法击中一个被回收的 pgid),RLIMIT 强制在 soft 和 hard 两者上都保持配置值与继承值中的最严格者(并且 SIGXCPU 诊断不再把一个宿主无法保证的预算说出来),并且从模型创建的线程调用的绑定会完成而不是超时,而且握手帧读取器不再在一个大程序上烧掉 CPU 预算。每处行为修复都附带一个在缺少它时会失败的测试,除了 Problem 一节点出的那六处——分块读取帧(一处系统调用次数的改进)、确认为空后的收尾(它唯一透过 seam 可观测的效应会在信号投递时冻结,而修复前的代码也会产生同样的结果)、共享的 stdout/stderr 预算(它唯一透过 seam 可观测的差异取决于不确定的跨管道到达时机),以及 `flush_line` 重排序(它降低后的峰值仍落在 12× 门本就放行的范围内,因此没有配置会有不同表现)、节流 binding 回复(32.0 MiB → 0.0 MiB 的峰值降幅位于宿主 fd-3 可写缓冲内部、透过 seam 不可度量),以及在快照之前丢弃迟到的 binding 解析(它的三条断言在修复前同样成立,因此不是一处缺代码即变红的用例)——因此其余各处未来若发生回归都会变红。
|
||||
Reference in new issue
Block a user