use std::collections::HashMap;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, LazyLock, Mutex};
use std::thread;
use std::time::Duration;
pub fn is_cargo_subcommand_available(name: &str) -> bool {
static CACHE: LazyLock<Mutex<HashMap<String, bool>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
let cache = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(&result) = cache.get(name) {
return result;
}
drop(cache);
let available = Command::new("cargo")
.args([name, "--version"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success());
CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(name.to_string(), available);
available
}
pub struct ProcessOutput {
pub stdout: String,
pub timed_out: bool,
pub exit_code: Option<i32>,
}
pub fn spawn_in_group(cmd: &mut Command) -> std::io::Result<Child> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd.spawn()
}
pub fn kill_process_tree(child: &mut Child) {
#[cfg(unix)]
{
if let Ok(raw) = i32::try_from(child.id())
&& let Some(pid) = rustix::process::Pid::from_raw(raw)
{
let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
return;
}
}
let _ = child.kill();
}
pub fn run_with_timeout(
mut child: Child,
timeout_secs: u64,
max_output_bytes: u64,
) -> Result<ProcessOutput, String> {
tracing::debug!(
timeout_secs,
max_output_bytes,
"running subprocess with timeout"
);
let stdout = child
.stdout
.take()
.ok_or("failed to capture subprocess stdout")?;
if let Some(stderr) = child.stderr.take() {
std::thread::spawn(move || {
use std::io::Read;
let _ = std::io::copy(&mut stderr.take(1024 * 1024), &mut std::io::sink());
});
}
let (cancel_tx, cancel_rx) = mpsc::channel::<()>();
let child = Arc::new(Mutex::new(child));
let child_watcher = Arc::clone(&child);
let timed_out = Arc::new(AtomicBool::new(false));
let timed_out_watcher = Arc::clone(&timed_out);
let watcher = thread::spawn(move || {
if cancel_rx
.recv_timeout(Duration::from_secs(timeout_secs))
.is_err()
&& let Ok(mut c) = child_watcher.lock()
&& matches!(c.try_wait(), Ok(None))
{
kill_process_tree(&mut c); let _ = c.wait(); timed_out_watcher.store(true, Ordering::Relaxed);
}
});
let mut output = String::new();
{
use std::io::Read;
let _ = stdout.take(max_output_bytes).read_to_string(&mut output);
}
let _ = cancel_tx.send(());
let _ = watcher.join();
let exit_code = child
.lock()
.ok()
.and_then(|mut c| c.wait().ok().and_then(|s| s.code()));
let did_timeout = timed_out.load(Ordering::Relaxed);
tracing::debug!(exit_code, timed_out = did_timeout, "subprocess finished");
Ok(ProcessOutput {
stdout: output,
timed_out: did_timeout,
exit_code,
})
}
#[cfg(all(test, unix))]
mod tests {
use super::{kill_process_tree, spawn_in_group};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[test]
fn test_spawn_in_group_leads_own_group() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 30"])
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn_in_group(&mut cmd).expect("spawn should succeed");
let raw = i32::try_from(child.id()).unwrap();
let child_pid = rustix::process::Pid::from_raw(raw).unwrap();
let group = rustix::process::getpgid(Some(child_pid)).expect("getpgid");
assert_eq!(group, child_pid, "child should lead its own process group");
kill_process_tree(&mut child);
let _ = child.wait();
}
#[test]
fn test_kill_process_tree_terminates_child_promptly() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 30"])
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn_in_group(&mut cmd).expect("spawn should succeed");
assert!(
matches!(child.try_wait(), Ok(None)),
"child should be alive"
);
kill_process_tree(&mut child);
let start = Instant::now();
let status = child.wait().expect("wait should reap the killed child");
assert!(
!status.success(),
"a SIGKILL'd process must not report success"
);
assert!(
start.elapsed() < Duration::from_secs(5),
"group kill should terminate the child promptly"
);
}
}