use std::path::Path;
use std::time::Duration;
const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub const STDERR_CAPTURE_MAX: usize = 512;
pub fn shell_command(cmd: &str, cwd: &Path) -> std::process::Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut command = std::process::Command::new("cmd");
command.arg("/C").raw_arg(cmd).current_dir(cwd);
command
}
#[cfg(not(windows))]
{
use std::os::unix::process::CommandExt;
let mut command = std::process::Command::new("sh");
command.arg("-c").arg(cmd).current_dir(cwd);
command.process_group(0);
command
}
}
#[derive(Debug)]
pub struct ShellResult {
pub status: std::process::ExitStatus,
pub stderr_head: String,
}
pub fn run_with_timeout(cmd: &str, timeout_secs: u64, cwd: &Path) -> anyhow::Result<ShellResult> {
let mut child = shell_command(cmd, cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()?;
let stderr_handle = child.stderr.take().expect("stderr piped");
let reader_thread = std::thread::spawn(move || {
use std::io::Read;
let mut buf = vec![0u8; STDERR_CAPTURE_MAX + 1];
let mut reader = stderr_handle;
let mut total = 0;
loop {
match reader.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => {
total += n;
if total > STDERR_CAPTURE_MAX {
let mut discard = [0u8; 4096];
while reader.read(&mut discard).unwrap_or(0) > 0 {}
break;
}
}
Err(_) => break,
}
}
let cap = total.min(STDERR_CAPTURE_MAX);
let text = String::from_utf8_lossy(&buf[..cap]).to_string();
if total > STDERR_CAPTURE_MAX {
format!("{text}... (truncated)")
} else {
text
}
});
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
if let Some(status) = child.try_wait()? {
let stderr_head = reader_thread.join().unwrap_or_default();
return Ok(ShellResult {
status,
stderr_head,
});
}
if std::time::Instant::now() >= deadline {
kill_process_tree(&mut child);
let stderr_head = reader_thread.join().unwrap_or_default();
anyhow::bail!("timed out after {timeout_secs}s: {stderr_head}");
}
std::thread::sleep(POLL_INTERVAL);
}
}
pub fn kill_process_tree(child: &mut std::process::Child) {
#[cfg(windows)]
{
let pid = child.id();
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(windows))]
{
let pid = child.id() as i32;
if pid > 0 {
#[expect(unsafe_code)]
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
}
}
let _ = child.wait();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_command_sets_cwd() {
let dir = tempfile::TempDir::new().unwrap();
let cmd = shell_command("echo hello", dir.path());
assert_eq!(cmd.get_current_dir(), Some(dir.path()));
}
#[test]
fn run_with_timeout_captures_exit_status() {
let dir = tempfile::TempDir::new().unwrap();
let result = run_with_timeout("exit 0", 5, dir.path()).unwrap();
assert!(result.status.success());
}
#[test]
fn run_with_timeout_captures_nonzero_exit() {
let dir = tempfile::TempDir::new().unwrap();
let result = run_with_timeout("exit 42", 5, dir.path()).unwrap();
assert!(!result.status.success());
}
#[test]
fn run_with_timeout_captures_stderr() {
let dir = tempfile::TempDir::new().unwrap();
let result = run_with_timeout("echo oops >&2", 5, dir.path()).unwrap();
assert!(result.stderr_head.contains("oops"));
}
#[test]
fn run_with_timeout_kills_on_timeout() {
let dir = tempfile::TempDir::new().unwrap();
let result = run_with_timeout("sleep 60", 1, dir.path());
let err = result.unwrap_err().to_string();
assert!(
err.contains("timed out"),
"expected timeout error, got: {err}"
);
}
#[test]
fn kill_process_tree_handles_already_exited_child() {
let dir = tempfile::TempDir::new().unwrap();
let mut child = shell_command("exit 0", dir.path()).spawn().unwrap();
let _ = child.wait();
kill_process_tree(&mut child);
}
#[test]
fn shell_result_is_send_and_sync() {
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<ShellResult>;
};
}
#[test]
fn run_with_timeout_truncates_large_stderr() {
let dir = tempfile::TempDir::new().unwrap();
let result = run_with_timeout("printf '%0800d' 0 >&2", 5, dir.path()).unwrap();
assert!(
result.stderr_head.len() <= STDERR_CAPTURE_MAX + 20,
"stderr should be truncated, got {} bytes",
result.stderr_head.len()
);
assert!(
result.stderr_head.contains("(truncated)"),
"expected truncation marker, got: {}",
&result.stderr_head[..result.stderr_head.len().min(100)]
);
}
}