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}