use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result};
use crate::log::StageLogger;
use crate::retry::Retriable;
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
const POST_EXIT_DRAIN_GRACE: Duration = Duration::from_secs(3);
#[cfg(unix)]
fn set_own_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
#[cfg(windows)]
fn set_own_process_group(cmd: &mut Command) {
use std::os::windows::process::CommandExt as _;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
#[cfg(not(any(unix, windows)))]
fn set_own_process_group(_cmd: &mut Command) {}
#[derive(Clone, Copy)]
struct ChildTree {
pid: i32,
#[cfg(windows)]
job: Option<windows_job::JobHandle>,
}
impl ChildTree {
fn reap(self, signal: i32) {
#[cfg(unix)]
{
unsafe {
libc::kill(-self.pid, signal);
}
}
#[cfg(windows)]
{
let _ = signal; match self.job {
Some(job) => job.terminate(),
None => taskkill_tree(self.pid),
}
}
}
}
#[cfg(windows)]
fn taskkill_tree(pid: i32) {
let taskkill = std::env::var_os("SystemRoot")
.map(|root| {
std::path::Path::new(&root)
.join("System32")
.join("taskkill.exe")
})
.unwrap_or_else(|| std::path::PathBuf::from("taskkill.exe"));
let _ = std::process::Command::new(taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
fn kill_child_tree(child: &mut Child, tree: ChildTree) {
#[cfg(unix)]
tree.reap(libc::SIGKILL);
#[cfg(windows)]
tree.reap(0);
let _ = child.kill();
}
#[cfg(windows)]
mod windows_job {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle as _;
use std::process::Child;
type Handle = *mut c_void;
type Bool = i32;
type Dword = u32;
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9;
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: Dword = 0x0000_2000;
const JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION: Dword = 0x0000_0400;
#[repr(C)]
#[derive(Clone, Copy)]
#[allow(dead_code)]
struct JobObjectBasicLimitInformation {
per_process_user_time_limit: i64,
per_job_user_time_limit: i64,
limit_flags: Dword,
minimum_working_set_size: usize,
maximum_working_set_size: usize,
active_process_limit: Dword,
affinity: usize,
priority_class: Dword,
scheduling_class: Dword,
}
#[repr(C)]
#[derive(Clone, Copy)]
#[allow(dead_code)]
struct IoCounters {
read_operation_count: u64,
write_operation_count: u64,
other_operation_count: u64,
read_transfer_count: u64,
write_transfer_count: u64,
other_transfer_count: u64,
}
#[repr(C)]
#[derive(Clone, Copy)]
#[allow(dead_code)]
struct JobObjectExtendedLimitInformation {
basic_limit_information: JobObjectBasicLimitInformation,
io_info: IoCounters,
process_memory_limit: usize,
job_memory_limit: usize,
peak_process_memory_used: usize,
peak_job_memory_used: usize,
}
unsafe extern "system" {
fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> Handle;
fn SetInformationJobObject(
job: Handle,
class: i32,
info: *const c_void,
len: Dword,
) -> Bool;
fn AssignProcessToJobObject(job: Handle, process: Handle) -> Bool;
fn TerminateJobObject(job: Handle, exit_code: Dword) -> Bool;
fn CloseHandle(object: Handle) -> Bool;
}
#[derive(Clone, Copy)]
pub struct JobHandle(isize);
unsafe impl Send for JobHandle {}
unsafe impl Sync for JobHandle {}
impl JobHandle {
pub fn terminate(self) {
unsafe {
let _ = TerminateJobObject(self.0 as Handle, 1);
}
}
pub fn close(self) {
unsafe {
let _ = CloseHandle(self.0 as Handle);
}
}
}
pub fn enclose_child(child: &Child) -> Option<JobHandle> {
unsafe {
let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if job.is_null() {
return None;
}
let mut info: JobObjectExtendedLimitInformation = std::mem::zeroed();
info.basic_limit_information.limit_flags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
if SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
std::ptr::addr_of!(info) as *const c_void,
std::mem::size_of::<JobObjectExtendedLimitInformation>() as Dword,
) == 0
{
let _ = CloseHandle(job);
return None;
}
if AssignProcessToJobObject(job, child.as_raw_handle() as Handle) == 0 {
let _ = CloseHandle(job);
return None;
}
Some(JobHandle(job as isize))
}
}
}
static LIVE_CHILD_TREES: OnceLock<Mutex<std::collections::HashMap<i32, ChildTree>>> =
OnceLock::new();
fn live_child_trees() -> &'static Mutex<std::collections::HashMap<i32, ChildTree>> {
LIVE_CHILD_TREES.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
fn register_child_tree(tree: ChildTree) {
live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.insert(tree.pid, tree);
}
fn deregister_child_tree(pid: i32) {
live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(&pid);
}
struct TreeRegistration(ChildTree);
impl Drop for TreeRegistration {
fn drop(&mut self) {
deregister_child_tree(self.0.pid);
#[cfg(windows)]
if let Some(job) = self.0.job {
job.close();
}
}
}
fn terminate_all_child_trees() -> usize {
let trees: Vec<ChildTree> = {
let guard = live_child_trees().lock().unwrap_or_else(|p| p.into_inner());
guard.values().copied().collect()
};
for tree in trees.iter().copied() {
#[cfg(unix)]
tree.reap(libc::SIGTERM);
#[cfg(windows)]
tree.reap(0);
}
trees.len()
}
pub fn install_termination_handler() {
static INSTALLED: OnceLock<()> = OnceLock::new();
if INSTALLED.set(()).is_err() {
return; }
#[cfg(unix)]
unix_termination::install();
#[cfg(windows)]
windows_termination::install();
}
#[cfg(unix)]
mod unix_termination {
use super::terminate_all_child_trees;
use std::os::unix::io::RawFd;
use std::sync::atomic::{AtomicI32, Ordering};
static WAKE_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
static FIRED_SIGNAL: AtomicI32 = AtomicI32::new(0);
extern "C" fn on_signal(sig: libc::c_int) {
FIRED_SIGNAL.store(sig, Ordering::SeqCst);
let fd = WAKE_WRITE_FD.load(Ordering::SeqCst);
if fd >= 0 {
let byte: u8 = 1;
unsafe {
let _ = libc::write(fd, &byte as *const u8 as *const libc::c_void, 1);
}
}
}
pub fn install() {
let mut fds: [RawFd; 2] = [-1, -1];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return;
}
let (read_fd, write_fd) = (fds[0], fds[1]);
WAKE_WRITE_FD.store(write_fd, Ordering::SeqCst);
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = on_signal as *const () as usize;
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_flags = 0;
libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut());
libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut());
}
std::thread::Builder::new()
.name("anodizer-sigwatch".into())
.spawn(move || watcher(read_fd))
.ok();
}
fn watcher(read_fd: RawFd) -> ! {
let mut byte = [0u8; 1];
loop {
let n = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
if n != 0 {
break; }
}
terminate_all_child_trees();
let sig = FIRED_SIGNAL.load(Ordering::SeqCst);
let sig = if sig == 0 { libc::SIGTERM } else { sig };
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = libc::SIG_DFL;
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_flags = 0;
libc::sigaction(sig, &sa, std::ptr::null_mut());
libc::raise(sig);
}
std::process::exit(128 + sig);
}
}
#[cfg(windows)]
mod windows_termination {
use super::terminate_all_child_trees;
use std::sync::atomic::{AtomicBool, Ordering};
type Bool = i32;
type Dword = u32;
const TRUE: Bool = 1;
const CTRL_C_EVENT: Dword = 0;
const CTRL_BREAK_EVENT: Dword = 1;
const CTRL_CLOSE_EVENT: Dword = 2;
const CTRL_LOGOFF_EVENT: Dword = 5;
const CTRL_SHUTDOWN_EVENT: Dword = 6;
static FIRED: AtomicBool = AtomicBool::new(false);
unsafe extern "system" {
fn SetConsoleCtrlHandler(handler: Option<HandlerRoutine>, add: Bool) -> Bool;
}
type HandlerRoutine = unsafe extern "system" fn(ctrl_type: Dword) -> Bool;
unsafe extern "system" fn on_ctrl(ctrl_type: Dword) -> Bool {
match ctrl_type {
CTRL_C_EVENT | CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT
| CTRL_SHUTDOWN_EVENT => {
FIRED.store(true, Ordering::SeqCst);
terminate_all_child_trees();
0
}
_ => 0,
}
}
pub fn install() {
unsafe {
SetConsoleCtrlHandler(Some(on_ctrl), TRUE);
}
}
}
pub fn run_checked(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
if log.is_verbose() {
run_streamed(cmd, log, label)
} else {
let output = cmd
.output()
.with_context(|| format!("failed to spawn {label}"))?;
log.check_output(output, label)
}
}
pub fn run_checked_with_stdin(
cmd: &mut Command,
stdin: &[u8],
log: &StageLogger,
label: &str,
) -> Result<Output> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_inner(cmd, Some(stdin), log, label, None)
}
pub fn run_checked_with_stdin_timeout(
cmd: &mut Command,
stdin: &[u8],
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_inner(cmd, Some(stdin), log, label, Some(timeout))
}
fn run_streamed(cmd: &mut Command, log: &StageLogger, label: &str) -> Result<Output> {
run_inner(cmd, None, log, label, None)
}
pub fn run_checked_timeout(
cmd: &mut Command,
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
run_inner(cmd, None, log, label, Some(timeout))
}
fn wait_or_kill(
child: &Mutex<Child>,
readers_done: &AtomicUsize,
reader_count: usize,
timeout: Duration,
tree: ChildTree,
) -> std::io::Result<Option<ExitStatus>> {
let deadline = Instant::now() + timeout;
let mut exited: Option<ExitStatus> = None;
let mut drain_deadline: Option<Instant> = None;
loop {
if exited.is_none() {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
if let Some(status) = guard.try_wait()? {
exited = Some(status);
drain_deadline = Some(Instant::now() + POST_EXIT_DRAIN_GRACE);
} else if Instant::now() >= deadline {
kill_child_tree(&mut guard, tree);
return Ok(None);
}
}
if let Some(status) = exited {
if readers_done.load(Ordering::Acquire) >= reader_count {
return Ok(Some(status));
}
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
kill_child_tree(&mut guard, tree);
return Ok(Some(status));
}
}
std::thread::sleep(WAIT_POLL_INTERVAL);
}
}
fn capture_inner(
cmd: &mut Command,
stdin: Option<&[u8]>,
log: &StageLogger,
label: &str,
timeout: Option<Duration>,
) -> Result<Output> {
let verbose = log.is_verbose();
if timeout.is_some() {
set_own_process_group(cmd);
}
let mut child = cmd
.spawn()
.with_context(|| format!("failed to spawn {label}"))?;
#[cfg(windows)]
let job = if timeout.is_some() {
windows_job::enclose_child(&child)
} else {
None
};
let tree = ChildTree {
pid: child.id() as i32,
#[cfg(windows)]
job,
};
let _registration = timeout.is_some().then(|| {
register_child_tree(tree);
TreeRegistration(tree)
});
let child_stdin = match stdin {
Some(_) => Some(
child
.stdin
.take()
.with_context(|| format!("{label}: child has no stdin pipe"))?,
),
None => None,
};
let child_stdout = child
.stdout
.take()
.with_context(|| format!("{label}: child has no stdout pipe"))?;
let child_stderr = child
.stderr
.take()
.with_context(|| format!("{label}: child has no stderr pipe"))?;
let child = Mutex::new(child);
let mut out_buf: Vec<u8> = Vec::new();
let mut err_buf: Vec<u8> = Vec::new();
let mut stdin_err: Option<std::io::Error> = None;
let mut timed_out = false;
let mut watchdog_err: Option<std::io::Error> = None;
let child_ref = &child;
let readers_done = AtomicUsize::new(0);
let readers_done_ref = &readers_done;
std::thread::scope(|s| {
let stdin_handle = child_stdin.map(|mut pipe| {
let bytes = stdin.expect("child_stdin is Some only when stdin is Some");
s.spawn(move || -> std::io::Result<()> {
pipe.write_all(bytes)?;
Ok(())
})
});
let out_handle = s.spawn(move || {
let buf = tee_stream(child_stdout, log, false, verbose);
readers_done_ref.fetch_add(1, Ordering::Release);
buf
});
let err_handle = s.spawn(move || {
let buf = tee_stream(child_stderr, log, true, verbose);
readers_done_ref.fetch_add(1, Ordering::Release);
buf
});
let watchdog =
timeout.map(|t| s.spawn(move || wait_or_kill(child_ref, readers_done_ref, 2, t, tree)));
out_buf = join_capture(out_handle, log, "stdout");
err_buf = join_capture(err_handle, log, "stderr");
if let Some(h) = watchdog {
match h.join() {
Ok(Ok(Some(_status))) => {} Ok(Ok(None)) => timed_out = true,
Ok(Err(e)) => watchdog_err = Some(e),
Err(_) => log.warn(&format!("{label}: timeout watchdog thread panicked")),
}
}
if let Some(h) = stdin_handle {
match h.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => stdin_err = Some(e),
Err(_) => log.warn(&format!("{label}: stdin writer thread panicked")),
}
}
});
let reaped = {
let mut guard = child.lock().unwrap_or_else(|p| p.into_inner());
guard.wait()
};
if let Some(e) = watchdog_err {
return Err(anyhow::Error::new(e).context(format!("{label}: failed to wait for child")));
}
if timed_out {
let secs = timeout.map(|t| t.as_secs_f64()).unwrap_or_default();
return Err(anyhow::Error::new(Retriable::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("{label}: child did not exit within {secs:.0}s; killed"),
))));
}
if let Some(e) = stdin_err
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(anyhow::Error::new(e).context(format!("{label}: failed to write stdin")));
}
let status = reaped.with_context(|| format!("{label}: failed to wait for child"))?;
Ok(Output {
status,
stdout: out_buf,
stderr: err_buf,
})
}
fn run_inner(
cmd: &mut Command,
stdin: Option<&[u8]>,
log: &StageLogger,
label: &str,
timeout: Option<Duration>,
) -> Result<Output> {
let output = capture_inner(cmd, stdin, log, label, timeout)?;
if log.is_verbose() {
log.check_output_streamed(output, label)
} else {
log.check_output(output, label)
}
}
pub fn run_capture_timeout(
cmd: &mut Command,
log: &StageLogger,
label: &str,
timeout: Duration,
) -> Result<Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
capture_inner(cmd, None, log, label, Some(timeout))
}
fn join_capture(
handle: std::thread::ScopedJoinHandle<'_, Vec<u8>>,
log: &StageLogger,
stream: &str,
) -> Vec<u8> {
match handle.join() {
Ok(buf) => buf,
Err(_) => {
log.warn(&format!(
"internal: {stream} capture thread panicked; output for this step is lost"
));
Vec::new()
}
}
}
fn tee_stream<R: std::io::Read>(
reader: R,
log: &StageLogger,
is_stderr: bool,
tee: bool,
) -> Vec<u8> {
let mut buf = BufReader::new(reader);
let mut capture: Vec<u8> = Vec::new();
let mut line: Vec<u8> = Vec::new();
loop {
line.clear();
match buf.read_until(b'\n', &mut line) {
Ok(0) => break,
Ok(_) => {
capture.extend_from_slice(&line);
if tee {
let text = String::from_utf8_lossy(&line);
let stripped = text.trim_end_matches(['\n', '\r']);
if is_stderr {
log.stream_child_stderr(stripped);
} else {
log.stream_child_stdout(stripped);
}
}
}
Err(_) => break,
}
}
capture
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::{LogLevel, StageLogger, Verbosity};
fn sh(script: &str) -> Command {
let mut c = Command::new("sh");
c.arg("-c").arg(script);
c
}
#[cfg(windows)]
fn cmd_c(script: &str) -> Command {
let mut c = Command::new("cmd");
c.arg("/c").arg(script);
c
}
fn count_level(cap: &crate::log::LogCapture, level: LogLevel) -> usize {
cap.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == level)
.count()
}
#[test]
#[cfg(unix)]
fn run_checked_success_is_silent_at_default_verbosity() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_checked(&mut sh("echo hi"), &log, "echo").expect("echo must succeed");
assert!(
String::from_utf8_lossy(&out.stdout).contains("hi"),
"captured stdout must contain the child's output"
);
assert_eq!(
cap.status_count(),
0,
"default-verbosity success must emit no status lines"
);
assert_eq!(
count_level(&cap, LogLevel::Verbose),
0,
"default-verbosity success must emit no verbose lines"
);
assert_eq!(cap.error_count(), 0, "success must emit no error lines");
}
#[test]
#[cfg(unix)]
fn run_checked_failure_embeds_child_stderr() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let err = run_checked(&mut sh("echo boom >&2; exit 3"), &log, "boomer")
.expect_err("non-zero exit must surface as Err");
let chain = format!("{err:#}");
assert!(
chain.contains("boom"),
"error must embed the child's stderr; got: {chain}"
);
assert!(
chain.contains("exit code: 3"),
"error must name the exit code; got: {chain}"
);
}
#[test]
#[cfg(unix)]
fn run_checked_verbose_emits_stdout_line() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
run_checked(&mut sh("echo hi"), &log, "echo").expect("echo must succeed");
let verbose: Vec<_> = cap
.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == LogLevel::Verbose)
.collect();
assert!(
verbose.iter().any(|(_, msg)| msg.contains("hi")),
"verbose run must record a verbose line containing the child's stdout; got: {verbose:?}"
);
}
#[test]
#[cfg(unix)]
fn run_checked_verbose_failure_no_double_emit() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let _ = run_checked(&mut sh("echo BOOMTOKEN >&2; exit 1"), &log, "boomer")
.expect_err("non-zero exit must surface as Err");
let hits = cap
.all_messages()
.into_iter()
.filter(|(_, msg)| msg.contains("BOOMTOKEN"))
.count();
assert_eq!(
hits, 1,
"verbose failure must surface its stderr exactly once (no double-emit)"
);
}
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_roundtrips() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_checked_with_stdin(&mut Command::new("cat"), b"piped-in\n", &log, "cat")
.expect("cat must succeed");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
"piped-in",
"cat must echo the piped stdin back on stdout"
);
}
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_verbose_roundtrips() {
let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let out = run_checked_with_stdin(&mut Command::new("cat"), b"streamed-in\n", &log, "cat")
.expect("cat must succeed");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
"streamed-in",
"verbose stdin path must also round-trip the piped input"
);
let verbose: Vec<_> = cap
.all_messages()
.into_iter()
.filter(|(lvl, _)| *lvl == LogLevel::Verbose)
.collect();
assert!(
verbose.iter().any(|(_, msg)| msg.contains("streamed-in")),
"verbose stdin run must tee the child's stdout; got: {verbose:?}"
);
}
fn big_stdin() -> Vec<u8> {
let mut v = vec![b'A'; 192 * 1024];
v.push(b'\n');
v
}
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_large_in_and_out_no_deadlock() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let stdin = big_stdin();
let out = run_checked_with_stdin(
&mut sh("cat; i=0; while [ $i -lt 100000 ]; do echo line$i; i=$((i+1)); done"),
&stdin,
&log,
"bigcat",
)
.expect("large in/out child must complete without hanging");
assert!(
out.stdout.len() > stdin.len() + 100_000,
"captured stdout must include the echoed stdin AND the generated lines; \
got {} bytes",
out.stdout.len()
);
assert!(
out.stdout.windows(3).any(|w| w == b"AAA"),
"echoed stdin must be present in captured stdout"
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("line99999"),
"the last generated stdout line must be captured"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_timeout_kills_hung_child() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let start = Instant::now();
let err = run_checked_with_stdin_timeout(
&mut sh("sleep 30"),
b"ignored stdin\n",
&log,
"hung",
Duration::from_millis(200),
)
.expect_err("a child outliving the timeout must surface as Err");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"timeout must return promptly (killed the child), took {elapsed:?}"
);
assert!(
err.downcast_ref::<crate::retry::Retriable>().is_some(),
"timeout error must be Retriable; got: {err:#}"
);
let chain = format!("{err:#}");
assert!(
chain.contains("did not exit") && chain.contains("killed"),
"timeout error must name the kill; got: {chain}"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_timeout_fast_child_succeeds() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_checked_with_stdin_timeout(
&mut Command::new("cat"),
b"within-deadline\n",
&log,
"cat",
Duration::from_secs(30),
)
.expect("a fast child must succeed under a generous timeout");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim_end(),
"within-deadline",
"the fast-path timeout call must still round-trip stdin to stdout"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(unix)]
fn run_capture_timeout_returns_nonzero_exit_as_ok_output() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let out = run_capture_timeout(
&mut sh("echo to-stdout; echo to-stderr >&2; exit 7"),
&log,
"classify-me",
Duration::from_secs(30),
)
.expect("a non-zero exit must be Ok(Output), not Err");
assert_eq!(
out.status.code(),
Some(7),
"the caller must see the real non-zero exit code"
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("to-stdout"),
"stdout must be captured for body classification"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("to-stderr"),
"stderr must be captured for body classification"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(unix)]
fn run_capture_timeout_kills_hung_child() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let start = Instant::now();
let err = run_capture_timeout(
&mut sh("sleep 30"),
&log,
"hung-upload",
Duration::from_millis(200),
)
.expect_err("a child outliving the timeout must surface as Err");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"timeout must return promptly (killed the child), took {elapsed:?}"
);
assert!(
crate::retry::is_retriable(err.as_ref()),
"a deadline kill must classify as retriable so the upload retries within budget; got: {err:#}"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(unix)]
fn run_capture_timeout_reaps_grandchild_holding_pipe() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let start = Instant::now();
let out = run_capture_timeout(
&mut sh("sleep 60 & echo started; exit 0"),
&log,
"grandchild-holds-pipe",
Duration::from_millis(300),
)
.expect("a clean child exit must yield Ok(Output), even with a leaked grandchild");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(20),
"the drain must be bounded — the leaked grandchild's pipe was reaped \
at the deadline, not waited out ({elapsed:?})"
);
assert_eq!(
out.status.code(),
Some(0),
"the direct child exited 0; reaping a leaked grandchild must not \
rewrite that into a failure (which would re-publish on retry)"
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("started"),
"output the child wrote before exiting must still be captured"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
#[cfg(windows)]
fn run_capture_timeout_reaps_grandchild_holding_pipe_windows() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let start = Instant::now();
let out = run_capture_timeout(
&mut cmd_c("start /b ping -n 60 127.0.0.1 & echo started & exit 0"),
&log,
"grandchild-holds-pipe",
Duration::from_millis(300),
)
.expect("a clean child exit must yield Ok(Output), even with a leaked grandchild");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(20),
"the drain must be bounded — the leaked grandchild's pipe was reaped \
at the deadline, not waited out ({elapsed:?})"
);
assert_eq!(
out.status.code(),
Some(0),
"the direct child exited 0; reaping a leaked grandchild must not \
rewrite that into a failure (which would re-publish on retry)"
);
let captured = String::from_utf8_lossy(&out.stdout);
assert!(
captured.contains("started"),
"output the child wrote before exiting must still be captured"
);
assert!(
captured.to_ascii_lowercase().contains("ping")
|| captured.to_ascii_lowercase().contains("pinging"),
"the backgrounded grandchild must genuinely hold the inherited pipe \
(its ping output should land in our capture); got: {captured:?}"
);
}
#[test]
#[cfg(unix)]
fn run_checked_with_stdin_large_in_and_out_no_deadlock_verbose() {
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Verbose);
let stdin = big_stdin();
let out = run_checked_with_stdin(
&mut sh("cat; i=0; while [ $i -lt 100000 ]; do echo line$i; i=$((i+1)); done"),
&stdin,
&log,
"bigcat",
)
.expect("verbose large in/out child must complete without hanging");
assert!(
String::from_utf8_lossy(&out.stdout).contains("line99999"),
"verbose path must capture the full stdout"
);
}
#[serial_test::serial(child_tree_registry)]
#[test]
fn child_tree_registry_add_and_remove() {
let sentinel = -424_242; register_child_tree(ChildTree {
pid: sentinel,
#[cfg(windows)]
job: None,
});
assert!(
live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains_key(&sentinel),
"register must make the tree visible to the watcher"
);
deregister_child_tree(sentinel);
assert!(
!live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains_key(&sentinel),
"deregister must drop the tree so a recycled pid is never signalled"
);
}
#[cfg(unix)]
#[serial_test::serial(child_tree_registry)]
#[test]
fn err_path_does_not_leak_registered_child_tree() {
let baseline = live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.len();
let (log, _cap) = StageLogger::with_capture("test", Verbosity::Normal);
let err = run_capture_timeout(
&mut sh("sleep 30"),
&log,
"leak-probe",
Duration::from_millis(50),
)
.expect_err("a child that outlives its timeout must surface an Err");
assert!(
format!("{err:#}").contains("did not exit within"),
"the error must be the watchdog deadline kill; got: {err:#}"
);
assert_eq!(
live_child_trees()
.lock()
.unwrap_or_else(|p| p.into_inner())
.len(),
baseline,
"the RAII guard must deregister the child tree even on the Err path"
);
}
#[cfg(unix)]
#[serial_test::serial(child_tree_registry)]
#[test]
fn watcher_kill_reaps_registered_child_tree() {
struct KillOnDrop(Option<std::process::Child>);
impl Drop for KillOnDrop {
fn drop(&mut self) {
if let Some(mut c) = self.0.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
let mut cmd = Command::new("sleep");
cmd.arg("300");
set_own_process_group(&mut cmd); let child = cmd.spawn().expect("spawn sleep child");
let pid = child.id() as i32;
let mut guard = KillOnDrop(Some(child));
register_child_tree(ChildTree { pid });
let killed = terminate_all_child_trees();
assert!(killed >= 1, "watcher must report it signalled ≥1 tree");
let status = guard
.0
.take()
.expect("child taken once")
.wait()
.expect("reap killed child");
deregister_child_tree(pid);
use std::os::unix::process::ExitStatusExt as _;
assert_eq!(
status.signal(),
Some(libc::SIGTERM),
"the registered child must die from the watcher's group SIGTERM, not outlive us; got {status:?}"
);
}
}