use std::io::{self, Read as _};
use std::process::{Command, ExitStatus, Output};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::stdout_piped::{StdoutPipedHandle, StdoutPipedSpawn, disclaimed_stdout_piped_spawn};
const POLL_INTERVAL: Duration = Duration::from_millis(20);
const DRAIN_GRACE: Duration = Duration::from_secs(1);
pub fn disclaimed_stdout_with_timeout(
program: &str,
args: &[String],
timeout: Duration,
) -> io::Result<Output> {
let mut cmd = Command::new(program);
cmd.args(args);
let StdoutPipedSpawn {
id,
mut stdout,
handle,
} = disclaimed_stdout_piped_spawn(cmd)?;
let (tx, rx) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = stdout.read_to_end(&mut buf);
let _ = tx.send(buf);
});
let mut waiter = Waiter(handle);
let deadline = Instant::now() + timeout;
loop {
match waiter.try_wait() {
Ok(Some(status)) => {
let stdout = rx.recv_timeout(DRAIN_GRACE).unwrap_or_default();
return Ok(Output {
status,
stdout,
stderr: Vec::new(),
});
}
Ok(None) => {}
Err(err) => {
if err.raw_os_error() != Some(libc::ECHILD) {
waiter.kill_and_reap();
}
return Err(err);
}
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
std::thread::sleep(POLL_INTERVAL.min(remaining));
}
waiter.kill_and_reap();
Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("`{program}` (pid {id}) did not exit within {timeout:?}; child killed"),
))
}
struct Waiter(StdoutPipedHandle);
impl Waiter {
fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
match &mut self.0 {
StdoutPipedHandle::Native(child) => child.try_wait(),
#[cfg(target_os = "macos")]
StdoutPipedHandle::Disclaimed(pid) => super::macos::try_wait_for(*pid),
}
}
fn kill_and_reap(&mut self) {
match &mut self.0 {
StdoutPipedHandle::Native(child) => {
let _ = child.kill();
let _ = child.wait();
}
#[cfg(target_os = "macos")]
StdoutPipedHandle::Disclaimed(pid) => {
unsafe { libc::kill(*pid, libc::SIGKILL) };
let _ = super::macos::wait_for(*pid);
}
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_string()).collect()
}
#[test]
#[serial_test::serial]
fn disclaimed_stdout_with_timeout_returns_output_for_fast_child() {
let out = disclaimed_stdout_with_timeout(
"/bin/sh",
&args(&["-c", "echo probe-output; exit 7"]),
Duration::from_secs(10),
)
.expect("a child that exits well inside the deadline must not error");
assert_eq!(out.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&out.stdout), "probe-output\n");
assert!(out.stderr.is_empty(), "this spawn shape discards stderr");
}
#[test]
#[serial_test::serial]
fn disclaimed_stdout_with_timeout_kills_and_reaps_wedged_child() {
let dir = tempfile::tempdir().expect("tempdir");
let pidfile = dir.path().join("pid");
let script = format!("echo $$ > {}; exec sleep 300", pidfile.display());
let started = Instant::now();
let err = disclaimed_stdout_with_timeout(
"/bin/sh",
&args(&["-c", &script]),
Duration::from_millis(300),
)
.expect_err("a wedged child must surface a timeout, not hang");
let elapsed = started.elapsed();
assert_eq!(err.kind(), io::ErrorKind::TimedOut, "err: {err}");
assert!(
elapsed < Duration::from_secs(10),
"bounded wait must return promptly, took {elapsed:?}"
);
let pid: libc::pid_t = std::fs::read_to_string(&pidfile)
.expect("child must have recorded its pid before wedging")
.trim()
.parse()
.expect("pid file must hold a pid");
let alive = unsafe { libc::kill(pid, 0) };
assert_eq!(
alive, -1,
"no wedged child may outlive the timeout (pid {pid} still exists)"
);
assert_eq!(
io::Error::last_os_error().raw_os_error(),
Some(libc::ESRCH),
"the child must be gone, not merely unsignalable"
);
}
#[test]
#[serial_test::serial]
fn disclaimed_stdout_with_timeout_reports_spawn_error_for_missing_binary() {
let err = disclaimed_stdout_with_timeout(
"/nonexistent/definitely-not-a-real-binary-5969",
&[],
Duration::from_secs(1),
)
.expect_err("spawning a missing binary must error, not time out");
assert_ne!(
err.kind(),
io::ErrorKind::TimedOut,
"a spawn failure must not be reported as a timeout: {err}"
);
}
}