use std::io::{self, Read};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
pub fn run_with_timeout(program: &str, args: &[&str], timeout: Duration) -> io::Result<Output> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut out_pipe = child.stdout.take().expect("stdout was piped");
let mut err_pipe = child.stderr.take().expect("stderr was piped");
let out_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = out_pipe.read_to_end(&mut buf);
buf
});
let err_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = err_pipe.read_to_end(&mut buf);
buf
});
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let _ = out_reader.join();
let _ = err_reader.join();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("`{program}` timed out after {timeout:?}"),
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
Ok(Output {
status,
stdout,
stderr,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_with_timeout_captures_stdout() {
let out = run_with_timeout("echo", &["hello"], Duration::from_secs(5)).unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
}
#[test]
fn test_run_with_timeout_kills_hung_process() {
let start = Instant::now();
let result = run_with_timeout("sleep", &["30"], Duration::from_millis(200));
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
assert!(
start.elapsed() < Duration::from_secs(5),
"timeout was not enforced promptly"
);
}
#[test]
fn test_run_with_timeout_nonexistent_program() {
let result = run_with_timeout("zzz_no_such_binary_xyz", &[], Duration::from_secs(5));
assert!(result.is_err());
}
#[test]
fn test_run_with_timeout_large_output_no_deadlock() {
let out = run_with_timeout(
"sh",
&["-c", "head -c 200000 /dev/zero"],
Duration::from_secs(10),
)
.expect("large-output command should complete, not time out");
assert!(out.status.success());
assert_eq!(out.stdout.len(), 200_000);
}
}