//! External watchdog for a prebuilt Rust test or a direct reference worker. //! Output is test progress, not proof of GPU progress. No total-runtime limit. #[cfg(target_os = "macos")] #[path = "../src/process_resources.rs"] mod process_resources; #[cfg(target_os = "macos")] mod supervisor { use super::process_resources::{ResourceUsage, resource_usage}; use serde_json::json; use std::io::{self, Read, Write}; use std::os::unix::process::CommandExt; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; const SAMPLE_INTERVAL: Duration = Duration::from_millis(100); pub(super) struct Limits { pub(super) bytes: u64, pub(super) start: Duration, pub(super) idle: Duration, } struct Worker(Child); impl Drop for Worker { fn drop(&mut self) { if !matches!(self.0.try_wait(), Ok(Some(_))) { // This process group was created for this child alone. Also // clean up helpers on error; memory accounting covers the test // process, so this launcher is not a process-tree supervisor. unsafe { libc::kill(-(self.0.id() as i32), libc::SIGKILL) }; let _ = self.0.kill(); let _ = self.0.wait(); } } } #[derive(Default)] struct Output { last: Option, failed: bool, } // Worker-side probes are observations, not inference progress. Buffer JSON // across pipe reads so a split heartbeat cannot keep a stuck worker alive. // Plain output (including Rust test banners without a newline) still counts. fn useful_output(pending: &mut Vec, bytes: &[u8]) -> bool { pending.extend_from_slice(bytes); let mut progressed = false; while let Some(end) = pending.iter().position(|&b| b == b'\n') { let line = &pending[..end]; let observation = serde_json::from_slice::(line) .ok() .is_some_and(|value| { matches!( value["event"].as_str(), Some( "gpu_canary_sample" | "gpu_canary_summary" | "reference_canary_summary" | "test_resource_sample" ) ) }); progressed |= !observation && line.iter().any(|b| !b.is_ascii_whitespace()); pending.drain(..=end); } if pending.len() > 64 * 1024 || pending .iter() .find(|b| !b.is_ascii_whitespace()) .is_some_and(|&b| b != b'{') { progressed = true; pending.clear(); } progressed } fn forward( mut input: impl Read + Send + 'static, mut output: impl Write + Send + 'static, progress: Arc>, ) -> thread::JoinHandle<()> { thread::spawn(move || { let mut bytes = [0; 4096]; let mut pending = Vec::new(); loop { match input.read(&mut bytes) { Ok(0) => return, Ok(n) => { if output .write_all(&bytes[..n]) .and_then(|()| output.flush()) .is_err() { progress.lock().unwrap().failed = true; return; } if useful_output(&mut pending, &bytes[..n]) { progress.lock().unwrap().last = Some(Instant::now()); } } Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(_) => { progress.lock().unwrap().failed = true; return; } } } }) } pub(super) fn run(command: &mut Command, limits: Limits) -> Result<(), String> { if limits.bytes == 0 || limits.start.is_zero() || limits.idle.is_zero() { return Err("memory and progress limits must be positive".into()); } // Fail before spawning if process accounting is unavailable. resource_usage(std::process::id()).ok_or("process accounting unavailable")?; let mut worker = Worker( command .process_group(0) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("could not start test: {e}"))?, ); let progress = Arc::new(Mutex::new(Output::default())); let readers = [ forward( worker.0.stdout.take().unwrap(), io::stdout(), progress.clone(), ), forward( worker.0.stderr.take().unwrap(), io::stderr(), progress.clone(), ), ]; let started = Instant::now(); let mut peak = ResourceUsage::default(); eprintln!( "{}", json!({"event":"test_supervisor_started", "pid":worker.0.id(), "limit_bytes":limits.bytes, "start_seconds":limits.start.as_secs_f64(), "idle_seconds":limits.idle.as_secs_f64(), "sample_ms":100}) ); let outcome = loop { if let Some(status) = worker.0.try_wait().map_err(|e| e.to_string())? { break if status.success() { Ok(()) } else { Err(format!("test exited with {status}")) }; } let now = Instant::now(); let Some(sample) = resource_usage(worker.0.id()) else { // Exit can race the sample; missing telemetry on a live worker // is a failure, never permission to keep running unmonitored. if let Some(status) = worker.0.try_wait().map_err(|e| e.to_string())? { break if status.success() { Ok(()) } else { Err(format!("test exited with {status}")) }; } break Err("process accounting lost".into()); }; peak.include(sample); eprintln!( "{}", json!({"event":"test_resource_sample", "elapsed_ms":started.elapsed().as_millis(), "usage":sample}) ); if sample.physical_bytes > limits.bytes || sample.peak_physical_bytes > limits.bytes { break Err("memory_limit".into()); } let output = progress.lock().map_err(|e| e.to_string())?; if output.failed { break Err("output monitoring failed".into()); } let (last, timeout, reason) = output .last .map_or((started, limits.start, "start_timeout"), |last| { (last, limits.idle, "continuation_timeout") }); if now.saturating_duration_since(last) >= timeout { break Err(reason.into()); } drop(output); thread::sleep(SAMPLE_INTERVAL); }; drop(worker); // Kill and reap on every failure, including output errors. for reader in readers { reader.join().map_err(|_| "output reader panicked")?; } eprintln!( "{}", json!({"event":"test_resource_summary", "elapsed_ms":started.elapsed().as_millis(), "peak":peak, "error":outcome.as_ref().err()}) ); if progress.lock().map_err(|e| e.to_string())?.failed { return Err("output monitoring failed".into()); } outcome } pub(super) fn cli() -> Result<(), String> { let args = std::env::args_os().skip(1).collect::>(); if args.len() < 5 || (args[3] != "--command" && args.len() != 5) { return Err("usage: test-supervisor MEMORY_MIB START_SECONDS IDLE_SECONDS TEST_BINARY TEST_FILTER\nOr: test-supervisor MEMORY_MIB START_SECONDS IDLE_SECONDS --command EXECUTABLE [ARG ...]\nMonitors the direct worker only; do not pass cargo or a spawning shell.".into()); } let number = |i: usize| { args[i] .to_str() .and_then(|s| s.parse::().ok()) .filter(|n| *n > 0) .ok_or("limits must be positive integers") }; let limits = Limits { bytes: number(0)? .checked_mul(1024 * 1024) .ok_or("memory limit overflow")?, start: Duration::from_secs(number(1)?), idle: Duration::from_secs(number(2)?), }; let mut command = worker_command(&args[3..])?; run(&mut command, limits) } fn worker_command(args: &[std::ffi::OsString]) -> Result { if args.len() < 2 || args[1].is_empty() { return Err("worker executable or test filter must not be empty".into()); } if args[0] == "--command" { let mut command = Command::new(&args[1]); command.args(&args[2..]); Ok(command) } else if args.len() == 2 { let mut command = Command::new(&args[0]); command .arg(&args[1]) .args(["--include-ignored", "--test-threads=1", "--nocapture"]); Ok(command) } else { Err("unexpected test arguments".into()) } } #[cfg(test)] mod tests { use super::*; #[test] fn direct_worker_arguments_are_not_rust_test_arguments() { let args = |a: &[&str]| a.iter().map(std::ffi::OsString::from).collect::>(); let direct = worker_command(&args(&[ "--command", "/usr/bin/python3", "-u", "reference.py", ])) .unwrap(); assert_eq!(direct.get_program(), "/usr/bin/python3"); assert_eq!( direct.get_args().collect::>(), ["-u", "reference.py"] ); let test = worker_command(&args(&["test-binary", "fixture"])).unwrap(); assert_eq!( test.get_args().collect::>(), [ "fixture", "--include-ignored", "--test-threads=1", "--nocapture" ] ); assert!(worker_command(&args(&["--command", ""])).is_err()); assert!(worker_command(&args(&["test-binary", "fixture", "extra"])).is_err()); } #[test] fn watchdog_stops_memory_and_silence_but_not_total_runtime() { let limits = || Limits { bytes: 256 * 1024 * 1024, start: Duration::from_millis(300), idle: Duration::from_millis(300), }; let mut sleeping = Command::new("/bin/sleep"); sleeping.arg("5"); assert_eq!(run(&mut sleeping, limits()).unwrap_err(), "start_timeout"); let mut started = Command::new("/bin/sh"); started.args(["-c", "printf ready; exec /bin/sleep 5"]); assert_eq!( run(&mut started, limits()).unwrap_err(), "continuation_timeout" ); for (prefix, expected) in [ ("", "start_timeout"), ("printf 'ready\\n'; ", "continuation_timeout"), ] { let mut heartbeat = Command::new("/bin/sh"); heartbeat.args(["-c", &format!("{prefix}while :; do printf '{{\"event\":\"gpu_canary_sample\",\"ok\":true}}\\n'; sleep 0.05; done")]); assert_eq!(run(&mut heartbeat, limits()).unwrap_err(), expected); } let mut bounded = limits(); bounded.bytes = 1; assert_eq!(run(&mut sleeping, bounded).unwrap_err(), "memory_limit"); let mut progressing = Command::new("/bin/sh"); progressing.args([ "-c", "for i in 1 2 3 4 5 6 7 8; do printf progress; sleep 0.1; done", ]); let before = Instant::now(); run(&mut progressing, limits()).unwrap(); assert!(before.elapsed() >= Duration::from_millis(600)); let mut failure = Command::new("/bin/sh"); failure.args(["-c", "exit 7"]); assert!( run(&mut failure, limits()) .unwrap_err() .contains("exit status: 7") ); } #[test] fn split_probe_records_do_not_reset_progress() { let mut pending = Vec::new(); assert!(!useful_output(&mut pending, b"{\"event\":\"gpu_canary_")); assert!(!useful_output(&mut pending, b"sample\",\"ok\":true}\n")); assert!(useful_output( &mut pending, b"{\"event\":\"mtp_tokens\",\"ids\":[42]}\n" )); assert!(useful_output(&mut pending, b"progress")); assert!(pending.is_empty()); } } } fn main() { #[cfg(target_os = "macos")] if let Err(error) = supervisor::cli() { eprintln!("Test supervisor: {error}"); std::process::exit(1); } #[cfg(not(target_os = "macos"))] { eprintln!("test-supervisor requires macOS process accounting"); std::process::exit(1); } }