server/sandbox/tests/reap_grace.rs
//! **A PID 1's reaper leaves an owner's child to its owner** (lane lsm-shim, 2026-11-03).
//!
//! MEASURED on the LSM appliance kernel: Heimdall's runner spawns its shim and polls
//! `try_wait`; between two polls the PID-1 reaper's `waitpid(-1)` took the exit, and every
//! job read `setup: waiting for the shim: No child process (os error 10)`. The claim
//! (`appliance_pid1::claim`) covers only the engines that claim; Heimdall's runner, a
//! library, cannot. The reaper therefore leaves every exited child a grace before it takes
//! the status: an owner that waits gets its child's exit, an orphan is still reaped.
//!
//! One test function: the reaper looks at every child of this process, so the steps run
//! in one binary, one after another.
use std::process::Command;
use std::time::{Duration, Instant};
use holger_sandbox::reap::Reaper;
fn exited_child() -> std::process::Child {
let child = Command::new("true").spawn().expect("spawn true");
// Let it exit and stay a zombie.
let pid = child.id() as i32;
let t0 = Instant::now();
while !holger_sandbox::reap::zombie_children()
.expect("read /proc")
.contains(&pid)
{
assert!(
t0.elapsed() < Duration::from_secs(10),
"child {pid} never exited"
);
std::thread::sleep(Duration::from_millis(5));
}
child
}
#[test]
fn the_reaper_leaves_an_owners_child_its_grace_and_reaps_the_abandoned() {
// 1. An owner polling `try_wait` (Heimdall's run_shim) gets the exit, not ECHILD.
let mut r = Reaper::new(Duration::from_secs(5));
let mut owned = exited_child();
let pass = r.pass(Instant::now(), &|_| false);
assert!(
pass.reaped.is_empty(),
"the reaper took an owner's child: {:?}",
pass.reaped
);
assert_eq!(pass.waiting, 1, "the exited child is waiting in its grace");
let status = owned.try_wait().expect("the owner's try_wait: not ECHILD");
assert!(
status.is_some_and(|s| s.success()),
"the owner reads its child's exit"
);
// 2. A child nobody waits for (an orphan, a dropped Child) is reaped once its grace is over.
let mut r = Reaper::new(Duration::from_millis(50));
let abandoned = exited_child();
let pid = abandoned.id() as i32;
std::mem::forget(abandoned);
let first = r.pass(Instant::now(), &|_| false);
assert!(first.reaped.is_empty(), "reaped inside its grace");
std::thread::sleep(Duration::from_millis(80));
let later = r.pass(Instant::now(), &|_| false);
assert_eq!(
later.reaped,
vec![pid],
"the abandoned child is reaped after its grace"
);
assert!(
!holger_sandbox::reap::zombie_children()
.unwrap()
.contains(&pid),
"no zombie left"
);
// 3. A claimed child is never taken, even past its grace.
let mut r = Reaper::new(Duration::from_millis(1));
let mut claimed = exited_child();
let cpid = claimed.id() as i32;
std::thread::sleep(Duration::from_millis(20));
let pass = r.pass(Instant::now(), &|p| p == cpid);
let pass2 = r.pass(Instant::now() + Duration::from_secs(1), &|p| p == cpid);
assert!(
pass.reaped.is_empty() && pass2.reaped.is_empty(),
"a claimed child was taken"
);
assert!(claimed.try_wait().expect("claimed: not ECHILD").is_some());
}