Skip to main content

Module reaper

Module reaper 

Source
Expand description

The process-global child reaper.

waitpid(-1) is process-global — it reaps any child (including PR_SET_CHILD_SUBREAPER orphans), so two threads each calling it would steal each other’s children (the robbed one then waits forever for an exit another thread already collected).

The fix is dispatch by pid: exactly one place drains waitpid(-1) and routes each reaped pid to the owning Supervisor’s channel, so any number of Supervisors run concurrently without stealing from each other.

Two operations, both under a global pid→route registry mutex:

  • spawn_tracked forks the child under the lock, then registers its pid → the owner’s reap channel — so registration is atomic with the fork and the reaper can never waitpid a not-yet-registered child.
  • reap_and_dispatch (called from each Supervisor’s tick) drains waitpid(-1, WNOHANG) and sends each reaped pid to its owner; an unowned pid (an adopted orphan, or a foreign child such as an MCP-server / exec process that reaps itself) is simply dropped — already reaped, no owner.

There is no dedicated reaper thread: reaping happens only while a Supervisor is active (its 200 ms tick drives it). That is deliberate — a continuous waitpid(-1) would steal the children of components that spawn-and-wait their own (the exec self-tool, the MCP client).

What waitpid(-1) reaps (the real coexistence contract). Because it is process-global, reap_and_dispatch reaps every exited child in the process, not just tracked ones — including a daemon’s long-lived MCP-server children, a warm session’s subagent, and adopted orphans — and it does so from whichever supervisor happens to tick, possibly concurrently with the main thread. That is safe not because these never overlap (in the daemon they do, once a served async run is in flight) but because every such component detects its child’s death via its own channel (MCP stdout EOF, the warm/async AgentMsg channel) and its Drop tolerates ECHILD — none of them needs the reaped exit status, which is what waitpid(-1) consumes. The one component that does consume a child’s status, the exec tool, runs only on a subagent’s single agentic-loop thread where no reactor ticks concurrently. A foreign child.wait() is waitpid(specific_pid) and so can never steal a tracked supervised child (a different, still-live pid).

Functions§

deregister
Drop a pid’s route without reaping it — for a Subagent handle dropped before the reaper dispatched its exit (an abandoned run, which then reaps the child itself). Harmless if the pid is absent (a foreign / already-reaped pid).
reap_and_dispatch
Drain waitpid(-1, WNOHANG) and dispatch each reaped pid to its owning Supervisor. Unowned pids (orphans / foreign self-reaping children) are dropped. Called from each Supervisor’s tick; the lock keeps the single waitpid(-1) serialized across concurrent Supervisors.
spawn_tracked
Spawn a supervised child and register its pid → reap_tx atomically with the fork (both under the routes lock), so the reaper can never waitpid a child before it is registered. spawn_fn does the fork and returns the Subagent whose pid() is the registry key.
spawn_tracked_pid
spawn_tracked for a plain std::process::Child — an instance-tier child, which is a full daemon with no control channel. Same contract: the fork happens under the routes lock so the reaper can never waitpid the pid before it is registered.