Skip to main content

agentd/supervisor/
reap.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Child reaping + orphan discipline.
3//!
4//! Two responsibilities:
5//! 1. **Subreaper / PID-1 discipline.** `PR_SET_CHILD_SUBREAPER` makes
6//!    grandchildren orphaned by a dying subagent reparent to *us*, not host
7//!    init — so the whole tree stays in agentd's reaping domain. When agentd
8//!    *is* PID 1 (a bare container), the same applies natively.
9//! 2. **Reaping.** Each active `Supervisor` tick drains the process-global
10//!    reaper (`reaper::reap_and_dispatch`), whose `reap_pending` here is the
11//!    single `waitpid(-1, WNOHANG)` **loop** — looped because SIGCHLD does not
12//!    queue, so one signal may cover several exits. The SIGCHLD self-pipe
13//!    (`signals.rs`) is only a promptness hint; correctness does not depend on
14//!    it.
15//!
16//! The exit-status decode ([`classify_status`]) is pure and unit-tested; the
17//! `waitpid` loop itself is a thin libc wrapper exercised by the reactor.
18
19/// How a child process ended.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum WaitOutcome {
22    /// Normal exit with this code (0 = clean).
23    Exited(i32),
24    /// Killed by this signal number (e.g. 9 = SIGKILL, 15 = SIGTERM).
25    Signaled(i32),
26}
27
28impl WaitOutcome {
29    pub fn is_clean(self) -> bool {
30        matches!(self, WaitOutcome::Exited(0))
31    }
32}
33
34/// A reaped child: its pid and how it ended.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Reaped {
37    pub pid: i32,
38    pub outcome: WaitOutcome,
39}
40
41#[cfg(unix)]
42mod imp {
43    use super::{Reaped, WaitOutcome};
44
45    /// Decode a raw `waitpid` status into a [`WaitOutcome`]. Pure — uses the
46    /// libc `WIF*` macros so the encoding stays correct per platform.
47    pub fn classify_status(status: i32) -> WaitOutcome {
48        if libc::WIFSIGNALED(status) {
49            WaitOutcome::Signaled(libc::WTERMSIG(status))
50        } else {
51            // WIFEXITED (or stopped/continued, which WNOHANG won't surface
52            // without WUNTRACED/WCONTINUED — we request neither).
53            WaitOutcome::Exited(libc::WEXITSTATUS(status))
54        }
55    }
56
57    /// Reap every child that has exited, without blocking. Drains in a loop
58    /// because SIGCHLD does not queue. Returns each reaped pid + outcome
59    /// (including unknown pids — orphaned grandchildren we adopted).
60    pub fn reap_pending() -> Vec<Reaped> {
61        let mut reaped = Vec::new();
62        loop {
63            let mut status: libc::c_int = 0;
64            let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
65            if pid > 0 {
66                reaped.push(Reaped {
67                    pid,
68                    outcome: classify_status(status),
69                });
70            } else {
71                // 0 = children exist but none have exited; -1 = ECHILD/error.
72                break;
73            }
74        }
75        reaped
76    }
77
78    /// Become a subreaper so orphaned grandchildren reparent to us. Returns
79    /// true on success (Linux ≥ 3.4). Best-effort: a failure just means
80    /// orphans go to init, which `PDEATHSIG` already guards against.
81    pub fn set_child_subreaper() -> bool {
82        #[cfg(target_os = "linux")]
83        {
84            unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
85        }
86        #[cfg(not(target_os = "linux"))]
87        {
88            false
89        }
90    }
91
92    /// Are we PID 1 (running as a bare container's init)? Then we must reap
93    /// *all* orphans, and our own death takes the tree with us.
94    pub fn is_init() -> bool {
95        unsafe { libc::getpid() == 1 }
96    }
97}
98
99#[cfg(not(unix))]
100mod imp {
101    use super::{Reaped, WaitOutcome};
102    pub fn classify_status(status: i32) -> WaitOutcome {
103        WaitOutcome::Exited(status)
104    }
105    pub fn reap_pending() -> Vec<Reaped> {
106        Vec::new()
107    }
108    pub fn set_child_subreaper() -> bool {
109        false
110    }
111    pub fn is_init() -> bool {
112        false
113    }
114}
115
116pub use imp::{classify_status, is_init, set_child_subreaper};
117
118/// Set in an instance-tier child's environment by the spawning parent.
119/// The child daemon sees it at startup and installs
120/// [`install_instance_pdeathsig`] so a parent crash retires the child
121/// gracefully (SIGTERM → its own drain) instead of orphaning a daemon.
122pub const INSTANCE_CHILD_ENV: &str = "AGENTD_INSTANCE_CHILD";
123
124/// PDEATHSIG(SIGTERM) for an instance-tier child — set post-exec by the child
125/// itself (the pre-exec value would not survive the execve), mirroring the
126/// subagent's SIGKILL install in `subagent::control`. SIGTERM, not SIGKILL:
127/// a daemon child has durable state worth a graceful drain.
128pub fn install_instance_pdeathsig() {
129    #[cfg(target_os = "linux")]
130    unsafe {
131        libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
132        // The parent may have died in the fork/exec window: PDEATHSIG only
133        // fires on deaths AFTER it is installed.
134        if libc::getppid() == 1 {
135            libc::raise(libc::SIGTERM);
136        }
137    }
138}
139// `reap_pending` is the one process-global `waitpid(-1)`; it must be called ONLY
140// from `reaper::reap_and_dispatch` (under the routes lock), so a stray caller
141// can't reopen the reap-before-register race or steal another reactor's child.
142pub(in crate::supervisor) use imp::reap_pending;
143
144#[cfg(all(test, unix))]
145mod tests {
146    use super::*;
147
148    // Construct raw statuses in the Linux/glibc encoding the libc macros
149    // decode: a normal exit puts the code in the high byte; a signal death
150    // puts the signal in the low 7 bits.
151    fn exited(code: i32) -> i32 {
152        code << 8
153    }
154    fn signaled(sig: i32) -> i32 {
155        sig
156    }
157
158    #[test]
159    fn classify_exit_code() {
160        assert_eq!(classify_status(exited(0)), WaitOutcome::Exited(0));
161        assert_eq!(classify_status(exited(7)), WaitOutcome::Exited(7));
162        assert!(classify_status(exited(0)).is_clean());
163        assert!(!classify_status(exited(5)).is_clean());
164    }
165
166    #[test]
167    fn classify_signal_death() {
168        assert_eq!(
169            classify_status(signaled(libc::SIGKILL)),
170            WaitOutcome::Signaled(9)
171        );
172        assert_eq!(
173            classify_status(signaled(libc::SIGTERM)),
174            WaitOutcome::Signaled(15)
175        );
176        assert!(!classify_status(signaled(libc::SIGKILL)).is_clean());
177    }
178
179    // NOTE: `reap_pending()` is intentionally NOT unit-tested here. It calls
180    // `waitpid(-1, WNOHANG)`, which in a multi-threaded test process would reap
181    // *other* tests' child processes (e.g. the `exec` tests' /bin/echo) before
182    // their own `Child::wait`, causing spurious ECHILD failures. In production
183    // it only runs inside the supervisor process, whose reaping domain is its
184    // own; it's covered end-to-end by the spawn/reactive integration tests,
185    // which run agentd in separate processes.
186}