use crate::process::termination::TrackedProcessGroup;
use crate::process::{WaitTimeout as _, configure_process_group, kill_process_tree, termination};
pub(crate) enum Deadline {
Exited(std::process::ExitStatus),
Expired,
}
pub(crate) struct GroupChild {
child: std::process::Child,
tracked: TrackedProcessGroup,
}
impl GroupChild {
pub(crate) fn spawn(command: &mut std::process::Command) -> std::io::Result<Self> {
configure_process_group(command);
let child = command.spawn()?;
let tracked = termination::track(&child);
Ok(Self { child, tracked })
}
pub(crate) fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
self.child.stdout.take()
}
pub(crate) fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
self.child.stderr.take()
}
pub(crate) fn kill_tree(&mut self) {
kill_process_tree(&mut self.child, &self.tracked);
match self.child.wait_timeout(crate::process::KILL_REAP_LIMIT) {
Ok(Some(_)) | Err(_) => {}
Ok(None) => {
tracing::error!(
pid = self.child.id(),
reap_limit_secs = crate::process::KILL_REAP_LIMIT.as_secs(),
"child was not reaped within the kill limit after being signalled; abandoning \
the wait instead of hanging"
);
}
}
}
pub(crate) fn wait_within(
&mut self,
budget: std::time::Duration,
command: &impl std::fmt::Debug,
) -> std::io::Result<Deadline> {
let deadline = std::time::Instant::now() + budget;
let mut stopped_since: Option<std::time::Instant> = None;
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
tracing::warn!(
command = ?command,
budget_seconds = budget.as_secs(),
"command exceeded its timeout; killing its process group"
);
self.kill_tree();
return Ok(Deadline::Expired);
}
let chunk = remaining.min(crate::process::STOPPED_PROCESS_POLL_INTERVAL);
match self.child.wait_timeout(chunk) {
Ok(Some(status)) => return Ok(Deadline::Exited(status)),
Ok(None) => {}
Err(error) => {
self.kill_tree();
return Err(error);
}
}
if crate::process::is_process_stopped(self.child.id()) {
let since = *stopped_since.get_or_insert_with(std::time::Instant::now);
if since.elapsed() >= crate::process::STOPPED_PROCESS_GRACE {
tracing::warn!(
command = ?command,
"child process is stopped rather than running; killing its process group \
instead of waiting out the rest of its timeout budget"
);
self.kill_tree();
return Ok(Deadline::Expired);
}
} else {
stopped_since = None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::{Deadline, GroupChild};
use std::time::Duration;
const SETTLE_POLL: Duration = Duration::from_millis(20);
const SETTLE_LIMIT: Duration = Duration::from_secs(10);
const PROBE_LIFETIME: Duration = Duration::from_secs(60);
const GRANDCHILD_PROBE_MARKER: &str = "ALEF_PROCESS_GRANDCHILD_PROBE";
const GRANDCHILD_PROBE_NAME: &str = "process::timed::tests::grandchild_probe_child";
fn long_lived_command() -> std::process::Command {
let mut command = if cfg!(windows) {
let mut ping = std::process::Command::new("ping");
ping.args(["-n", "61", "127.0.0.1"]);
ping
} else {
let mut sleep = std::process::Command::new("sleep");
sleep.arg("60");
sleep
};
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
command
}
fn exiting_command(code: i32) -> std::process::Command {
let (shell, flag) = if cfg!(windows) { ("cmd", "/C") } else { ("sh", "-c") };
let mut command = std::process::Command::new(shell);
command.args([flag, &format!("exit {code}")]);
command
}
#[cfg(unix)]
fn is_alive(pid: u32) -> bool {
unsafe { libc::kill(pid.cast_signed(), 0) == 0 }
}
#[cfg(windows)]
fn is_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return false;
}
let mut exit_code = 0_u32;
let read = unsafe { GetExitCodeProcess(handle, &raw mut exit_code) };
unsafe {
CloseHandle(handle);
}
read != 0 && exit_code == STILL_ACTIVE.cast_unsigned()
}
#[cfg(unix)]
fn terminate(pid: u32) {
unsafe {
libc::kill(pid.cast_signed(), libc::SIGKILL);
}
}
#[cfg(windows)]
fn terminate(pid: u32) {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return;
}
unsafe {
TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
fn wait_until_gone(pid: u32) -> bool {
let deadline = std::time::Instant::now() + SETTLE_LIMIT;
while std::time::Instant::now() < deadline {
if !is_alive(pid) {
return true;
}
std::thread::sleep(SETTLE_POLL);
}
!is_alive(pid)
}
fn probe_command(marker: &std::path::Path) -> std::process::Command {
let mut command = std::process::Command::new(std::env::current_exe().expect("the test binary"));
command
.args(["--exact", GRANDCHILD_PROBE_NAME, "--ignored", "--test-threads=1"])
.env(GRANDCHILD_PROBE_MARKER, marker)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
command
}
fn announced_grandchild(marker: &std::path::Path) -> u32 {
let deadline = std::time::Instant::now() + SETTLE_LIMIT;
loop {
assert!(
std::time::Instant::now() < deadline,
"the probe never announced a grandchild"
);
if let Ok(contents) = std::fs::read_to_string(marker)
&& let Ok(pid) = contents.trim().parse::<u32>()
&& is_alive(pid)
{
return pid;
}
std::thread::sleep(SETTLE_POLL);
}
}
#[test]
#[ignore = "spawned as a subprocess by the process-tree kill tests"]
#[expect(
clippy::zombie_processes,
reason = "the grandchild is meant to outlive this process; waiting on it is what the tree kill has to make unnecessary"
)]
fn grandchild_probe_child() {
let Ok(marker) = std::env::var(GRANDCHILD_PROBE_MARKER) else {
return;
};
let grandchild = long_lived_command().spawn().expect("spawn the grandchild");
std::fs::write(&marker, grandchild.id().to_string()).expect("announce the grandchild");
std::thread::sleep(PROBE_LIFETIME);
}
#[test]
fn an_expired_deadline_kills_the_grandchild_too() {
let directory = tempfile::tempdir().expect("scratch directory");
let marker = directory.path().join("grandchild.pid");
let mut child = GroupChild::spawn(&mut probe_command(&marker)).expect("spawn the process group");
let grandchild = announced_grandchild(&marker);
let outcome = child
.wait_within(Duration::from_millis(200), &GRANDCHILD_PROBE_NAME)
.expect("waiting on a live child is not an error");
assert!(matches!(outcome, Deadline::Expired));
assert!(
wait_until_gone(grandchild),
"grandchild {grandchild} survived its parent's deadline"
);
}
#[test]
fn killing_the_direct_child_alone_leaves_the_grandchild_running() {
let directory = tempfile::tempdir().expect("scratch directory");
let marker = directory.path().join("grandchild.pid");
let mut child = probe_command(&marker).spawn().expect("spawn the probe");
let grandchild = announced_grandchild(&marker);
let _ = child.kill();
let _ = child.wait();
std::thread::sleep(SETTLE_POLL);
let survived = is_alive(grandchild);
terminate(grandchild);
assert!(
survived,
"grandchild {grandchild} died without a tree kill, so the test above proves nothing"
);
}
#[test]
fn a_child_that_beats_its_deadline_reports_its_own_exit_status() {
let mut child = GroupChild::spawn(&mut exiting_command(7)).expect("spawn the process group");
let outcome = child
.wait_within(Duration::from_secs(30), &"exit 7")
.expect("waiting on a live child is not an error");
match outcome {
Deadline::Exited(status) => assert_eq!(status.code(), Some(7)),
Deadline::Expired => panic!("a command that exits immediately must not report expiry"),
}
}
#[cfg(unix)]
#[test]
fn a_stopped_child_is_detected_and_killed_well_before_the_timeout_budget() {
let mut child = GroupChild::spawn(&mut long_lived_command()).expect("spawn the process group");
let pid = child.child.id();
unsafe {
libc::kill(pid.cast_signed(), libc::SIGSTOP);
}
let stop_confirmed_by = std::time::Instant::now() + SETTLE_LIMIT;
while !crate::process::is_process_stopped(pid) {
assert!(
std::time::Instant::now() < stop_confirmed_by,
"the fixture child never reached the kernel's stopped state"
);
std::thread::sleep(SETTLE_POLL);
}
let budget = Duration::from_secs(30);
let started = std::time::Instant::now();
let outcome = child
.wait_within(budget, &"stopped-fixture")
.expect("waiting on a stopped child is not an error");
let elapsed = started.elapsed();
assert!(
matches!(outcome, Deadline::Expired),
"a stopped child can never exit on its own and must report as expired"
);
assert!(
elapsed < budget / 2,
"a stopped child must be detected and killed well inside its timeout budget, not after \
waiting it out: took {elapsed:?} against a {budget:?} budget"
);
assert!(wait_until_gone(pid), "stopped child {pid} survived its own detection");
}
}