use std::process::{Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturedOutput {
pub exit_code: Option<i32>,
pub success: bool,
pub stdout: String,
pub stderr: String,
pub duration: Duration,
pub timed_out: bool,
}
impl CapturedOutput {
#[must_use]
pub fn from_parts(
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
duration: Duration,
timed_out: bool,
) -> Self {
let exit_code = status.code();
let success = exit_code == Some(0);
Self {
exit_code,
success,
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
duration,
timed_out,
}
}
#[must_use]
pub fn spawn_failure(message: String, duration: Duration) -> Self {
Self {
exit_code: None,
success: false,
stdout: String::new(),
stderr: format!("spawn failed: {message}"),
duration,
timed_out: false,
}
}
}
#[must_use]
pub fn command_argv(cmd: &Command) -> Vec<String> {
let program = cmd.get_program().to_string_lossy().into_owned();
let mut argv = vec![program];
for arg in cmd.get_args() {
argv.push(arg.to_string_lossy().into_owned());
}
argv
}
pub fn run_with_timeout(
cmd: &mut Command,
timeout: Duration,
) -> std::io::Result<CapturedOutput> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let start = Instant::now();
let child = cmd.spawn()?;
let pid = child.id();
let (tx, rx) = mpsc::channel::<()>();
let watchdog = std::thread::spawn(move || -> bool {
match rx.recv_timeout(timeout) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => false,
Err(mpsc::RecvTimeoutError::Timeout) => {
#[cfg(unix)]
unsafe { libc::kill(pid as i32, libc::SIGKILL); }
true
}
}
});
let output = child.wait_with_output()?;
let _ = tx.send(());
let timed_out = watchdog.join().unwrap_or(false);
let duration = start.elapsed();
Ok(CapturedOutput::from_parts(
output.status,
output.stdout,
output.stderr,
duration,
timed_out,
))
}
pub fn dual_run(
sui: &mut Command,
nix: &mut Command,
timeout: Duration,
) -> (CapturedOutput, CapturedOutput) {
let sui_out = match run_with_timeout(sui, timeout) {
Ok(out) => out,
Err(e) => CapturedOutput::spawn_failure(e.to_string(), Duration::ZERO),
};
let nix_out = match run_with_timeout(nix, timeout) {
Ok(out) => out,
Err(e) => CapturedOutput::spawn_failure(e.to_string(), Duration::ZERO),
};
(sui_out, nix_out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn captured_output_success_marks_success_flag() {
let out = CapturedOutput::from_parts(
fake_exit(0),
b"hi".to_vec(),
b"".to_vec(),
Duration::from_millis(1),
false,
);
assert!(out.success);
assert_eq!(out.exit_code, Some(0));
assert_eq!(out.stdout, "hi");
}
#[test]
fn captured_output_nonzero_is_not_success() {
let out = CapturedOutput::from_parts(
fake_exit(1),
b"".to_vec(),
b"oops".to_vec(),
Duration::from_millis(1),
false,
);
assert!(!out.success);
assert_eq!(out.exit_code, Some(1));
}
#[test]
fn spawn_failure_records_message() {
let out = CapturedOutput::spawn_failure("no such file".into(), Duration::ZERO);
assert!(!out.success);
assert!(out.stderr.contains("no such file"));
assert_eq!(out.exit_code, None);
}
#[test]
fn command_argv_renders_program_and_args() {
let mut cmd = Command::new("/bin/echo");
cmd.args(["hello", "world"]);
let argv = command_argv(&cmd);
assert_eq!(argv, vec!["/bin/echo", "hello", "world"]);
}
#[test]
fn timeout_kills_runaway_child() {
if !std::path::Path::new("/bin/sleep").exists() {
return;
}
let mut cmd = Command::new("/bin/sleep");
cmd.arg("30");
let out = run_with_timeout(&mut cmd, Duration::from_millis(100))
.expect("spawn should succeed");
assert!(out.timed_out, "watchdog must have fired");
assert!(out.exit_code.is_none() || out.exit_code == Some(-1));
}
fn fake_exit(code: i32) -> ExitStatus {
let bin = if code == 0 { "/usr/bin/true" } else { "/usr/bin/false" };
let alt = if code == 0 { "/bin/true" } else { "/bin/false" };
let path = if std::path::Path::new(bin).exists() { bin } else { alt };
std::process::Command::new(path)
.status()
.expect("status must succeed")
}
}