agentd/supervisor/reaper.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! The process-global child reaper.
3//!
4//! `waitpid(-1)` is process-global — it reaps *any* child (including
5//! `PR_SET_CHILD_SUBREAPER` orphans), so two threads each calling it would steal
6//! each other's children (the robbed one then waits forever for an exit another
7//! thread already collected).
8//!
9//! The fix is **dispatch by pid**: exactly one place drains `waitpid(-1)` and
10//! routes each reaped pid to the owning `Supervisor`'s channel, so any number of
11//! Supervisors run **concurrently** without stealing from each other.
12//!
13//! Two operations, both under a global pid→route registry mutex:
14//! * [`spawn_tracked`] forks the child **under the lock**, then registers its
15//! pid → the owner's reap channel — so registration is atomic with the fork
16//! and the reaper can never `waitpid` a not-yet-registered child.
17//! * [`reap_and_dispatch`] (called from each Supervisor's tick) drains
18//! `waitpid(-1, WNOHANG)` and sends each reaped pid to its owner; an **unowned**
19//! pid (an adopted orphan, or a *foreign* child such as an MCP-server / `exec`
20//! process that reaps itself) is simply dropped — already reaped, no owner.
21//!
22//! There is **no dedicated reaper thread**: reaping happens only while a
23//! Supervisor is active (its 200 ms tick drives it). That is deliberate — a
24//! continuous `waitpid(-1)` would steal the children of components that
25//! spawn-and-wait their own (the `exec` self-tool, the MCP client).
26//!
27//! **What `waitpid(-1)` reaps (the real coexistence contract).** Because it is
28//! process-global, `reap_and_dispatch` reaps *every* exited child in the process,
29//! not just tracked ones — including a daemon's long-lived MCP-server children, a
30//! warm session's subagent, and adopted orphans — and it does so from **whichever
31//! supervisor happens to tick**, possibly concurrently with the main thread. That
32//! is safe **not** because these never overlap (in the daemon they do, once a
33//! served async run is in flight) but because every such component detects its
34//! child's death via its own channel (MCP stdout EOF, the warm/async `AgentMsg`
35//! channel) and its `Drop` tolerates `ECHILD` — none of them needs the reaped
36//! exit *status*, which is what `waitpid(-1)` consumes. The one component that
37//! *does* consume a child's status, the `exec` tool, runs only on a subagent's
38//! single agentic-loop thread where no reactor ticks concurrently. A foreign
39//! `child.wait()` is `waitpid(specific_pid)` and so can never steal a *tracked*
40//! supervised child (a different, still-live pid).
41
42use crate::supervisor::reap::{self, Reaped};
43use crate::supervisor::spawn::Subagent;
44use std::collections::HashMap;
45use std::io;
46use std::sync::mpsc::Sender;
47use std::sync::{LazyLock, Mutex, MutexGuard};
48
49/// pid → the owning Supervisor's reap channel. Holds only LIVE (unreaped)
50/// supervised pids; an entry leaves when its pid is reaped (dispatched) or when
51/// its handle is dropped unreaped ([`deregister`]).
52static ROUTES: LazyLock<Mutex<HashMap<i32, Sender<Reaped>>>> =
53 LazyLock::new(|| Mutex::new(HashMap::new()));
54
55fn routes() -> MutexGuard<'static, HashMap<i32, Sender<Reaped>>> {
56 ROUTES.lock().unwrap_or_else(|e| e.into_inner())
57}
58
59/// Spawn a supervised child and register its pid → `reap_tx` **atomically with
60/// the fork** (both under the routes lock), so the reaper can never `waitpid` a
61/// child before it is registered. `spawn_fn` does the fork and returns the
62/// [`Subagent`] whose `pid()` is the registry key.
63///
64/// The lock is held across all of `spawn_fn` — the fork, the first-frame payload
65/// write, and the reader-thread spawn — so concurrent supervisors briefly
66/// serialize on each spawn. The hold is bounded by child startup (the child
67/// drains its stdin pipe within a few ms of `exec`), never by the length of a
68/// run, so spawn contention stays in the millisecond range.
69pub fn spawn_tracked(
70 reap_tx: &Sender<Reaped>,
71 spawn_fn: impl FnOnce() -> io::Result<Subagent>,
72) -> io::Result<Subagent> {
73 let mut routes = routes();
74 let sub = spawn_fn()?;
75 routes.insert(sub.pid(), reap_tx.clone());
76 Ok(sub)
77}
78
79/// [`spawn_tracked`] for a plain [`std::process::Child`] — an instance-tier
80/// child, which is a full daemon with no control channel. Same contract: the
81/// fork happens under the routes lock so the reaper can never `waitpid` the pid
82/// before it is registered.
83pub fn spawn_tracked_pid(
84 reap_tx: &Sender<Reaped>,
85 spawn_fn: impl FnOnce() -> io::Result<std::process::Child>,
86) -> io::Result<std::process::Child> {
87 let mut routes = routes();
88 let child = spawn_fn()?;
89 routes.insert(child.id() as i32, reap_tx.clone());
90 Ok(child)
91}
92
93/// Drain `waitpid(-1, WNOHANG)` and dispatch each reaped pid to its owning
94/// Supervisor. Unowned pids (orphans / foreign self-reaping children) are
95/// dropped. Called from each Supervisor's tick; the lock keeps the single
96/// `waitpid(-1)` serialized across concurrent Supervisors.
97pub fn reap_and_dispatch() {
98 let mut routes = routes();
99 for reaped in reap::reap_pending() {
100 if let Some(tx) = routes.remove(&reaped.pid) {
101 let _ = tx.send(reaped); // the owner may be gone — harmless
102 }
103 // else: an adopted orphan or a foreign self-reaping child (MCP server /
104 // warm session / async child) — already reaped here, no route, no owner
105 // that needs its exit status (each detects death via its own channel).
106 }
107}
108
109/// Drop a pid's route without reaping it — for a [`Subagent`] handle dropped
110/// before the reaper dispatched its exit (an abandoned run, which then reaps the
111/// child itself). Harmless if the pid is absent (a foreign / already-reaped pid).
112pub fn deregister(pid: i32) {
113 routes().remove(&pid);
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::supervisor::reap::WaitOutcome;
120 use std::sync::mpsc;
121
122 #[test]
123 fn deregister_of_an_unknown_pid_is_a_noop() {
124 deregister(-12345); // must not panic / must tolerate a foreign pid
125 }
126
127 #[test]
128 fn a_registered_route_receives_a_dispatched_reap() {
129 // Drive the registry directly (no real fork): register a synthetic pid,
130 // then simulate the reaper dispatching its exit.
131 let (tx, rx) = mpsc::channel::<Reaped>();
132 let pid = -98765; // a pid waitpid(-1) will never return — isolates this test
133 routes().insert(pid, tx);
134 // Simulate dispatch (what reap_and_dispatch does on a real exit).
135 if let Some(tx) = routes().remove(&pid) {
136 let _ = tx.send(Reaped {
137 pid,
138 outcome: WaitOutcome::Exited(0),
139 });
140 }
141 let got = rx.try_recv().expect("the route received the reap");
142 assert_eq!(got.pid, pid);
143 assert!(got.outcome.is_clean());
144 assert!(
145 routes().get(&pid).is_none(),
146 "the route is removed on dispatch"
147 );
148 }
149}