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(String),
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());
isolate_process_group(cmd);
tracing::debug!(
target: "skillpack::spawn",
program = %cmd.get_program().to_string_lossy(),
args = ?cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect::<Vec<_>>(),
cwd = ?cmd.get_current_dir(),
"spawn"
);
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 {
kill_tree(&mut child); return SpawnOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(10));
};
if !exited {
kill_tree(&mut child);
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 combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout_buf),
String::from_utf8_lossy(&stderr_buf)
);
match child.wait() {
Ok(s) if s.success() => SpawnOutcome::RanClean(combined),
_ => SpawnOutcome::RanNonZero(combined),
}
}
#[cfg(unix)]
fn isolate_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(not(unix))]
fn isolate_process_group(_cmd: &mut Command) {}
pub fn reset_sigpipe() {
#[cfg(unix)]
{
#[allow(unsafe_code)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
}
fn kill_tree(child: &mut std::process::Child) {
#[cfg(unix)]
{
let pid = child.id();
#[allow(unsafe_code)]
unsafe {
libc::killpg(pid as libc::pid_t, libc::SIGKILL);
}
let _ = child.wait();
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let _ = child.kill();
let _ = child.wait();
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
let _ = child.wait();
}
}
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!(matches!(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"));
}
}