//! Reliable process-termination primitives. //! //! The huskies server kills child processes in several distinct places: //! the watchdog terminates agents that have exceeded turn/budget limits, //! `stop_agent` terminates on operator request, `kill_all_children` runs at //! server shutdown, the merge-gate completion path kills stale `cargo` //! processes, and `script/local-release` tears down the gateway during a //! redeploy. Every one of these used to send a signal that the target was //! free to ignore (most commonly `portable_pty`'s `SIGHUP`), with no //! verification that the process actually exited. Agents and bots that //! ignore `SIGHUP` survived the "kill", which produced concurrent claude //! processes on the same story — directly the duplicate-spawn bug we hit on //! 2026-05-15. //! //! This module provides one trustworthy way to kill processes: SIGKILL with //! verification. Build a pid set with the helpers in this module (or your //! own), then hand it to [`sigkill_pids_and_verify`]. //! //! All functions on this module are deliberately Unix-only — huskies runs in //! Linux containers and macOS dev hosts, both POSIX. use crate::slog_warn; /// Maximum time we'll wait for SIGKILL'd processes to disappear before /// declaring failure. SIGKILL is uncatchable, so the kernel normally /// reaps within tens of milliseconds; anything past 2 s indicates the /// process is wedged in uninterruptible IO (e.g. waiting on a frozen NFS /// mount). Caller can decide whether to proceed despite survivors. const KILL_VERIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); /// Polling interval while waiting for processes to disappear. 100 ms is /// fine-grained enough that the typical few-ms reap latency is barely /// observable, but coarse enough that we don't burn CPU spinning. const KILL_VERIFY_POLL: std::time::Duration = std::time::Duration::from_millis(100); /// SIGKILL every pid in `pids`, then poll until all of them are gone. /// /// Returns `Ok(n)` where `n == pids.len()` when every pid is verified /// reaped within [`KILL_VERIFY_TIMEOUT`]. Returns `Err(survivors)` with the /// pids still alive after the timeout — extremely rare for SIGKILL but /// possible if a process is wedged in uninterruptible IO. An empty `pids` /// slice returns `Ok(0)` immediately. /// /// **Why SIGKILL and not SIGTERM-first:** several huskies-internal targets /// (claude-code, the bot itself) either ignore the polite signals or take /// arbitrarily long to honour them. The watchdog only kills agents that /// have already misbehaved by definition (exceeded budget/turn limits), so /// there is no reason to give them a graceful-shutdown grace period. pub fn sigkill_pids_and_verify(pids: &[u32]) -> Result> { if pids.is_empty() { return Ok(0); } for &pid in pids { // libc::kill returns -1 on failure (with errno). We deliberately // ignore the result: the process may already be gone (errno ESRCH), // and trying again wouldn't help. The verification loop below is // the source of truth for "did this work". unsafe { libc::kill(pid as i32, libc::SIGKILL) }; } let deadline = std::time::Instant::now() + KILL_VERIFY_TIMEOUT; while std::time::Instant::now() < deadline { if pids.iter().copied().all(|pid| !pid_is_alive(pid)) { return Ok(pids.len()); } std::thread::sleep(KILL_VERIFY_POLL); } let survivors: Vec = pids .iter() .copied() .filter(|&pid| pid_is_alive(pid)) .collect(); if survivors.is_empty() { Ok(pids.len()) } else { slog_warn!( "[process_kill] SIGKILL did not reap pids within {:?}: {survivors:?}. \ They may be wedged in uninterruptible IO.", KILL_VERIFY_TIMEOUT ); Err(survivors) } } /// Return every pid whose command line matches `pattern` (passed to /// `pgrep -f`). Empty when nothing matches or when `pgrep` is unavailable. /// /// Useful for collecting processes by a path or argument substring — e.g. /// "every process running in `/`" or "every cargo invocation /// against this `Cargo.toml`". pub fn pids_matching(pattern: &str) -> Vec { // `--` prevents pgrep (getopt_long-based) from interpreting patterns that // start with `--` (e.g. `--manifest-path ...`) as unrecognised long options. let Ok(output) = std::process::Command::new("pgrep") .args(["-f", "--", pattern]) .output() else { return Vec::new(); }; String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|l| l.trim().parse::().ok()) .collect() } /// Return every descendant pid of `root_pid`, deepest-first, **excluding** /// `root_pid` itself. Walks the parent→child relation via `pgrep -P`. /// /// Deepest-first ordering lets callers signal leaves before their parents /// when that matters; for SIGKILL it makes no difference. pub fn descendant_pids(root_pid: u32) -> Vec { let mut out: Vec = Vec::new(); walk_descendants(root_pid, &mut out); out } fn walk_descendants(pid: u32, out: &mut Vec) { let Ok(output) = std::process::Command::new("pgrep") .args(["-P", &pid.to_string()]) .output() else { return; }; let kids: Vec = String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|l| l.trim().parse::().ok()) .collect(); for kid in kids { walk_descendants(kid, out); out.push(kid); } } /// Check whether `pid` currently exists. Implemented via `kill(pid, 0)` — /// no signal is sent, only existence is probed. fn pid_is_alive(pid: u32) -> bool { // signal 0: "is this process around?" Returns 0 if the process exists // and we have permission to signal it, -1 with errno otherwise. unsafe { libc::kill(pid as i32, 0) == 0 } } #[cfg(test)] mod tests { use super::*; use std::process::{Child, Command, Stdio}; use std::thread::JoinHandle; /// Spawn a sleeper for kill testing, and spawn a background reaper that /// calls `wait()` as soon as the child exits. Returns the pid plus the /// reaper join handle so the test can confirm reaping after the kill. /// /// The reaper is essential because the production code's verify loop /// uses `kill(pid, 0)` to test existence — which returns 0 for zombies. /// If no one reaps the test's sleeper, its pid stays occupied (as a /// zombie) and `sigkill_pids_and_verify` mistakenly reports survivors. /// In production the PTY blocking thread is always reaping on behalf of /// portable_pty, so this isn't a concern there. fn spawn_sleeper_with_reaper(secs: u64) -> (u32, JoinHandle<()>) { let child: Child = Command::new("sleep") .arg(secs.to_string()) .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .expect("failed to spawn sleep"); let pid = child.id(); let reaper = std::thread::spawn(move || { let mut c = child; let _ = c.wait(); }); (pid, reaper) } #[test] fn sigkill_empty_slice_is_ok() { let result = sigkill_pids_and_verify(&[]); assert!(matches!(result, Ok(0))); } #[test] fn sigkill_real_process_is_verified_gone() { let (pid, reaper) = spawn_sleeper_with_reaper(60); assert!(pid_is_alive(pid), "sleeper should be alive before kill"); let result = sigkill_pids_and_verify(&[pid]); assert!( matches!(result, Ok(1)), "sigkill must verify the process is gone: {result:?}" ); let _ = reaper.join(); assert!(!pid_is_alive(pid), "sleeper must be dead after kill"); } #[test] fn sigkill_already_dead_pid_is_ok() { let (pid, reaper) = spawn_sleeper_with_reaper(0); let _ = reaper.join(); // Wait briefly for the kernel to recycle the pid. for _ in 0..20 { if !pid_is_alive(pid) { break; } std::thread::sleep(std::time::Duration::from_millis(100)); } // Now SIGKILL a pid that no longer exists. Result must still be Ok. let result = sigkill_pids_and_verify(&[pid]); assert!( result.is_ok(), "sigkill of already-dead pid must succeed: {result:?}" ); } #[test] fn sigkill_multiple_real_processes() { let mut handles: Vec<(u32, JoinHandle<()>)> = (0..3).map(|_| spawn_sleeper_with_reaper(60)).collect(); let pids: Vec = handles.iter().map(|(p, _)| *p).collect(); for &pid in &pids { assert!(pid_is_alive(pid)); } let result = sigkill_pids_and_verify(&pids); assert!( matches!(result, Ok(3)), "all 3 sleepers must die: {result:?}" ); for (_, reaper) in handles.drain(..) { let _ = reaper.join(); } for &pid in &pids { assert!(!pid_is_alive(pid), "pid {pid} survived sigkill"); } } #[test] fn pids_matching_finds_a_running_process() { // pgrep -f matches the FULL command line, so the marker has to be // in argv somewhere. Putting it in a shell comment doesn't work — // sh strips it. Override argv[0] so the marker is durably visible. use std::os::unix::process::CommandExt; let marker = format!("kill-test-marker-{}-{}", std::process::id(), rand_u64()); let argv0 = format!("test-marker-{marker}"); let child: Child = Command::new("sleep") .arg0(argv0) .arg("60") .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .expect("spawn"); let child_pid = child.id(); let reaper = std::thread::spawn(move || { let mut c = child; let _ = c.wait(); }); // pgrep needs a moment to see the new process. std::thread::sleep(std::time::Duration::from_millis(100)); let found = pids_matching(&marker); assert!( found.contains(&child_pid), "pids_matching should find pid {child_pid} for marker '{marker}'; got {found:?}" ); // Cleanup so the test doesn't leak a sleeper. let _ = sigkill_pids_and_verify(&[child_pid]); let _ = reaper.join(); } /// Verify that the merge-gate's stale-cargo kill pattern actually kills /// processes whose command line contains `--manifest-path .../Cargo.toml`. #[test] fn stale_cargo_pattern_kills_matching_process() { use std::os::unix::process::CommandExt; let unique = format!("{}-{}", std::process::id(), rand_u64()); let fake_manifest = format!("/tmp/huskies-test-{unique}/Cargo.toml"); let argv0 = format!("cargo test --manifest-path {fake_manifest}"); let child: Child = Command::new("sleep") .arg0(&argv0) .arg("60") .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .expect("failed to spawn sleeper"); let pid = child.id(); let reaper = std::thread::spawn(move || { let mut c = child; let _ = c.wait(); }); std::thread::sleep(std::time::Duration::from_millis(100)); let pattern = format!("--manifest-path {fake_manifest}"); let pids = pids_matching(&pattern); assert!( pids.contains(&pid), "pids_matching must find the stale-cargo-like process (pid {pid}); got {pids:?}" ); let result = sigkill_pids_and_verify(&pids); assert!( result.is_ok(), "sigkill_pids_and_verify must succeed for stale cargo pids: {result:?}" ); assert!( !pid_is_alive(pid), "stale cargo-like process (pid {pid}) must be dead after kill" ); let _ = reaper.join(); } #[test] fn pids_matching_returns_empty_when_no_match() { let pattern = format!("nonexistent-pattern-{}-{}", std::process::id(), rand_u64()); let found = pids_matching(&pattern); assert!(found.is_empty(), "expected empty result, got {found:?}"); } /// Cheap unique-ish u64 for distinguishing test invocations without a /// dependency on a randomness crate. fn rand_u64() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0) } #[test] fn descendant_pids_of_real_process_tree() { // Build a parent sh that spawns a child sleep. The descendants of // the parent should include the sleep. let parent: Child = Command::new("sh") .args(["-c", "sleep 60"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .expect("spawn parent"); let parent_pid = parent.id(); let reaper = std::thread::spawn(move || { let mut c = parent; let _ = c.wait(); }); // Let the shell get around to fork+execing its child. std::thread::sleep(std::time::Duration::from_millis(200)); let descendants = descendant_pids(parent_pid); // On some shells `sh -c "sleep N"` exec-replaces sh with sleep, leaving // zero descendants. On others it forks. We don't care which; we only // care that the function doesn't panic and returns a sensible vec. assert!( descendants.iter().all(|&pid| pid != parent_pid), "descendant_pids must not include the root itself: {descendants:?}" ); // Cleanup: kill the parent and any descendants. let mut all = descendants; all.push(parent_pid); let _ = sigkill_pids_and_verify(&all); let _ = reaper.join(); } }