use std::io::{self, Read};
use std::process::{Command, ExitStatus, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug)]
pub struct BoundedOutput {
pub status: Option<ExitStatus>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub timed_out: bool,
}
impl BoundedOutput {
pub fn success(&self) -> bool {
self.status.is_some_and(|s| s.success())
}
}
pub fn run_bounded(mut cmd: Command, timeout: Duration) -> io::Result<BoundedOutput> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout_pipe = child
.stdout
.take()
.ok_or_else(|| io::Error::other("child stdout was not piped"))?;
let stderr_pipe = child
.stderr
.take()
.ok_or_else(|| io::Error::other("child stderr was not piped"))?;
let stdout_h = thread::spawn(move || drain(stdout_pipe));
let stderr_h = thread::spawn(move || drain(stderr_pipe));
let start = Instant::now();
let mut timed_out = false;
let status = loop {
if let Some(st) = child.try_wait()? {
break Some(st);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break None;
}
thread::sleep(POLL_INTERVAL);
};
let stdout = stdout_h.join().unwrap_or_default();
let stderr = stderr_h.join().unwrap_or_default();
Ok(BoundedOutput {
status,
stdout,
stderr,
timed_out,
})
}
fn drain<R: Read>(mut r: R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = r.read_to_end(&mut buf);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completes_under_timeout() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("echo hi; echo bye >&2");
let out = run_bounded(cmd, Duration::from_secs(5)).unwrap();
assert!(out.success());
assert!(!out.timed_out);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "bye");
}
#[test]
fn kills_on_timeout() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 30");
let out = run_bounded(cmd, Duration::from_millis(200)).unwrap();
assert!(out.timed_out);
assert!(out.status.is_none());
}
#[test]
fn propagates_nonzero_exit() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 7");
let out = run_bounded(cmd, Duration::from_secs(5)).unwrap();
assert!(!out.success());
assert!(!out.timed_out);
assert_eq!(out.status.and_then(|s| s.code()), Some(7));
}
}