use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
pub const HELP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpawnOutcome {
RanClean(String),
RanNonZero,
TimedOut,
NotFound,
SpawnFailed(String),
}
fn pipe_drain(r: impl Read + Send + 'static) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = Vec::new();
let mut reader = r;
let _ = reader.read_to_end(&mut buf);
let _ = tx.send(buf);
});
rx
}
enum StdinMode {
Null,
Piped(Vec<u8>),
}
fn run_inner(cmd: &mut Command, timeout: Duration, stdin_mode: StdinMode) -> SpawnOutcome {
let maybe_data: Option<Vec<u8>> = match stdin_mode {
StdinMode::Null => {
cmd.stdin(Stdio::null());
None
}
StdinMode::Piped(d) => {
cmd.stdin(Stdio::piped());
Some(d)
}
};
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SpawnOutcome::NotFound,
Err(e) => return SpawnOutcome::SpawnFailed(e.to_string()),
};
if let Some(data) = &maybe_data {
if let Some(mut child_stdin) = child.stdin.take() {
let _ = child_stdin.write_all(data);
drop(child_stdin);
}
}
let stdout_rx = child.stdout.take().map(pipe_drain);
let stderr_rx = child.stderr.take().map(pipe_drain);
let deadline = Instant::now() + timeout;
let exited = loop {
match child.try_wait() {
Ok(Some(_)) => break true,
Ok(None) => {}
Err(_) => break false,
}
if Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait(); return SpawnOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(10));
};
if !exited {
let _ = child.kill();
let _ = child.wait();
return SpawnOutcome::TimedOut;
}
let stdout_buf = stdout_rx.and_then(|rx| rx.recv().ok()).unwrap_or_default();
let stderr_buf = stderr_rx.and_then(|rx| rx.recv().ok()).unwrap_or_default();
let status = match child.wait() {
Ok(s) => s,
Err(_) => return SpawnOutcome::RanNonZero,
};
if !status.success() {
return SpawnOutcome::RanNonZero;
}
let combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout_buf),
String::from_utf8_lossy(&stderr_buf)
);
SpawnOutcome::RanClean(combined)
}
pub fn run(cmd: &mut Command, timeout: Duration) -> SpawnOutcome {
run_inner(cmd, timeout, StdinMode::Null)
}
pub fn run_with_stdin(cmd: &mut Command, timeout: Duration, stdin: Option<&[u8]>) -> SpawnOutcome {
match stdin {
Some(data) => run_inner(cmd, timeout, StdinMode::Piped(data.to_vec())),
None => run_inner(cmd, timeout, StdinMode::Null),
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::*;
#[cfg(unix)]
#[test]
fn small_help_captures_cleanly() {
let mut cmd = Command::new("echo");
cmd.arg("hello world");
let out = run(&mut cmd, Duration::from_secs(5));
let SpawnOutcome::RanClean(s) = out else {
panic!("expected RanClean, got {out:?}");
};
assert!(s.contains("hello world"));
}
#[cfg(unix)]
#[test]
fn missing_binary_is_not_found() {
let mut cmd = Command::new("/this/does/not/exist/xyz");
cmd.arg("--help");
assert_eq!(
run(&mut cmd, Duration::from_secs(2)),
SpawnOutcome::NotFound
);
}
#[cfg(unix)]
#[test]
fn nonzero_exit_is_ran_nonzero() {
let mut cmd = Command::new("false");
let out = run(&mut cmd, Duration::from_secs(5));
assert_eq!(out, SpawnOutcome::RanNonZero);
}
#[cfg(unix)]
#[test]
fn writes_beyond_pipe_buffer_do_not_deadlock() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "yes hello | head -n 20000"]);
let out = run(&mut cmd, Duration::from_secs(10));
let SpawnOutcome::RanClean(s) = out else {
panic!("expected RanClean, got {out:?}");
};
assert!(
s.contains("hello"),
"expected capture beyond 64KB pipe buffer"
);
}
#[cfg(unix)]
#[test]
fn run_with_stdin_feeds_bytes_and_closes() {
let mut cmd = Command::new("cat");
let out = run_with_stdin(&mut cmd, Duration::from_secs(5), Some(b"hello stdin"));
let SpawnOutcome::RanClean(s) = out else {
panic!("expected RanClean, got {out:?}");
};
assert!(
s.contains("hello stdin"),
"expected cat to echo fed stdin, got: {s}"
);
}
#[cfg(unix)]
#[test]
fn run_with_stdin_none_is_null_stdin() {
let mut cmd = Command::new("echo");
cmd.arg("no stdin needed");
let out = run_with_stdin(&mut cmd, Duration::from_secs(5), None);
let SpawnOutcome::RanClean(s) = out else {
panic!("expected RanClean, got {out:?}");
};
assert!(s.contains("no stdin needed"));
}
}