Files
netris-nestri/apps/nesinit/tests/reaping.rs
KAAL1 071241f944 fix(nesinit): a pid stops being the workload's the moment it is reaped
A pid is only a name for a process until that process is reaped; after that
the kernel may hand the same number to something else. The handle kept the
number, so a stop or a kill issued during shutdown — which every session
outcome reaches — could land on a process nobody meant, and the one aimed at
the workload would have been a SIGKILL.

The registry now clears the flag as it delivers the exit, in the same call, and
nothing signals a pid whose flag is down. Waiting for the workload on the way
out takes the same answer: already reaped is already gone.

There is a window left, between the reap and the delivery, and closing it
entirely needs a handle the kernel keeps rather than a number. Recorded rather
than papered over.
2026-09-05 10:04:01 +03:00

218 lines
7.1 KiB
Rust

// Reaping, against real processes.
//
// Its own test binary on purpose: the reaper waits on any child, so it would
// collect processes another test in the same binary was waiting for.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use nesinit::reap::{Waiters, become_subreaper, reap_exited};
/// Reaping is process-wide: `waitpid(-1, ...)` collects any child, so two of
/// these tests running at once would each reap the other's. One at a time.
static ONE_REAPER: Mutex<()> = Mutex::new(());
fn alone() -> MutexGuard<'static, ()> {
ONE_REAPER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Fork a child, run `body` in it, and never return from the child.
///
/// Raw fork rather than `Command` because the point is a child nothing else
/// holds a handle to — the standard library reaps the children it spawns,
/// which is precisely the work under test.
fn fork_child(body: impl FnOnce()) -> i32 {
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed: {}", std::io::Error::last_os_error());
if pid == 0 {
body();
unsafe { libc::_exit(0) };
}
pid
}
/// Reap until `pid` turns up, or give up.
fn reap_until(pid: i32) -> Option<nesprotocol::lifecycle::Exit> {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
for (reaped, exit) in reap_exited() {
if reaped == pid {
return Some(exit);
}
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
#[test]
fn a_child_that_exits_is_reaped_with_its_code() {
let _alone = alone();
let pid = fork_child(|| unsafe { libc::_exit(7) });
let exit = reap_until(pid).expect("the child was left a zombie");
assert_eq!(exit.exit_code, Some(7));
assert_eq!(exit.signal, None);
}
#[test]
fn a_child_that_is_killed_is_reaped_as_signalled() {
let _alone = alone();
let pid = fork_child(|| {
// Sleep long enough to be killed rather than to exit on its own.
unsafe { libc::pause() };
});
assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0);
let exit = reap_until(pid).expect("the child was left a zombie");
assert_eq!(exit.signal, Some(libc::SIGKILL));
assert_eq!(exit.exit_code, None, "a killed process has no exit code");
}
#[test]
fn an_orphan_is_reaped_by_whoever_inherits_it() {
let _alone = alone();
become_subreaper().expect("PR_SET_CHILD_SUBREAPER");
// A grandchild that outlives its parent. In a guest the kernel hands it to
// PID 1; here the same reparenting is arranged with the subreaper bit, so
// the reaper is exercised rather than the privilege.
let (read_fd, write_fd) = pipe();
let child = fork_child(|| {
let grandchild = unsafe { libc::fork() };
if grandchild == 0 {
// Outlive the parent, then exit with a code the test can pick out.
std::thread::sleep(Duration::from_millis(200));
unsafe { libc::_exit(11) };
}
let pid = grandchild.to_le_bytes();
unsafe { libc::write(write_fd, pid.as_ptr().cast(), pid.len()) };
unsafe { libc::_exit(0) };
});
let mut buf = [0u8; 4];
let read = unsafe { libc::read(read_fd, buf.as_mut_ptr().cast(), buf.len()) };
assert_eq!(read, 4, "the child never reported its own child");
let grandchild = i32::from_le_bytes(buf);
// The parent goes first; the orphan is what this test is about.
assert!(reap_until(child).is_some(), "the child was left a zombie");
let exit = reap_until(grandchild).expect("the orphan was left a zombie");
assert_eq!(exit.exit_code, Some(11));
}
fn pipe() -> (i32, i32) {
let mut fds = [0i32; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
(fds[0], fds[1])
}
#[test]
fn an_exit_reaches_whoever_asked_for_it() {
let _alone = alone();
let waiters = Waiters::new();
let mut watched = waiters
.watch(|| Ok(fork_child(|| unsafe { libc::_exit(9) })))
.expect("the child never started");
let mut exited = watched.take_exit().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
for (pid, exit) in reap_exited() {
waiters.deliver(pid, exit);
}
if let Ok(exit) = exited.try_recv() {
assert_eq!(exit.exit_code, Some(9));
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the exit was reaped by the reaper and never handed on");
}
#[test]
fn an_exit_that_happens_before_the_caller_is_registered_is_not_lost() {
let _alone = alone();
let waiters = Waiters::new();
// A reaper already running, as it is in the guest: it will collect this
// child before `watch` has finished registering interest in it.
let stop = Arc::new(AtomicBool::new(false));
let reaper = std::thread::spawn({
let waiters = waiters.clone();
let stop = stop.clone();
move || {
while !stop.load(Ordering::Relaxed) {
for (pid, exit) in reap_exited() {
waiters.deliver(pid, exit);
}
std::thread::sleep(Duration::from_millis(1));
}
}
});
let mut watched = waiters
.watch(|| {
let pid = fork_child(|| unsafe { libc::_exit(5) });
// Long enough for the child to exit and the reaper to reach it.
std::thread::sleep(Duration::from_millis(200));
Ok(pid)
})
.expect("the child never started");
let mut exited = watched.take_exit().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let exit = loop {
if let Ok(exit) = exited.try_recv() {
break exit;
}
assert!(
Instant::now() < deadline,
"the exit was dropped on the way through"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(exit.exit_code, Some(5));
stop.store(true, Ordering::Relaxed);
reaper.join().unwrap();
}
#[test]
fn a_pid_stops_being_the_workload_the_moment_it_is_reaped() {
let _alone = alone();
let waiters = Waiters::new();
let mut watched = waiters
.watch(|| Ok(fork_child(|| unsafe { libc::_exit(0) })))
.expect("the child never started");
let mut exited = watched.take_exit().unwrap();
assert!(
watched.running(),
"a child that has not been reaped is still itself"
);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
for (pid, exit) in reap_exited() {
waiters.deliver(pid, exit);
}
if exited.try_recv().is_ok() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
// The pid was freed by the reap that produced that exit, and the kernel is
// entitled to hand the number to something else. Signalling it after this
// point is signalling a stranger.
assert!(
!watched.running(),
"a reaped pid is still being treated as the workload's",
);
}