Skip to main content

microsandbox_agentd/
process.rs

1//! Process lifecycle management for the agent daemon.
2//!
3//! Initializing [`ProcessManager`] transfers process-wide child-status ownership
4//! to its dedicated thread: it drains `waitpid(-1, WNOHANG)` and therefore also
5//! consumes statuses for untracked children. Code that needs an exit status must
6//! acquire [`ProcessManager::spawn_guard`] before creating the process and finish
7//! with [`ProcessSpawnGuard::track`]. It must not independently wait on the same
8//! child PID during normal operation. Terminal teardown is the deliberate
9//! exception: it may reap any remaining child directly when the manager itself
10//! can no longer be assumed healthy.
11
12use std::collections::HashMap;
13use std::collections::hash_map::Entry;
14use std::future::Future;
15use std::panic::{AssertUnwindSafe, catch_unwind};
16use std::pin::Pin;
17use std::sync::{Arc, Mutex, MutexGuard, OnceLock, mpsc};
18use std::task::{Context, Poll};
19use std::thread;
20
21use tokio::signal::unix::{Signal, SignalKind};
22use tokio::sync::{oneshot, watch};
23
24use crate::error::{AgentdError, AgentdResult};
25
26//--------------------------------------------------------------------------------------------------
27// Constants
28//--------------------------------------------------------------------------------------------------
29
30static PROCESS_MANAGER: OnceLock<Arc<ProcessManager>> = OnceLock::new();
31
32/// Maximum number of children reaped while holding the process-state lock.
33///
34/// Releasing the lock between batches prevents a fork-heavy workload from
35/// starving exec spawning and signalling indefinitely.
36const REAP_BATCH_SIZE: usize = 64;
37
38//--------------------------------------------------------------------------------------------------
39// Types
40//--------------------------------------------------------------------------------------------------
41
42/// Coordinates process spawning, exit observation, and process-wide child reaping.
43#[derive(Debug)]
44pub struct ProcessManager {
45    state: Mutex<ProcessManagerState>,
46    startup_error: OnceLock<String>,
47    failure_tx: watch::Sender<Option<String>>,
48}
49
50#[derive(Debug)]
51struct ProcessManagerState {
52    processes: HashMap<i32, TrackedProcess>,
53    next_generation: u64,
54    terminal_error: Option<String>,
55}
56
57#[derive(Debug)]
58struct TrackedProcess {
59    generation: u64,
60    exit_tx: Option<oneshot::Sender<i32>>,
61}
62
63/// Keeps process reaping paused until a newly spawned PID is tracked.
64pub struct ProcessSpawnGuard<'a> {
65    state: MutexGuard<'a, ProcessManagerState>,
66}
67
68/// Stable identity for one registration of an operating-system PID.
69///
70/// PIDs can be reused after a child is reaped. The generation prevents an old
71/// exec session from signalling a newer process that received the same PID.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct ProcessIdentity {
74    pid: i32,
75    generation: u64,
76}
77
78/// Observes the eventual exit code of a tracked process.
79///
80/// Processes terminated by a signal resolve to `-1`. If the reaper thread
81/// unexpectedly drops the notification, the failure is logged and also resolves
82/// to `-1` to preserve the exec-session wire protocol.
83pub struct ProcessExitWatcher {
84    identity: ProcessIdentity,
85    receiver: oneshot::Receiver<i32>,
86}
87
88//--------------------------------------------------------------------------------------------------
89// Methods
90//--------------------------------------------------------------------------------------------------
91
92impl ProcessManager {
93    /// Returns the process-wide manager, starting its `SIGCHLD` thread on first use.
94    ///
95    /// The first call blocks synchronously until the dedicated thread has built
96    /// its runtime and installed the `SIGCHLD` listener.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the thread, runtime, or signal listener cannot start,
101    /// or if the process manager has terminated unexpectedly.
102    pub fn get() -> AgentdResult<Arc<Self>> {
103        if let Some(manager) = PROCESS_MANAGER.get() {
104            return manager.result();
105        }
106
107        let candidate = Arc::new(Self::new());
108        let manager = PROCESS_MANAGER.get_or_init(move || {
109            candidate.launch_thread();
110            candidate
111        });
112        manager.result()
113    }
114
115    fn new() -> Self {
116        let (failure_tx, _) = watch::channel(None);
117        Self {
118            state: Mutex::new(ProcessManagerState::new()),
119            startup_error: OnceLock::new(),
120            failure_tx,
121        }
122    }
123
124    /// Opens a spawn section that must end by tracking the child PID.
125    ///
126    /// The returned guard serializes the short spawn-to-registration window with
127    /// reaping. Production exec requests are already spawned serially by the agent
128    /// loop, so allowing parallel spawns here adds complexity without throughput.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the process manager has terminated.
133    pub fn spawn_guard(&self) -> AgentdResult<ProcessSpawnGuard<'_>> {
134        let state = self
135            .state
136            .lock()
137            .unwrap_or_else(|poisoned| poisoned.into_inner());
138        if let Some(error) = state.terminal_error.as_ref() {
139            return Err(AgentdError::ExecSession(error.clone()));
140        }
141        Ok(ProcessSpawnGuard { state })
142    }
143
144    /// Subscribes to terminal process-manager failures.
145    ///
146    /// The receiver is created before checking current state so a failure cannot
147    /// occur between the check and subscription without being observed.
148    pub fn subscribe_failure(&self) -> AgentdResult<watch::Receiver<Option<String>>> {
149        let receiver = self.failure_tx.subscribe();
150        if let Some(error) = self
151            .state
152            .lock()
153            .unwrap_or_else(|poisoned| poisoned.into_inner())
154            .terminal_error
155            .as_ref()
156        {
157            return Err(AgentdError::ExecSession(error.clone()));
158        }
159        Ok(receiver)
160    }
161
162    /// Signals a tracked process group only while its registration is current.
163    ///
164    /// Holding the state lock across the identity check and `kill` prevents a
165    /// reaped PID from being registered to a new session in between them.
166    pub fn signal_process_group(&self, identity: ProcessIdentity, signum: i32) -> AgentdResult<()> {
167        let state = self
168            .state
169            .lock()
170            .unwrap_or_else(|poisoned| poisoned.into_inner());
171        let Some(process) = state.processes.get(&identity.pid) else {
172            return Ok(());
173        };
174        if process.generation != identity.generation {
175            return Ok(());
176        }
177
178        if process.exit_tx.is_some() {
179            signal_process_group_or_process(identity.pid, signum)
180        } else {
181            // Once the leader has been reaped, a direct-PID fallback could hit
182            // an unrelated process that reused its PID. Only the still-existing
183            // process group is a valid target for the completed registration.
184            signal_process_group_only(identity.pid, signum)
185        }
186    }
187
188    /// Releases a process registration when its exec session is no longer signalable.
189    pub(crate) fn release(&self, identity: ProcessIdentity) {
190        let mut state = self
191            .state
192            .lock()
193            .unwrap_or_else(|poisoned| poisoned.into_inner());
194        if state.matches(identity) {
195            state.processes.remove(&identity.pid);
196        }
197    }
198
199    fn launch_thread(self: &Arc<Self>) {
200        let (startup_tx, startup_rx) = mpsc::sync_channel(1);
201        let manager = Arc::clone(self);
202        let spawn_result = thread::Builder::new()
203            .name("agentd-process-manager".to_string())
204            .spawn(move || {
205                let failure_manager = Arc::clone(&manager);
206                let result = catch_unwind(AssertUnwindSafe(|| {
207                    run_process_manager_thread(manager, startup_tx)
208                }));
209                let error = match result {
210                    Ok(Err(error)) => error,
211                    Ok(Ok(())) => "process manager thread stopped unexpectedly".to_string(),
212                    Err(_) => "process manager thread panicked".to_string(),
213                };
214                failure_manager.fail(error);
215            });
216
217        let startup_result = match spawn_result {
218            Ok(_) => startup_rx
219                .recv()
220                .unwrap_or_else(|error| Err(format!("receive thread startup: {error}"))),
221            Err(error) => Err(format!("spawn process manager thread: {error}")),
222        };
223        if let Err(error) = startup_result {
224            let _ = self.startup_error.set(error);
225        }
226    }
227
228    fn result(self: &Arc<Self>) -> AgentdResult<Arc<Self>> {
229        if let Some(error) = self.startup_error.get() {
230            return Err(AgentdError::ExecSession(format!(
231                "start process manager: {error}"
232            )));
233        }
234        match self
235            .state
236            .lock()
237            .unwrap_or_else(|poisoned| poisoned.into_inner())
238            .terminal_error
239            .as_ref()
240        {
241            Some(error) => Err(AgentdError::ExecSession(error.clone())),
242            None => Ok(Arc::clone(self)),
243        }
244    }
245
246    fn fail(&self, error: String) {
247        let mut state = self
248            .state
249            .lock()
250            .unwrap_or_else(|poisoned| poisoned.into_inner());
251        if state.terminal_error.is_some() {
252            return;
253        }
254
255        state.terminal_error = Some(error.clone());
256        for (pid, process) in &state.processes {
257            if process.exit_tx.is_some() {
258                let _ = signal_process_group_or_process(*pid, libc::SIGKILL);
259            } else {
260                let _ = signal_process_group_only(*pid, libc::SIGKILL);
261            }
262        }
263        state.processes.clear();
264        drop(state);
265
266        // `send_replace` retains the failure even if the agent has not subscribed yet.
267        self.failure_tx.send_replace(Some(error));
268    }
269
270    async fn run(self: Arc<Self>, mut signal: Signal) -> Result<(), String> {
271        self.reap_until_idle()?;
272        while signal.recv().await.is_some() {
273            self.reap_until_idle()?;
274        }
275        Err("process manager SIGCHLD listener closed".to_string())
276    }
277
278    fn reap_until_idle(&self) -> Result<(), String> {
279        while self.reap_exited_batch()? {
280            // The state lock is released between batches. Yielding here gives
281            // a waiting spawn or signal request a chance to acquire it.
282            thread::yield_now();
283        }
284        Ok(())
285    }
286
287    fn reap_exited_batch(&self) -> Result<bool, String> {
288        let mut state = self
289            .state
290            .lock()
291            .unwrap_or_else(|poisoned| poisoned.into_inner());
292        state
293            .reap_exited_batch()
294            .map_err(|error| format!("waitpid failed while reaping processes: {error}"))
295    }
296}
297
298impl ProcessManagerState {
299    fn new() -> Self {
300        Self {
301            processes: HashMap::new(),
302            next_generation: 1,
303            terminal_error: None,
304        }
305    }
306
307    fn track(&mut self, pid: i32) -> AgentdResult<ProcessExitWatcher> {
308        if pid <= 0 {
309            return Err(AgentdError::ExecSession(format!(
310                "cannot track invalid process PID {pid}"
311            )));
312        }
313
314        let generation = self.next_generation;
315        self.next_generation = self.next_generation.checked_add(1).ok_or_else(|| {
316            AgentdError::ExecSession("process registration generation exhausted".to_string())
317        })?;
318        let identity = ProcessIdentity { pid, generation };
319        let (exit_tx, receiver) = oneshot::channel();
320        match self.processes.entry(pid) {
321            Entry::Vacant(entry) => {
322                entry.insert(TrackedProcess {
323                    generation,
324                    exit_tx: Some(exit_tx),
325                });
326                Ok(ProcessExitWatcher { identity, receiver })
327            }
328            Entry::Occupied(mut entry) if entry.get().exit_tx.is_none() => {
329                // A PID can only be reused after its old process group is gone.
330                // Replacing the completed registration invalidates the old
331                // session identity without rejecting the new exec request.
332                entry.insert(TrackedProcess {
333                    generation,
334                    exit_tx: Some(exit_tx),
335                });
336                Ok(ProcessExitWatcher { identity, receiver })
337            }
338            Entry::Occupied(_) => Err(AgentdError::ExecSession(format!(
339                "process PID {pid} is already tracked"
340            ))),
341        }
342    }
343
344    fn matches(&self, identity: ProcessIdentity) -> bool {
345        self.processes
346            .get(&identity.pid)
347            .is_some_and(|process| process.generation == identity.generation)
348    }
349
350    /// Reaps at most one bounded batch.
351    ///
352    /// Returns `true` when the batch filled completely, which tells the caller
353    /// to yield and immediately check for more exited children without relying
354    /// on another (possibly coalesced) `SIGCHLD` notification.
355    fn reap_exited_batch(&mut self) -> std::io::Result<bool> {
356        let mut reaped = 0;
357        while reaped < REAP_BATCH_SIZE {
358            let mut status = 0;
359            let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
360            if pid > 0 {
361                reaped += 1;
362                let mut remove = false;
363                if let Some(process) = self.processes.get_mut(&pid)
364                    && let Some(exit_tx) = process.exit_tx.take()
365                {
366                    // Keep a completed registration as a tombstone while the
367                    // exec session drains output and remains signalable. If the
368                    // watcher was already dropped, no session owns the identity.
369                    remove = exit_tx.send(exit_code(status)).is_err();
370                }
371                if remove {
372                    self.processes.remove(&pid);
373                }
374                continue;
375            }
376            if pid == 0 {
377                return Ok(false);
378            }
379
380            let error = std::io::Error::last_os_error();
381            if error.raw_os_error() == Some(libc::EINTR) {
382                continue;
383            }
384            if error.raw_os_error() == Some(libc::ECHILD) {
385                return Ok(false);
386            }
387            return Err(error);
388        }
389        Ok(true)
390    }
391}
392
393impl ProcessSpawnGuard<'_> {
394    /// Tracks the spawned PID before allowing process reaping to proceed.
395    ///
396    /// Consuming the guard makes the manager the sole owner of that PID's exit
397    /// status during normal operation. Await the returned [`ProcessExitWatcher`]
398    /// instead of calling `waitpid` or a child handle's wait method. Terminal
399    /// teardown may bypass the manager as a last-resort fallback.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error when `pid` is not positive or is already tracked.
404    pub fn track(mut self, pid: i32) -> AgentdResult<ProcessExitWatcher> {
405        match self.state.track(pid) {
406            Ok(exit_watcher) => Ok(exit_watcher),
407            Err(error) => {
408                // Registration failed while the state lock still prevents the
409                // reaper from freeing and reusing this PID.
410                if pid > 0 {
411                    let _ = signal_process_group_or_process(pid, libc::SIGKILL);
412                }
413                Err(error)
414            }
415        }
416    }
417}
418
419impl ProcessIdentity {
420    /// Returns the operating-system PID associated with this registration.
421    pub fn pid(self) -> i32 {
422        self.pid
423    }
424}
425
426impl ProcessExitWatcher {
427    /// Returns the stable identity of the tracked process.
428    pub fn identity(&self) -> ProcessIdentity {
429        self.identity
430    }
431}
432
433//--------------------------------------------------------------------------------------------------
434// Trait Implementations
435//--------------------------------------------------------------------------------------------------
436
437impl Future for ProcessExitWatcher {
438    type Output = i32;
439
440    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
441        match Pin::new(&mut self.receiver).poll(cx) {
442            Poll::Ready(Ok(code)) => Poll::Ready(code),
443            Poll::Ready(Err(error)) => {
444                eprintln!(
445                    "agentd: process manager dropped the exit notification for PID {}: {error}",
446                    self.identity.pid
447                );
448                Poll::Ready(-1)
449            }
450            Poll::Pending => Poll::Pending,
451        }
452    }
453}
454
455//--------------------------------------------------------------------------------------------------
456// Functions
457//--------------------------------------------------------------------------------------------------
458
459fn run_process_manager_thread(
460    manager: Arc<ProcessManager>,
461    startup: mpsc::SyncSender<Result<(), String>>,
462) -> Result<(), String> {
463    let runtime = match tokio::runtime::Builder::new_current_thread()
464        .enable_all()
465        .build()
466    {
467        Ok(runtime) => runtime,
468        Err(error) => {
469            let error = format!("build process manager Tokio runtime: {error}");
470            let _ = startup.send(Err(error.clone()));
471            return Err(error);
472        }
473    };
474
475    runtime.block_on(async move {
476        match tokio::signal::unix::signal(SignalKind::child()) {
477            Ok(signal) => {
478                let _ = startup.send(Ok(()));
479                manager.run(signal).await
480            }
481            Err(error) => {
482                let error = format!("install process manager SIGCHLD listener: {error}");
483                let _ = startup.send(Err(error.clone()));
484                Err(error)
485            }
486        }
487    })
488}
489
490fn signal_process_group_or_process(pid: i32, signum: i32) -> AgentdResult<()> {
491    let group_result = unsafe { libc::kill(-pid, signum) };
492    if group_result == 0 {
493        return Ok(());
494    }
495
496    let group_error = std::io::Error::last_os_error();
497    if group_error.raw_os_error() != Some(libc::ESRCH) {
498        return Err(group_error.into());
499    }
500
501    // A failed pre-exec may exit before establishing its process group. The
502    // direct PID is still safe to target because the caller holds the current
503    // registration lock, so it cannot refer to a reused process here.
504    let process_result = unsafe { libc::kill(pid, signum) };
505    if process_result == 0 {
506        return Ok(());
507    }
508
509    let process_error = std::io::Error::last_os_error();
510    if process_error.raw_os_error() == Some(libc::ESRCH) {
511        Ok(())
512    } else {
513        Err(process_error.into())
514    }
515}
516
517fn signal_process_group_only(pid: i32, signum: i32) -> AgentdResult<()> {
518    let result = unsafe { libc::kill(-pid, signum) };
519    if result == 0 {
520        return Ok(());
521    }
522
523    let error = std::io::Error::last_os_error();
524    if error.raw_os_error() == Some(libc::ESRCH) {
525        Ok(())
526    } else {
527        Err(error.into())
528    }
529}
530
531fn exit_code(status: i32) -> i32 {
532    if libc::WIFEXITED(status) {
533        libc::WEXITSTATUS(status)
534    } else {
535        -1
536    }
537}
538
539//--------------------------------------------------------------------------------------------------
540// Tests
541//--------------------------------------------------------------------------------------------------
542
543#[cfg(test)]
544mod tests {
545    use std::io::Read;
546    use std::process::{Command, Stdio};
547    use std::sync::Arc;
548    use std::thread;
549    use std::time::{Duration, Instant};
550
551    use super::*;
552
553    const HELPER_ENV: &str = "MSB_AGENTD_PROCESS_MANAGER_HELPER";
554    const HELPER_SENTINEL: &str = "process-manager-helper-passed";
555    const TEST_NAME: &str = "process::tests::reaping_is_batched_and_tracks_exit_codes";
556
557    #[test]
558    fn reaping_is_batched_and_tracks_exit_codes() {
559        if std::env::var_os(HELPER_ENV).is_some() {
560            run_batched_reap_scenario();
561            println!("{HELPER_SENTINEL}");
562            return;
563        }
564
565        let mut helper = Command::new(std::env::current_exe().expect("current test binary"))
566            .args(["--exact", TEST_NAME, "--nocapture"])
567            .env(HELPER_ENV, "1")
568            .stdout(Stdio::piped())
569            .spawn()
570            .expect("spawn isolated process manager test");
571        let mut output = String::new();
572        helper
573            .stdout
574            .take()
575            .expect("helper stdout")
576            .read_to_string(&mut output)
577            .expect("read helper stdout");
578
579        match helper.wait() {
580            Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
581            Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
582            Err(error) => panic!("wait for helper: {error}"),
583        }
584        assert!(
585            output.contains(HELPER_SENTINEL),
586            "helper did not complete the reap scenario:\n{output}"
587        );
588    }
589
590    #[test]
591    fn invalid_pids_are_rejected() {
592        let manager = ProcessManager::new();
593        for pid in [-1, 0] {
594            let error = match manager
595                .spawn_guard()
596                .expect("acquire process spawn guard")
597                .track(pid)
598            {
599                Ok(_) => panic!("invalid PID should be rejected"),
600                Err(error) => error,
601            };
602            assert!(error.to_string().contains(&pid.to_string()));
603        }
604    }
605
606    #[test]
607    fn terminal_failure_rejects_spawns_and_wakes_exits() {
608        const UNUSED_PID: i32 = i32::MAX;
609
610        let manager = Arc::new(ProcessManager::new());
611        let mut failure_rx = manager
612            .subscribe_failure()
613            .expect("subscribe to process manager failure");
614        let exit_watcher = manager
615            .spawn_guard()
616            .expect("acquire process spawn guard")
617            .track(UNUSED_PID)
618            .expect("track test PID");
619
620        manager.fail("process manager test failure".to_string());
621
622        assert!(manager.result().is_err());
623        assert!(manager.spawn_guard().is_err());
624        let runtime = tokio::runtime::Builder::new_current_thread()
625            .build()
626            .expect("test runtime");
627        runtime
628            .block_on(failure_rx.changed())
629            .expect("receive process manager failure");
630        assert_eq!(
631            failure_rx.borrow().as_deref(),
632            Some("process manager test failure")
633        );
634        assert_eq!(runtime.block_on(exit_watcher), -1);
635    }
636
637    #[test]
638    fn stale_identity_does_not_match_reused_pid() {
639        const PID: i32 = i32::MAX;
640
641        let manager = ProcessManager::new();
642        let first = manager
643            .spawn_guard()
644            .expect("acquire first process spawn guard")
645            .track(PID)
646            .expect("track first PID generation");
647        let first_identity = first.identity();
648        let first_exit_tx = manager
649            .state
650            .lock()
651            .unwrap_or_else(|poisoned| poisoned.into_inner())
652            .processes
653            .get_mut(&PID)
654            .expect("first process registration")
655            .exit_tx
656            .take()
657            .expect("first exit sender");
658        first_exit_tx.send(0).expect("send first exit code");
659
660        assert!(
661            manager
662                .state
663                .lock()
664                .unwrap_or_else(|poisoned| poisoned.into_inner())
665                .matches(first_identity)
666        );
667
668        let second = manager
669            .spawn_guard()
670            .expect("acquire second process spawn guard")
671            .track(PID)
672            .expect("track reused PID generation");
673        let second_identity = second.identity();
674        assert_ne!(first_identity, second_identity);
675
676        let state = manager
677            .state
678            .lock()
679            .unwrap_or_else(|poisoned| poisoned.into_inner());
680        assert!(!state.matches(first_identity));
681        assert!(state.matches(second_identity));
682        drop(state);
683
684        // Releasing an old session must not unregister the new owner of the
685        // reused PID.
686        manager.release(first_identity);
687        assert!(
688            manager
689                .state
690                .lock()
691                .unwrap_or_else(|poisoned| poisoned.into_inner())
692                .matches(second_identity)
693        );
694        manager.release(second_identity);
695        assert!(
696            !manager
697                .state
698                .lock()
699                .unwrap_or_else(|poisoned| poisoned.into_inner())
700                .matches(second_identity)
701        );
702    }
703
704    fn run_batched_reap_scenario() {
705        let manager = Arc::new(ProcessManager::new());
706        let mut tracked = Vec::with_capacity(REAP_BATCH_SIZE + 1);
707        for offset in 0..=REAP_BATCH_SIZE {
708            let code = 10 + (offset % 50) as i32;
709            let guard = manager.spawn_guard().expect("acquire process spawn guard");
710            let child = Command::new("/bin/sh")
711                .args(["-c", &format!("exit {code}")])
712                .spawn()
713                .expect("spawn tracked child");
714            let pid = child.id() as i32;
715            drop(child);
716            let exit_watcher = guard.track(pid).expect("track child");
717            tracked.push((pid, code, exit_watcher));
718        }
719
720        for (pid, _, _) in &tracked {
721            wait_until_exited_without_reaping(*pid);
722        }
723
724        assert!(
725            manager
726                .reap_exited_batch()
727                .expect("reap first bounded batch")
728        );
729        drop(
730            manager
731                .spawn_guard()
732                .expect("spawn lock should be released between reap batches"),
733        );
734        assert!(
735            !manager
736                .reap_exited_batch()
737                .expect("reap remaining children")
738        );
739
740        let runtime = tokio::runtime::Builder::new_current_thread()
741            .build()
742            .expect("test runtime");
743        for (_, expected_code, exit_watcher) in tracked {
744            assert_eq!(runtime.block_on(exit_watcher), expected_code);
745        }
746
747        let orphan = Command::new("/bin/sh")
748            .args(["-c", "exit 43"])
749            .spawn()
750            .expect("spawn untracked child");
751        let orphan_pid = orphan.id() as i32;
752        drop(orphan);
753        wait_until_exited_without_reaping(orphan_pid);
754
755        assert!(!manager.reap_exited_batch().expect("reap untracked child"));
756        assert_already_reaped(orphan_pid);
757    }
758
759    fn wait_until_exited_without_reaping(pid: i32) {
760        let deadline = Instant::now() + Duration::from_secs(5);
761        while Instant::now() < deadline {
762            let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
763            let ret = unsafe {
764                libc::waitid(
765                    libc::P_PID,
766                    pid as libc::id_t,
767                    &mut info,
768                    libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
769                )
770            };
771            assert_eq!(ret, 0, "waitid failed: {}", std::io::Error::last_os_error());
772            if unsafe { info.si_pid() } == pid {
773                return;
774            }
775            thread::sleep(Duration::from_millis(10));
776        }
777
778        panic!("child {pid} did not exit");
779    }
780
781    fn assert_already_reaped(pid: i32) {
782        let ret = unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) };
783        assert_eq!(ret, -1);
784        assert_eq!(
785            std::io::Error::last_os_error().raw_os_error(),
786            Some(libc::ECHILD)
787        );
788    }
789}