use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
ExitedOk { ms: u32 },
ExitedAfterMs { ms: u32 },
TimedOutHung,
}
const REAP_BUDGET: Duration = Duration::from_millis(500);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub fn run_probe_sandboxed(argv: &[&str], timeout: Duration) -> ProbeOutcome {
let Some((program, args)) = argv.split_first() else {
return ProbeOutcome::ExitedAfterMs { ms: 0 };
};
let mut cmd = Command::new(program);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(target_os = "linux")]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
nix::unistd::setsid().map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
Ok(())
});
}
let start = Instant::now();
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::warn!(program, error = %e, "probe_spawn_failed");
return ProbeOutcome::ExitedAfterMs { ms: 0 };
}
};
#[cfg(target_os = "linux")]
let pgid = nix::unistd::Pid::from_raw(child.id() as i32);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let ms = elapsed_ms(start);
return if status.success() {
ProbeOutcome::ExitedOk { ms }
} else {
ProbeOutcome::ExitedAfterMs { ms }
};
}
Ok(None) => {
if start.elapsed() >= timeout {
break;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => {
tracing::warn!(program, error = %e, "probe_wait_failed");
return ProbeOutcome::ExitedAfterMs {
ms: elapsed_ms(start),
};
}
}
}
tracing::warn!(
program,
timeout_ms = timeout.as_millis() as u64,
"probe_timed_out; killing process group"
);
#[cfg(target_os = "linux")]
{
use nix::sys::signal::{killpg, Signal};
let _ = killpg(pgid, Signal::SIGKILL);
}
#[cfg(not(target_os = "linux"))]
{
let _ = child.kill();
}
let reap_start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return ProbeOutcome::TimedOutHung,
Ok(None) => {
if reap_start.elapsed() >= REAP_BUDGET {
tracing::error!(
program,
"probe child not reapable after SIGKILL (likely D-state); abandoning"
);
return ProbeOutcome::TimedOutHung;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => return ProbeOutcome::TimedOutHung,
}
}
}
fn elapsed_ms(start: Instant) -> u32 {
start.elapsed().as_millis().min(u32::MAX as u128) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exited_ok_for_true() {
let out = run_probe_sandboxed(&["true"], Duration::from_secs(2));
assert!(matches!(out, ProbeOutcome::ExitedOk { .. }), "{out:?}");
}
#[test]
fn exited_after_ms_for_false() {
let out = run_probe_sandboxed(&["false"], Duration::from_secs(2));
assert!(matches!(out, ProbeOutcome::ExitedAfterMs { .. }), "{out:?}");
}
#[test]
fn timed_out_for_long_sleep() {
let start = Instant::now();
let out = run_probe_sandboxed(&["/bin/sleep", "30"], Duration::from_millis(100));
let waited = start.elapsed();
assert_eq!(out, ProbeOutcome::TimedOutHung);
assert!(
waited < Duration::from_secs(3),
"runner blocked too long: {waited:?}"
);
}
#[test]
fn empty_argv_is_a_failure() {
assert_eq!(
run_probe_sandboxed(&[], Duration::from_secs(1)),
ProbeOutcome::ExitedAfterMs { ms: 0 }
);
}
#[test]
fn missing_program_is_a_failure() {
assert_eq!(
run_probe_sandboxed(&["/nonexistent/probe-xyzzy"], Duration::from_secs(1)),
ProbeOutcome::ExitedAfterMs { ms: 0 }
);
}
}