docs(code-runtime-python): register the pythonBin load-rejection change; harden PYABS

The review's follow-ups: (1) the product-visible change (an unresolvable
basename pythonBin now fails at load instead of a run-time ENOENT worker-exit)
is registered in the settlement note, paired. (2) PYABS falls back to the bare
name when python3 is not resolvable, instead of interpolating the literal
'undefined' into the wrappers.
This commit is contained in:
Chinesezjc authored and Tianyi Cui committed 2026-08-31 15:06:05 +08:00
1 parent 80e13b3446
commit f931c2128a
4 files changed
+5 -5

No files matched your search

@@ -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: 33c0495fba322a9bc5545c1cdddd3df23d2f8df4
2026-07-31-code-runtime-python-settlement-fixes.zh.md: 522f23ceba03440204e0f5b71334599434caa0b8
2026-07-31-code-runtime-python-settlement-fixes.md: f485f28e12dbffd386fd57755d657252dfed5daf
2026-07-31-code-runtime-python-settlement-fixes.zh.md: 5a6b8673ce313516ada5900a910dd15d126c1af3
@@ -102,7 +102,7 @@ In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/
- `tests/boot-write-failure.spec.ts` mocks `spawn` so the fd-3 pipe throws on the boot write — the one path a real subprocess cannot be coerced into — and asserts `run()` resolves a `worker-exit` rather than rejecting. A sibling case makes the mocked `spawn` throw SYNCHRONOUSLY and asserts `run()` still resolves a `worker-exit` and removes its staging directory, keyed off the exact bootstrap path the mocked `spawn` received in its argv so a sibling worker's concurrent staging cannot flake it. Both are isolated in this spec so the real-subprocess suite is untouched.
- `tests/residual-detach.spec.ts` unit-tests `detachResidual`: the carried copy equals the residual, owns a backing store sized to its own length (fixture kept above Node's Buffer pool threshold), and does not share the source frame's `ArrayBuffer`.
- `tests/runtime.spec.ts` — the output-cap case asserts the `parse-cap - envelope` bound (67108800) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the appliLine truncated
- `tests/runtime.spec.ts` — the output-cap case asserts the `parse-cap - envelope` bound (67108800) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. A native-write case writes 200 KiB with no newline via `os.write` under a raised `maxLogBytes` and asserts it reads back as EXACTLY one log entry (proving stray output is aggregated by line, not split at pipe-chunk boundaries); a companion writes `b"one\ntwo\nthree"` and asserts three entries (proving real newlines still delimit). A newline-free-flood case writes 2 MiB under a 4 KiB `maxLogBytes` and asserts the capture ends at the truncation marker and stays under budget (proving the residual is bounded by the ledger, not buffered whole); a NUL-flood companion writes 4000 newline-free NULs under the same budget and asserts truncation (proving the residual is charged by SERIALIZED cost, ~6× raw, measured without allocating the escaped copy); an illegal-UTF-8 case paces single-byte `\xff` writes under a 3072-byte budget with `Buffer.concat` wrapped to measure the peak merged buffer, asserting it stays under 2048 (charged at the U+FFFD width 3 the residual flushes near 1024 raw bytes; a raw-byte undercount would let it reach ~3072, so the bound discriminates); a CESU-8/overlong case paces the structurally-well-formed but illegal `ED A0 80` one byte at a time and asserts the same peak bound (charged at the true 9 per sequence it flushes early; charging the structural width 3 triples the peak, so reverting the per-lead range check turns it red); a broken-multibyte case writes a 3-byte lead then a fresh ASCII byte in separate chunks and asserts both a captured `A` and a U+FFFD (exercising `accrueStrayCost`'s cross-chunk broken-sequence branch); a post-truncation case writes a 108-byte payload (under the smallest PIPE_BUF, so one atomic write) whose first line exhausts a 64-byte budget and asserts the second line is dropped (exercising the post-truncation admit no-op in one `data` callback, no v8-ignore); a short-escape case writes a line mixing a tab, quote, backslash, a `\uXXXX` control, a multibyte character, and ASCII, asserting it round-trips verbatim (exercising every branch of `jsonStringCostUpTo`); a reassembly case writes a payload spanning every valid multibyte lead class (E0-range, plain 3-byte, F0, and F4) past the pipe buffer and asserts it round-trips with no U+FFFD (exercising `accrueStrayCost`'s per-lead ranges and cross-chunk reassembly); a lone-surrogate case forges an fd-3 `log` frame flooding 1000 `\ud800` escapes under a 4 KiB budget and asserts truncation (the count sits in the window where charging 3 bytes would admit and 6 bytes truncates, proving the surrogate is charged its full escaped width); a stray-sealing case paces 60000 single-byte newline-free `os.write(1, …)` calls under a raised budget with `Buffer.concat` wrapped to measure copy volume, asserting the trickle coalesces to one entry and the cumulative copy stays under a measured 256 KiB threshold (the sealed shape copies ~120 KB, the re-merge shape ~538 KB, so reverting the seal to a re-merge turns the assertion red — proving the fragment list seals into blocks past `MAX_PENDING_CHUNKS`). A closeDeadline-flush case has the leader write a newline-free diagnostic then spawn a `setsid` orphan holding the pipes open, and asserts the diagnostic survives in `logs` (proving the residual is flushed before the deadline destroys the streams). The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. A dispose-after-resolve case asserts `dispose()` of a completed run with a same-group survivor returns only after the survivor stops executing (proving the run stays in `live` until its group is reaped), with an `expect(afterDispose).toBeGreaterThan(0)` guard so the frozen-heartbeat assertion cannot pass vacuously when the file was never written. A deadline case busy-blocks the event loop past both timers and asserts the survivor's heartbeat freezes (proving the poll's deadline arm sends SIGKILL itself rather than cancelling the unfired escalation). The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out; a companion case abandons a thread's call so its loop closes, then answers it before a later binding — asserting the pump survives the closed-loop `call_soon_threadsafe` (host-gated ordering makes it deterministic, fail-before hangs the later binding to the wall clock). The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the appliLine truncated
## Alternatives considered
@@ -102,7 +102,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 用例断言 `parse-cap - envelope` 上界(67108800)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,Line truncated
- `tests/runtime.spec.ts`:output-cap 用例断言 `parse-cap - envelope` 上界(67108800)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。一个 native-write 用例在抬高后的 `maxLogBytes` 之下,通过 `os.write` 写入 200 KiB 且不含换行符,断言它回读时恰好是一条日志条目(证明散逸输出是按行聚合的,而不是在管道分片边界处被切开);一个配套用例写入 `b"one\ntwo\nthree"`,断言得到三条条目(证明真正的换行符仍然起分隔作用)。一个 newline-free-flood 用例在一个 4 KiB 的 `maxLogBytes` 之下写入 2 MiB,断言捕获终止于截断标记且保持在预算之内(证明残余数据受账本约束,而不是被整体缓冲);一个 NUL-flood 配套用例在同一预算之下写入 4000 个不含换行符的 NUL,断言发生截断(证明残余数据是按序列化开销计费的,约为原始的 6 倍,且在度量时不分配转义后的副本);一个 illegal-UTF-8 用例在一个 3072 字节的预算之下控速发出单字节 `\xff` 写入,并对 `Buffer.concat` 做包装以度量峰值合并缓冲区,断言它保持在 2048 之下(按 U+FFFD 宽度 3 计费时残余数据在约 1024 原始字节处冲刷;一次原始字节的少计会让它达到约 3072,因此该界限具有区分力);一个 CESU-8/overlong 用例把结构良构但非法的 `ED A0 80` 一次一个字节地控速发出,断言同样的峰值界限(按每序列真实的 9 计费时它提前冲刷;按结构宽度 3 计费会使峰值增至三倍,因此把逐前导字节范围检查回退会使它变红);一个 broken-multibyte 用例在分开的分片里先写入一个 3 字节的前导字节、再写入一个新的 ASCII 字节,断言同时捕获到一个 `A` 和一个 U+FFFD(覆盖 `accrueStrayCost` 的跨分片断裂序列分支);一个 post-truncation 用例写入一个 108 字节的载荷(小于最小的 PIPE_BUF,因此是一次原子写入),其首行耗尽一个 64 字节的预算,断言第二行被丢弃(覆盖单次 `data` 回调中的截断后准入空操作,无需 v8-ignore);一个 short-escape 用例写入一行混合了制表符、引号、反斜杠、一个 `\uXXXX` 控制字符、一个多字节字符和 ASCII 的内容,断言它原样完成往返(覆盖 `jsonStringCostUpTo` 的每一条分支);一个 reassembly 用例写入一个跨越每个合法多字节前导字节类别(E0 范围、普通 3 字节、F0 和 F4)、越过管道缓冲区的载荷,断言它原样完成往返且不含 U+FFFD(覆盖 `accrueStrayCost` 的逐前导字节范围与跨分片重组);一个 lone-surrogate 用例在一个 4 KiB 预算之下伪造一个以 1000 个 `\ud800` 转义洪泛的 fd-3 `log` 帧,断言发生截断(该计数正落在计 3 字节会放行、计 6 字节则截断的窗口内,证明该代理项是按其完整转义宽度计费的);一个 stray-sealing 用例在抬高后的预算之下控速发出 60000 次单字节、不含换行符的 `os.write(1, …)` 调用,并对 `Buffer.concat` 做包装以度量复制量,断言这股细流合并为一条条目、且累积复制量保持在一个实测的 256 KiB 阈值之下(封存后的形态复制约 120 KB,重新合并的形态复制约 538 KB,因此把封存回退成重新合并会使该断言变红——证明分片列表在越过 `MAX_PENDING_CHUNKS` 后封存为块)。一个 closeDeadline-flush 用例让 leader 写入一段不含换行符的诊断,随后 spawn 一个持有管道不放的 `setsid` 孤儿进程,断言该诊断在 `logs` 中存留下来(证明残余数据在截止时间销毁流之前被冲刷)。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。一个 dispose-after-resolve 用例断言,对一个已完成、且存在同进程组存活者的运行调用 `dispose()`,只有在该存活者停止执行之后才返回(证明该运行会一直留在 `live` 中,直到它的进程组被回收),并带有一个 `expect(afterDispose).toBeGreaterThan(0)` 守卫,使得当心跳文件从未被写入时,冻结心跳的断言不会被空洞地通过。一个 deadline 用例忙阻塞事件循环越过两个定时器,断言该存活者的心跳冻结(证明轮询的截止时间分支自身发送 SIGKILL,而不是取消尚未触发的升级)。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时;一个配套用例放弃某个线程的调用,使其事件循环关闭,Line truncated
## Alternatives considered
@@ -14,7 +14,7 @@ import type { Config } from '../src/index.ts'
// interpreters only reachable through the caller's PATH (Nix, pyenv). Baking
// the resolved absolute path mirrors what resolvePythonBin does for the product
// spawn.
const PYABS = resolvePythonBin('python3')
const PYABS = resolvePythonBin('python3') ?? 'python3'
import type { CodeBindingFunction, CodeJsonValue, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**