agentd/supervisor/reap.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Child reaping + orphan discipline. RFC 0003 §pid1-orphan.
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// `reap_pending` is the one process-global `waitpid(-1)`; it must be called ONLY
118// from `reaper::reap_and_dispatch` (under the routes lock), so a stray caller
119// can't reopen the reap-before-register race or steal another reactor's child.
120pub(in crate::supervisor) use imp::reap_pending;
121
122#[cfg(all(test, unix))]
123mod tests {
124 use super::*;
125
126 // Construct raw statuses in the Linux/glibc encoding the libc macros
127 // decode: a normal exit puts the code in the high byte; a signal death
128 // puts the signal in the low 7 bits.
129 fn exited(code: i32) -> i32 {
130 code << 8
131 }
132 fn signaled(sig: i32) -> i32 {
133 sig
134 }
135
136 #[test]
137 fn classify_exit_code() {
138 assert_eq!(classify_status(exited(0)), WaitOutcome::Exited(0));
139 assert_eq!(classify_status(exited(7)), WaitOutcome::Exited(7));
140 assert!(classify_status(exited(0)).is_clean());
141 assert!(!classify_status(exited(5)).is_clean());
142 }
143
144 #[test]
145 fn classify_signal_death() {
146 assert_eq!(
147 classify_status(signaled(libc::SIGKILL)),
148 WaitOutcome::Signaled(9)
149 );
150 assert_eq!(
151 classify_status(signaled(libc::SIGTERM)),
152 WaitOutcome::Signaled(15)
153 );
154 assert!(!classify_status(signaled(libc::SIGKILL)).is_clean());
155 }
156
157 // NOTE: `reap_pending()` is intentionally NOT unit-tested here. It calls
158 // `waitpid(-1, WNOHANG)`, which in a multi-threaded test process would reap
159 // *other* tests' child processes (e.g. the `exec` tests' /bin/echo) before
160 // their own `Child::wait`, causing spurious ECHILD failures. In production
161 // it only runs inside the supervisor process, whose reaping domain is its
162 // own; it's covered end-to-end by the spawn/reactive integration tests,
163 // which run agentd in separate processes.
164}