Browse Source

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.
Chinesezjc 2 months ago
parent
commit
6cb70e6e69

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml

@@ -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: 27e6a78e5d164e31ae4d5bd24170e5c254d37e44
-2026-07-31-code-runtime-python-settlement-fixes.zh.md: 668f17cf2b504eb339b11fe311cc3593c99c569b
+2026-07-31-code-runtime-python-settlement-fixes.md: fbf562ac7eeb407d137905649160916c6ce63c4f
+2026-07-31-code-runtime-python-settlement-fixes.zh.md: 594d5074a5b96885416c4019d228eaafd10b08ae

+ 24 - 6
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md

@@ -1,4 +1,4 @@
-# Agent Note: Three settlement and framing fixes in the CPython backend
+# Agent Note: Settlement, framing, and lifecycle fixes in the CPython backend
 
 Status: implemented
 
@@ -6,11 +6,11 @@ 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` and rejects `run()` only for seam misuse. Three defects broke that contract in ways unit coverage did not surface, because each hid behind a `/* v8 ignore */`, a captured-callable comment that read as a fix but was not, or a memory effect invisible through the seam. They were found by review of the backend as it stood, not by a failing test, so each fix ships with a test that fails without it.
+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 outlives the fiber. 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, or a cross-event-loop completion that silently deadlocked. Each fix ships with a test that fails without it.
 
 ## Decision
 
-Three independent corrections, each in the package that owns the defect.
+Six independent corrections, each in the package that owns the defect.
 
 ### Boot-write failure no longer rejects run()
 
@@ -24,11 +24,23 @@ In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/
 
 Also in `src/index.ts`, after the newline loop over a `Buffer.concat` of the pending fd-3 chunks, the leftover partial line was carried forward as the `subarray` VIEW it was sliced to. A view keeps the entire concat backing allocation alive, so a large frame followed by a tiny trailing fragment pinned a whole frame's worth of memory while `pendingBytes` — set to the fragment's length — reported far less than was retained. The residual is now detached into a fresh right-sized `Buffer` via the exported `detachResidual` helper, letting the concat allocation be collected and keeping `pendingBytes` an honest measure.
 
+### Output-cap load bound is ceiling minus envelope, not divided by six
+
+The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges `Buffer.byteLength(JSON.stringify(text))` and `checkDoneValue` measures the escaped form — so a payload admitted under the cap occupies at most `cap + envelope` on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now `FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES`, and the unused `MAX_JSON_ESCAPE_EXPANSION` constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps.
+
+### Same-group survivors are reaped before the fiber goes quiescent
+
+A model program can leave a descendant in the child's OWN process group (no `setsid`, so `kill(-pid)` reaches it) that ignores SIGTERM but releases the inherited stdout/stderr/fd-3 pipes. The leader then exits, its `close` fires because the pipes drained, and settlement runs while that descendant is still alive. `kill()` arms an `unref`'d SIGKILL timer after SIGTERM; the fix is that `settle()` no longer resolves the run's `finished` promise immediately when an escalation is in flight. Instead, when `killing` is set and the process group is not yet empty (`process.kill(-pid, 0)` does not throw ESRCH), it polls the group on a REF'd timer, bounded by `graceMs + CLOSE_REAP_MARGIN_MS`, and resolves `finished` only once the group has emptied. The ref'd poll is the load-bearing part: it keeps the host event loop alive until the SIGKILL has actually reaped the group, so even a short-lived host — a one-shot headless run, a config subprocess — cannot exit and reparent the survivor to init. In the normal case (the leader was the only member) the first probe returns ESRCH and settlement resolves with zero added latency. `teardown()` awaits each run's `finished`, so disposal is genuinely quiescent, matching its JSDoc.
+
+### Binding replies complete on the calling loop's thread
+
+Also in `py/bootstrap.py`, a binding reply Future is created on the loop that ran `dispatch`. When the model calls a binding from a worker THREAD via `asyncio.run(tools.x(...))`, that Future belongs to the thread's loop, not the main loop where `_pump_replies` reads the reply. `asyncio.Future` is not thread-safe: completing it from another thread does not wake its own loop, so the direct `set_result`/`set_exception` left the awaiting thread stranded and the run degraded to a wall-clock timeout. Each pending entry now records its Future's loop alongside the Future, and `_pump_replies` completes it via that loop's `call_soon_threadsafe`. The shared `pending`/`next_id` state is guarded by a `threading.Lock` held across the id claim, the fd-3 write, and the counter advance, so concurrent callers cannot interleave frames out of the id order the host requires.
+
 ## Testing
 
 - `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. Isolated in its own 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, and does not share the source frame's `ArrayBuffer`.
-- `tests/runtime.spec.ts` adds a real-subprocess case where four daemon threads emit unterminated writes up to the moment the body returns and settlement flushes, repeated so the interleave lands; the run must complete cleanly. A pure data race has no single bad input to reject, so this maximizes overlap rather than asserting a deterministic rejection.
+- `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. 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. 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.
 
 ## Alternatives considered
 
@@ -40,6 +52,12 @@ Also in `src/index.ts`, after the newline loop over a `Buffer.concat` of the pen
 
 **Assert the residual memory effect through the seam.** Rejected: the retained allocation is not observable through `CodeRunResult`, so a black-box test could not distinguish fixed from unfixed. Extracting `detachResidual` makes the backing-store invariant a deterministic unit test instead.
 
+**Reap the same-group survivor with a fire-and-forget `unref`'d SIGKILL timer alone.** Rejected: an `unref`'d timer does not keep the host alive, so a host that exits within the grace window (a one-shot run, a config subprocess) never fires the SIGKILL and the survivor is reparented to init — the same "no subprocess outlives the fiber" violation in a different shape, and `teardown`'s "await each child's exit" JSDoc would be false. Awaiting the group's death on a ref'd poll keeps the host alive exactly long enough to reap, at zero cost in the common empty-group case.
+
+**Assert the reap with `process.kill(pid, 0)` throwing ESRCH.** Rejected: a SIGKILL'd process lingers as a zombie until its parent `wait()`s it, and in a container whose PID 1 does not reap orphans the signal-0 probe keeps succeeding, so the assertion would false-fail cross-environment. A heartbeat file that stops advancing detects "no longer executing," which a reaped process and a zombie both satisfy.
+
+**Complete the cross-loop Future with a plain `set_result` and rely on the GIL.** Rejected: the GIL serializes bytecode but does not make `asyncio.Future` cross-loop-safe — completing a Future from a thread other than its loop's does not schedule its callbacks or wake the loop. `call_soon_threadsafe` on the owning loop is the documented mechanism.
+
 ## Consequences
 
-The seam's resolve-don't-reject contract now holds on the boot-write path, and its coverage is measured rather than ignored. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush — negligible against the os.write already on that path. Fd-3 residual memory is bounded by the actual retained bytes, and `pendingBytes` measures what it claims. Each fix carries a test that fails without it, so a future regression on any of the three goes red.
+The seam's resolve-don't-reject contract holds on the boot-write path with measured coverage. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush. Fd-3 residual memory is bounded by the actual retained bytes. The output caps admit every value a frame can carry. Disposal is genuinely quiescent against a same-group survivor — bounded by the existing grace budget, zero-cost when the group is already empty — and bindings called from model-created threads complete instead of timing out. Each fix carries a test that fails without it, so a future regression on any of the six goes red.

+ 24 - 6
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: CPython 后端的三处结算与分帧修复
+# Agent Note: CPython 后端中的结算、分帧与生命周期修复
 
 Status: implemented
 
@@ -6,11 +6,11 @@ Status: implemented
 
 ## Problem
 
-用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`。三个缺陷以单元测试覆盖率无法暴露的方式破坏了这一契约,因为它们各自藏在一处 `/* v8 ignore */` 之后、藏在一条读起来像修复但实际并非修复的"捕获可调用对象"注释之后,或藏在一处透过 seam 不可见的内存效应之后。这些缺陷是通过审查当时的后端代码发现的,而非由某个失败的测试发现,因此每处修复都附带一个在缺少该修复时会失败的测试。
+用于 Code Mode 的 CPython 子进程后端建立在 [fd-3 帧协议](../architecture/2026-07-31-code-runtime-python-fd3-protocol.md)之上,把每个程序结果都 resolve 成一个 `CodeRunResult`,仅在 seam 被误用时才 reject `run()`,并且会 dispose 到完全停稳,从而没有任何子进程存活得比 fiber 更久。一连串审查暴露出一些缺陷,它们以单元测试覆盖率无法捕获的方式破坏了这些契约:每一个都藏在一处 `/* v8 ignore */` 之后、一个读起来像修复但实际并非修复的捕获可调用对象之后、一处透过 seam 不可见的内存效应之后、一处重复计数的加载期上界之后、一处存活者能够熬过的进程组升级之后,或者一处静默死锁的跨事件循环完成之后。每处修复都附带一个在缺少它时会失败的测试。
 
 ## Decision
 
-三处相互独立的修正,各自位于拥有对应缺陷的包中。
+六处相互独立的修正,各自位于拥有对应缺陷的包中。
 
 ### Boot-write failure no longer rejects run()
 
@@ -24,11 +24,23 @@ Status: implemented
 
 同样在 `src/index.ts` 中,在对待处理 fd-3 分片的 `Buffer.concat` 结果按换行符做循环之后,剩余的不完整行被以它被切出的 `subarray` 视图形式向前传递。视图会使整个 concat 的底层分配保持存活,因此一个大帧后面跟着一个极小的尾部片段,会钉住整整一帧大小的内存,而 `pendingBytes`(被设为该片段的长度)报告的值远小于实际保留的内存。现在,残余数据通过导出的 `detachResidual` 辅助函数被分离到一个大小恰当的新 `Buffer` 中,从而让 concat 分配得以被回收,并使 `pendingBytes` 成为一个诚实的度量值。
 
+### Output-cap load bound is ceiling minus envelope, not divided by six
+
+那处在加载期拒绝比单个 fd-3 帧所能承载更大的 `maxLogBytes`/`maxValueBytes` 的检查,会把帧上限除以六以应对最坏情况下的转义膨胀。但这两项预算都是以已转义的序列化字节来计量的:宿主日志账本按 `Buffer.byteLength(JSON.stringify(text))` 计费,而 `checkDoneValue` 度量的是转义后的形式,因此一个在上限之内被放行的载荷在传输时最多占用 `cap + envelope`;转义已经包含在计费之内,不能再被乘一次。现在该上界为 `FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES`,未使用的 `MAX_JSON_ESCAPE_EXPANSION` 常量已被删除。旧的上界并非不安全(它是放行不足),但它静默地禁止了合法的大上限。
+
+### Same-group survivors are reaped before the fiber goes quiescent
+
+模型程序可能在子进程自己的进程组里(没有 `setsid`,因此 `kill(-pid)` 能到达它)留下一个后代,它忽略 SIGTERM,但释放了继承而来的 stdout/stderr/fd-3 管道。随后 leader 退出,由于管道已被抽空,它的 `close` 触发,于是结算在那个后代仍存活时运行。`kill()` 在 SIGTERM 之后装设一个 `unref` 的 SIGKILL 定时器;本次修复是,当有一次升级正在进行时,`settle()` 不再立即 resolve 该次运行的 `finished` promise。取而代之的是,当 `killing` 被置位且进程组尚未为空时(`process.kill(-pid, 0)` 不抛出 ESRCH),它在一个 ref 的定时器上轮询该进程组,以 `graceMs + CLOSE_REAP_MARGIN_MS` 为界,仅当进程组已清空后才 resolve `finished`。这个 ref 的轮询是承重部分:它让宿主事件循环保持存活,直到 SIGKILL 真正回收了该进程组,因此即使是一个短命的宿主(一次性的 headless 运行、一个配置子进程)也无法退出并把存活者 reparent 给 init。在正常情况下(leader 是唯一成员),第一次探测返回 ESRCH,结算以零附加延迟完成 resolve。`teardown()` 会 await 每次运行的 `finished`,因此 dispose 是真正完全停稳的,与其 JSDoc 相符。
+
+### Binding replies complete on the calling loop's thread
+
+同样在 `py/bootstrap.py` 中,一个绑定回复 Future 是在运行 `dispatch` 的那个事件循环上创建的。当模型通过 `asyncio.run(tools.x(...))` 从一个工作线程调用某个绑定时,该 Future 属于该线程的事件循环,而不是 `_pump_replies` 读取回复的主事件循环。`asyncio.Future` 不是线程安全的:从另一个线程完成它并不会唤醒它自己的事件循环,因此直接的 `set_result`/`set_exception` 会让那个正在等待的线程被搁置,该次运行退化为墙钟超时。现在每个待处理条目都会在记录 Future 的同时记录其 Future 所属的事件循环,`_pump_replies` 通过该事件循环的 `call_soon_threadsafe` 来完成它。共享的 `pending`/`next_id` 状态由一把 `threading.Lock` 保护,该锁跨越 id 认领、fd-3 写入和计数器推进这三步持有,因此并发调用方无法以违反宿主所要求的 id 顺序来交错帧。
+
 ## Testing
 
 - `tests/boot-write-failure.spec.ts` 对 `spawn` 做 mock,使 fd-3 管道在引导写入时抛出异常(这是真实子进程无法被迫进入的唯一路径),并断言 `run()` resolve 出一个 `worker-exit` 而非 reject。它被隔离在自己的 spec 中,因此真实子进程测试套件不受影响。
-- `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储,并且不与源帧的 `ArrayBuffer` 共享。
-- `tests/runtime.spec.ts` 新增一个真实子进程用例:四个 daemon 线程持续发出未结束的写入,直到函数体返回、结算执行 flush 的那一刻,并反复运行以让交错真正出现;该次运行必须干净地完成。纯数据竞态没有单一的坏输入可供 reject,因此该测试最大化重叠而非断言一个确定性的 reject。
+- `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储(fixture 保持在 Node 的 Buffer 池阈值之上),并且不与源帧的 `ArrayBuffer` 共享。
+- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时。
 
 ## Alternatives considered
 
@@ -40,6 +52,12 @@ Status: implemented
 
 **通过 seam 断言残余数据的内存效应。** 已否决:被保留的分配透过 `CodeRunResult` 不可观测,因此黑盒测试无法区分已修复与未修复。转而抽取出 `detachResidual`,把底层存储的不变量变成一个确定性的单元测试。
 
+**仅用一个发后不理的 `unref` SIGKILL 定时器来回收同进程组存活者。** 已否决:`unref` 的定时器不会让宿主保持存活,因此一个在宽限窗口内退出的宿主(一次性运行、一个配置子进程)永远不会触发 SIGKILL,存活者被 reparent 给 init,这是同一个"没有子进程存活得比 fiber 更久"的违规换了个形态,而且 `teardown` 的"await 每个子进程退出"的 JSDoc 会变为不实。在一个 ref 的轮询上 await 进程组的消亡,让宿主恰好保持存活足够长以完成回收,在常见的空进程组情形下代价为零。
+
+**用 `process.kill(pid, 0)` 抛出 ESRCH 来断言回收。** 已否决:一个被 SIGKILL 的进程会作为僵尸进程滞留,直到它的父进程 `wait()` 它,而在一个 PID 1 不回收孤儿进程的容器里,signal-0 探测会持续成功,因此该断言会在跨环境时误报失败。一个停止推进的心跳文件检测的是"不再执行",而被回收的进程和僵尸进程都满足这一点。
+
+**用一个普通的 `set_result` 完成跨事件循环的 Future 并依赖 GIL。** 已否决:GIL 序列化字节码,但并不使 `asyncio.Future` 跨事件循环安全:从一个并非其事件循环所属的线程完成一个 Future,不会调度它的回调,也不会唤醒该事件循环。在拥有该 Future 的事件循环上调用 `call_soon_threadsafe` 才是有文档记载的机制。
+
 ## Consequences
 
-现在 seam 的"只 resolve、不 reject"契约在引导写入路径上得以成立,且其覆盖率是被度量而非被忽略的。日志捕获现在是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁,这相对于该路径上已有的 os.write 可以忽略不计。fd-3 残余数据的内存现在受实际保留的字节数约束,且 `pendingBytes` 度量的正是它所声称的值。每处修复都附带一个在缺少它时会失败的测试,因此这三处中任何一处未来若发生回归都会变红。
+seam 的"只 resolve、不 reject"契约在引导写入路径上得以成立,且覆盖率是被度量的。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁。fd-3 残余数据的内存受实际保留的字节数约束。输出上限放行一个帧所能承载的每一个值。dispose 面对同进程组存活者是真正完全停稳的(以既有的宽限预算为界,在进程组已为空时代价为零),并且从模型创建的线程调用的绑定会完成而不是超时。每处修复都附带一个在缺少它时会失败的测试,因此这六处中任何一处未来若发生回归都会变红。

+ 80 - 28
packages/code-runtime/code-runtime-python/py/bootstrap.py

@@ -616,8 +616,25 @@ async def _run(channel: ProtocolChannel) -> None:
     )
 
     # 2. Wire the tools proxies and the ack.
-    pending: dict[int, asyncio.Future[Any]] = {}
+    #
+    # Each entry records the reply Future AND the loop it was created on. Model
+    # code may call a binding from a THREAD it started, spelled
+    # ``asyncio.run(tools.x(...))`` or its own new loop in that thread, so a
+    # Future here can belong to a loop other than the one ``_pump_replies`` runs
+    # on. ``asyncio.Future`` is not thread-safe: completing it from another
+    # thread does not wake its own loop, so the pump schedules the completion on
+    # the owning loop via ``call_soon_threadsafe`` (see ``_pump_replies``) rather
+    # than calling ``set_result`` directly.
+    pending: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future[Any]]] = {}
     next_id = 0
+    # Serializes the id claim + write + counter advance in ``dispatch`` against
+    # both other binding-calling threads and the pump's ``pop``. ``dispatch`` may
+    # run concurrently on several loops/threads, and the host answers a ``call``
+    # only when its id is the exact successor of the last one — so ids must reach
+    # the wire in the order they are claimed. Holding this lock across the write
+    # (not just the counter arithmetic) is what keeps two threads' frames from
+    # interleaving on fd 3 out of id order, which the host would reject.
+    pending_lock = threading.Lock()
 
     error_classes: dict[str, type] = {}
 
@@ -646,25 +663,35 @@ async def _run(channel: ProtocolChannel) -> None:
         # state it retains to a single number. A frame that never reaches the
         # host must therefore not consume an id, so the counter advances only
         # once the write has succeeded.
-        call_id = next_id
-        fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
-        pending[call_id] = fut
-        try:
-            channel.send_sync(
-                {
-                    "type": "call",
-                    "id": call_id,
-                    "global": global_name,
-                    "name": name,
-                    "args": args,
-                }
-            )
-        except (TypeError, ValueError) as exc:
-            pending.pop(call_id, None)
-            raise call_failure(
-                f"binding arguments must be lossless JSON: {exc}"
-            ) from exc
-        next_id += 1
+        #
+        # The whole claim-write-advance runs under ``pending_lock`` because a
+        # binding may be called from more than one thread/loop at once (the model
+        # can start a thread that runs ``asyncio.run(tools.x(...))``). Without
+        # the lock two callers could claim the same id, or write their frames to
+        # fd 3 in an order that does not match their ids — either of which the
+        # host rejects as an out-of-sequence call. The Future's own loop is
+        # captured here so ``_pump_replies`` can complete it thread-safely.
+        loop = asyncio.get_event_loop()
+        with pending_lock:
+            call_id = next_id
+            fut: asyncio.Future[Any] = loop.create_future()
+            pending[call_id] = (loop, fut)
+            try:
+                channel.send_sync(
+                    {
+                        "type": "call",
+                        "id": call_id,
+                        "global": global_name,
+                        "name": name,
+                        "args": args,
+                    }
+                )
+            except (TypeError, ValueError) as exc:
+                pending.pop(call_id, None)
+                raise call_failure(
+                    f"binding arguments must be lossless JSON: {exc}"
+                ) from exc
+            next_id += 1
         try:
             return await fut
         except _BindingRejection as exc:
@@ -689,7 +716,9 @@ async def _run(channel: ProtocolChannel) -> None:
 
     # 3. Start a reply-pump task before the run message: replies can arrive
     # interleaved with the run's own binding traffic.
-    reply_task = asyncio.get_event_loop().create_task(_pump_replies(channel, pending))
+    reply_task = asyncio.get_event_loop().create_task(
+        _pump_replies(channel, pending, pending_lock)
+    )
 
     # 4. Read the run message.
     run = channel.read_frame()
@@ -793,28 +822,51 @@ async def _run(channel: ProtocolChannel) -> None:
 
 
 async def _pump_replies(
-    channel: ProtocolChannel, pending: dict[int, asyncio.Future[Any]]
+    channel: ProtocolChannel,
+    pending: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future[Any]]],
+    pending_lock: "threading.Lock",
 ) -> None:
     """Background task: read reply frames and settle pending futures.
 
     Cancelled after ``done`` is posted. Unknown ids and post-settlement replies
     are ignored (mirrors the worker backend's hostile-peer stance, though here
     the host is the trusted side; the guards defend against races).
+
+    A pending Future may belong to a loop other than this pump's — the model can
+    call a binding from a thread running its own loop (``asyncio.run(tools.x())``).
+    ``asyncio.Future`` is not thread-safe, so the completion is scheduled on the
+    Future's OWN loop via ``call_soon_threadsafe`` rather than mutated here; a
+    direct ``set_result`` would never wake the waiting loop and the call would
+    hang to the wall clock. The ``pop`` shares ``pending_lock`` with ``dispatch``
+    so a reply cannot race the claim that registers its id.
     """
 
+    def complete(fut: asyncio.Future[Any], ok: bool, value: Any, message: Any) -> None:
+        # Runs on the Future's own loop. `done()` re-checked here because
+        # cancellation or a duplicate reply may have settled it between the pop
+        # and this callback.
+        if fut.done():
+            return
+        if ok:
+            fut.set_result(value)
+        else:
+            fut.set_exception(_BindingRejection(str(message)))
+
     while True:
         frame = await channel.read_frame_async()
         if frame is None:
             return
         if frame.get("type") != "reply":
             continue
-        fut = pending.pop(frame.get("id"), None)
-        if fut is None or fut.done():
+        with pending_lock:
+            entry = pending.pop(frame.get("id"), None)
+        if entry is None:
             continue
-        if frame.get("ok"):
-            fut.set_result(frame.get("value"))
-        else:
-            fut.set_exception(_BindingRejection(str(frame.get("message"))))
+        loop, fut = entry
+        ok = bool(frame.get("ok"))
+        value = frame.get("value")
+        message = frame.get("message")
+        loop.call_soon_threadsafe(complete, fut, ok, value, message)
 
 
 _SCALAR_RE = re.compile(

+ 60 - 16
packages/code-runtime/code-runtime-python/src/index.ts

@@ -217,6 +217,17 @@ const FRAME_ENVELOPE_BYTES = 64
  */
 const CLOSE_REAP_MARGIN_MS = 2_000
 
+/**
+ * Interval between process-group liveness probes while settlement waits for an
+ * escalated SIGKILL to empty the group (see the `killing` branch in
+ * {@link PythonCodeRuntime.execute}'s settle). A poll rather than an event
+ * because the group members are the model's own descendants, which the host does
+ * not `wait()` for and gets no exit signal from; the probe is a signal-0
+ * `process.kill(-pid, 0)`, so the interval only bounds how promptly a now-empty
+ * group is noticed, capped by `graceMs + CLOSE_REAP_MARGIN_MS`.
+ */
+const GROUP_REAP_POLL_MS = 50
+
 /**
  * Extract a human message from an unknown thrown value.
  *
@@ -961,6 +972,7 @@ export class PythonCodeRuntime extends CodeRuntime {
       // Escalate SIGTERM → grace → SIGKILL on the entire process group. Idempotent
       // via `killing`.
       let killing = false
+      let graceTimer: NodeJS.Timeout | undefined
       // A backstop for the one case `close` cannot cover: model code that starts
       // a descendant with `os.setsid()`/`start_new_session=True` moves it into a
       // fresh process group, so the SIGTERM/SIGKILL aimed at the child's group
@@ -983,22 +995,29 @@ export class PythonCodeRuntime extends CodeRuntime {
         if (killing) return
         killing = true
         killGroup('SIGTERM')
-        // The SIGKILL is left to fire on its own timer and is deliberately NOT
-        // cancelled at settlement. A model program can leave a descendant in the
-        // SAME process group `kill(-pid)` targets — no setsid, so it stays in the
-        // group — that ignores SIGTERM but releases the inherited stdout/stderr/
-        // fd-3 pipes: the leader then exits, its `close` fires (the pipes drained),
-        // and settle() runs while that descendant is still alive. Cancelling the
-        // timer there would strand it, breaking "no subprocess outlives the fiber".
-        // Letting the timer elapse SIGKILLs the whole group, reaching the survivor;
-        // `killGroup` swallows ESRCH, so firing against an already-dead group (the
-        // normal case, where the leader was the only member) is harmless. `unref`
-        // so a pending SIGKILL never keeps the host process alive after run()
-        // resolves. (A setsid-escaped orphan in a FRESH group is the different case
-        // `closeDeadline` in finish() covers, since `close` never fires there.)
-        const graceTimer = setTimeout(() => { killGroup('SIGKILL') }, this.config.graceMs)
+        // Escalate to SIGKILL after the grace window. The timer is `unref`'d so a
+        // pending SIGKILL never keeps the host process alive on its own; the
+        // guarantee that a same-group survivor is actually reaped before the fiber
+        // goes quiescent is enforced by settle() awaiting the group's death (see
+        // there), NOT by this timer firing during host lifetime. A setsid-escaped
+        // orphan in a FRESH group is the different case `closeDeadline` in finish()
+        // covers, since `close` never fires there.
+        graceTimer = setTimeout(() => { killGroup('SIGKILL') }, this.config.graceMs)
         graceTimer.unref()
       }
+      // True once the group has no members left: a signal-0 probe to the whole
+      // group (`kill(-pid, 0)`) throws ESRCH when empty (EPERM would still mean a
+      // member exists). Only meaningful once a spawn produced a pid.
+      const groupEmpty = (): boolean => {
+        /* v8 ignore next -- pid is always defined once escalation runs; the guard narrows the type. */
+        if (child.pid === undefined) return true
+        try {
+          process.kill(-child.pid, 0)
+          return false
+        } catch (error: unknown) {
+          return (error as NodeJS.ErrnoException).code === 'ESRCH'
+        }
+      }
 
       let finishResolve!: () => void
       const finished = new Promise<void>((done) => { finishResolve = done })
@@ -1017,7 +1036,8 @@ export class PythonCodeRuntime extends CodeRuntime {
         // same-group descendant that ignored SIGTERM but released the pipes lets
         // `close` fire (and settle() run) while it is still alive, so the pending
         // SIGKILL must remain armed to reap it (see kill()). The timer is
-        // `unref`'d, so leaving it pending cannot keep the host process alive.
+        // `unref`'d; quiescence does not depend on it firing during host lifetime
+        // — `finished` (below) is withheld until the group is confirmed empty.
         if (closeDeadline !== undefined) clearTimeout(closeDeadline)
         // Drop from `live` only at settlement (close / pid-less spawn failure),
         // NOT at finish(): between finish() and the child's `close` the child
@@ -1042,8 +1062,32 @@ export class PythonCodeRuntime extends CodeRuntime {
           // tracked; the directory holds no secret, only a copy of two
           // checked-in scripts.
         }
-        finishResolve()
         resolve({ ...result, logs })
+        // `finished` is what teardown awaits to honor "no subprocess outlives the
+        // fiber". When no escalation ran (normal completion, no kill) or the group
+        // is already empty, resolve it now. Otherwise a same-group descendant that
+        // ignored SIGTERM but released the pipes is still alive here (its `close`
+        // is what got us to settle); withhold `finished` until the grace-window
+        // SIGKILL has emptied the group. The poll timers are REF'd on purpose: a
+        // short-lived host (a one-shot headless run, a config subprocess) would
+        // otherwise exit before the unref'd SIGKILL timer fired, reparenting the
+        // survivor to init — the leak this await exists to prevent. The wait is
+        // bounded by the same graceMs + margin the SIGKILL escalation uses, so a
+        // truly unreapable process (it cannot be, since it is in the group
+        // `kill(-pid)` reaches) could not hang disposal.
+        if (!killing || groupEmpty()) {
+          finishResolve()
+          return
+        }
+        const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
+        const pollGroup = (): void => {
+          if (groupEmpty() || Date.now() >= deadline) {
+            finishResolve()
+            return
+          }
+          setTimeout(pollGroup, GROUP_REAP_POLL_MS)
+        }
+        pollGroup()
       }
 
       const finish = (result: Omit<CodeRunResult, 'logs'>): void => {

+ 81 - 39
packages/code-runtime/code-runtime-python/tests/runtime.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, readdirSync, realpathSync } from 'node:fs'
+import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
 import { mkdtemp, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { basename, dirname, join } from 'node:path'
@@ -1932,72 +1932,73 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
     // it does not hold — here by giving the Popen child DEVNULL streams and
     // letting close_fds drop fd 3. The leader then writes `done` and exits, its
     // `close` fires because the pipes drained, and settle() runs while that
-    // descendant is still alive. If settle() cancelled the grace-window SIGKILL
-    // the descendant would outlive the fiber; leaving the unref'd timer to fire
-    // SIGKILLs the whole group and reaps it.
+    // descendant is still alive. settle() then keeps a REF'd poll alive until the
+    // grace-window SIGKILL has emptied the whole process group, so the host cannot
+    // exit and reparent the survivor to init: no subprocess outlives the fiber.
     //
     // The descendant must have SIG_IGN installed BEFORE the host sends SIGTERM,
     // or it dies from the default SIGTERM whether the fix is present or not — so
     // it writes a readiness marker after trapping and the leader waits for that
-    // marker before returning. The descendant sleeps 30 s as a safety net so a
-    // broken fix cannot leak it forever; the assertion window is far shorter, so
-    // it genuinely tests the SIGKILL reaping rather than the self-timeout.
+    // marker before returning. While alive it bumps a heartbeat file every 50 ms;
+    // the test asserts the heartbeat STOPS, which is what "no longer executing"
+    // means whether the killed descendant is reaped or lingers as a zombie (a
+    // SIGKILL'd process runs no more code either way). It sleeps 30 s as a safety
+    // net so a broken fix cannot leak it forever.
     const handoff = await mkdtemp(join(tmpdir(), 'dsh-samegroup-'))
     const readyMarker = join(handoff, 'ready')
+    const heartbeat = join(handoff, 'heartbeat')
     const { runtime } = await setup({ maxWallMs: 10_000, graceMs: 300 })
-    let reportedPid!: (pid: number) => void
-    const childPid = new Promise<number>((resolve) => { reportedPid = resolve })
     const result = await runtime.run({
       program: [
         'import subprocess, sys, os, time',
         `marker = ${JSON.stringify(readyMarker)}`,
+        `heartbeat = ${JSON.stringify(heartbeat)}`,
         // Same group (no start_new_session); ignores SIGTERM; holds none of the
         // leader's pipes (DEVNULL std streams, close_fds drops fd 3). It writes
-        // the marker (its argv[1]) only AFTER the trap is installed, so the
-        // leader cannot return — and the host cannot send SIGTERM — before the
-        // descendant ignores it.
-        'code = "import signal, sys, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); open(sys.argv[1], \'w\').close(); time.sleep(30)"',
-        'child = subprocess.Popen([sys.executable, "-c", code, marker],',
+        // the marker (argv[1]) only AFTER the trap is installed — so the leader
+        // cannot return, and the host cannot send SIGTERM, before it is ignored —
+        // then rewrites the heartbeat (argv[2]) every 50 ms for up to 30 s.
+        'code = ("import signal, sys, time\\n"',
+        '        "signal.signal(signal.SIGTERM, signal.SIG_IGN)\\n"',
+        '        "open(sys.argv[1], \'w\').close()\\n"',
+        '        "end = time.time() + 30\\n"',
+        '        "while time.time() < end:\\n"',
+        '        "    open(sys.argv[2], \'w\').close()\\n"',
+        '        "    time.sleep(0.05)\\n")',
+        'child = subprocess.Popen([sys.executable, "-c", code, marker, heartbeat],',
         '                         stdin=subprocess.DEVNULL,',
         '                         stdout=subprocess.DEVNULL,',
         '                         stderr=subprocess.DEVNULL)',
         'deadline = time.time() + 5',
         'while not os.path.exists(marker) and time.time() < deadline:',
         '    time.sleep(0.02)',
-        'await tools.report({"pid": child.pid})',
         'return "spawned"',
       ].join('\n'),
-      bindings: tools({
-        report: async (args) => {
-          reportedPid((args as { pid: number }).pid)
-          return 'ok'
-        },
-      }),
+      bindings: [],
     })
     expect(result.error).toBeUndefined()
     expect(result.value).toBe('spawned')
-    const pid = await childPid
-    expect(Number.isInteger(pid) && pid > 0).toBe(true)
     // The trap really installed before the leader returned, so this is the
     // SIGTERM-ignoring descendant, not one that would have died to the default.
     expect(existsSync(readyMarker)).toBe(true)
-    // run() resolved inside the grace window, so the descendant is still alive
-    // here; the pending SIGKILL reaps it shortly after graceMs. Poll until it is
-    // gone, well within the descendant's own 30 s self-timeout.
-    const deadline = Date.now() + 5_000
-    const alive = (): boolean => {
-      try {
-        process.kill(pid, 0)
-        return true
-      } catch {
-        return false
-      }
+    // The grace-window SIGKILL (graceMs 300 + reap margin) empties the group. Once
+    // it has, the descendant stops bumping the heartbeat. Poll the heartbeat's
+    // mtime: two consecutive reads far enough apart with no change means it is no
+    // longer executing — true whether it was reaped or lingers as a zombie, so
+    // the assertion holds in a container whose init does not wait() orphans. The
+    // window (well under the 30 s self-timeout) proves the SIGKILL did the work.
+    const mtime = (): number => { try { return statSync(heartbeat).mtimeMs } catch { return 0 } }
+    const stopDeadline = Date.now() + 8_000
+    let last = mtime()
+    let still = false
+    while (Date.now() < stopDeadline) {
+      await new Promise(resolve => setTimeout(resolve, 400))
+      const now = mtime()
+      if (now === last && now !== 0) { still = true; break }
+      last = now
     }
-    while (alive() && Date.now() < deadline) {
-      await new Promise(resolve => setTimeout(resolve, 50))
-    }
-    expect(() => process.kill(pid, 0)).toThrow(/ESRCH/)
-  }, 15_000)
+    expect(still).toBe(true)
+  }, 20_000)
 })
 
 describe('PythonCodeRuntime — hostile peer', () => {
@@ -2305,6 +2306,47 @@ describe('PythonCodeRuntime — hostile peer', () => {
     }
   }, 30_000)
 
+  it('completes a binding called from a worker thread on its own event loop', async () => {
+    // A binding reply Future is created on the loop that ran `dispatch`. When the
+    // model calls a binding from a worker THREAD via `asyncio.run(tools.x(...))`,
+    // that Future belongs to the thread's loop, not the main loop where
+    // `_pump_replies` reads the reply. `asyncio.Future` is not thread-safe:
+    // completing it from another thread does not wake its own loop, so a direct
+    // `set_result` would strand the awaiting thread and the run would degrade to a
+    // wall-clock timeout. The pump must schedule completion on the Future's own
+    // loop via `call_soon_threadsafe`. The tight maxWallMs makes the pre-fix
+    // failure a fast timeout rather than a hang.
+    //
+    // The main coroutine yields with `await asyncio.sleep` while the worker runs,
+    // rather than a synchronous `t.join()`: joining would block the main thread,
+    // so the main loop could not run `_pump_replies` and the call would deadlock
+    // regardless of the fix — that blocks the pump, not the cross-loop delivery
+    // this test pins.
+    const { runtime } = await setup({ maxWallMs: 8_000 })
+    const seen: unknown[] = []
+    const result = await runtime.run({
+      program: [
+        'import asyncio, threading',
+        'result = {}',
+        'def worker():',
+        // A fresh loop in this thread; the binding Future is created here.
+        '    result["value"] = asyncio.run(tools.echo({"from": "thread"}))',
+        't = threading.Thread(target=worker)',
+        't.start()',
+        'while t.is_alive():',
+        '    await asyncio.sleep(0.02)',
+        'return result["value"]',
+      ].join('\n'),
+      bindings: tools({
+        echo: async (args) => { seen.push(args); return args as CodeJsonValue },
+      }),
+    })
+    expect(result.error).toBeUndefined()
+    expect(result.value).toEqual({ from: 'thread' })
+    // The host binding actually ran (the reply round-tripped), not a timeout.
+    expect(seen).toEqual([{ from: 'thread' }])
+  }, 15_000)
+
   it('round-trips an exactly representable large integer through a binding echo', async () => {
     // The reply serializer must print BigInt digits for a beyond-safe
     // integral double: String(2**60) emits a rounded form, and the child