use std::time::Duration;
const ORPHAN_MIN_AGE_SECS: u64 = 60;
const ORPHAN_SCAN_TARGETS: &[&str] = &["claude", "codex"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReaperReport {
pub found: usize,
pub killed: usize,
pub failed: usize,
pub elapsed_ms: u64,
}
pub fn scan_and_kill_orphans() -> ReaperReport {
let start = std::time::Instant::now();
let mut report = ReaperReport {
found: 0,
killed: 0,
failed: 0,
elapsed_ms: 0,
};
#[cfg(unix)]
{
if let Err(e) = scan_unix(&mut report) {
tracing::warn!(target: "reaper", error = %e, "orphan scan failed");
}
}
#[cfg(not(unix))]
{
tracing::debug!(target: "reaper", "orphan scan is a no-op on non-Unix platforms");
}
report.elapsed_ms = start.elapsed().as_millis() as u64;
if report.killed > 0 {
tracing::warn!(
target: "reaper",
found = report.found,
killed = report.killed,
failed = report.failed,
"reaped orphan LLM subprocesses"
);
} else {
tracing::info!(target: "reaper", found = report.found, "no orphan LLM subprocesses detected");
}
report
}
#[cfg(unix)]
fn scan_unix(report: &mut ReaperReport) -> std::io::Result<()> {
use std::fs;
use std::path::Path;
let proc = Path::new("/proc");
let entries = fs::read_dir(proc)?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !name_str.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let pid: i32 = match name_str.parse() {
Ok(p) => p,
Err(_) => continue,
};
if pid == std::process::id() as i32 {
continue;
}
let stat_path = entry.path().join("stat");
let stat = match fs::read_to_string(&stat_path) {
Ok(s) => s,
Err(_) => continue,
};
let Some(close_paren) = stat.rfind(')') else {
continue;
};
let after = &stat[close_paren + 1..];
let mut parts = after.split_whitespace();
let state = parts.next().unwrap_or("");
let ppid: i32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(-1);
if ppid != 1 {
continue;
}
if state.starts_with('Z') {
continue;
}
let comm_path = entry.path().join("comm");
let comm = match fs::read_to_string(&comm_path) {
Ok(s) => s.trim().to_string(),
Err(_) => continue,
};
if !ORPHAN_SCAN_TARGETS.iter().any(|t| comm == *t) {
continue;
}
let age_ok = check_process_age(pid, ORPHAN_MIN_AGE_SECS);
if !age_ok {
continue;
}
report.found += 1;
match terminate_pid(pid) {
Ok(()) => {
report.killed += 1;
tracing::info!(target: "reaper", pid, comm = %comm, "killed orphan LLM subprocess");
}
Err(e) => {
report.failed += 1;
tracing::warn!(target: "reaper", pid, comm = %comm, error = %e, "failed to kill orphan");
}
}
}
Ok(())
}
#[cfg(unix)]
fn check_process_age(pid: i32, min_age_secs: u64) -> bool {
use std::fs;
let stat_path = std::path::Path::new("/proc")
.join(pid.to_string())
.join("stat");
let Ok(meta) = fs::metadata(&stat_path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
let Ok(elapsed) = std::time::SystemTime::now().duration_since(modified) else {
return false;
};
elapsed >= Duration::from_secs(min_age_secs)
}
#[cfg(unix)]
fn terminate_pid(pid: i32) -> std::io::Result<()> {
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reaper_report_starts_zeroed() {
let r = ReaperReport {
found: 0,
killed: 0,
failed: 0,
elapsed_ms: 0,
};
assert_eq!(r.found, 0);
assert_eq!(r.killed, 0);
assert_eq!(r.failed, 0);
}
#[test]
fn orphan_min_age_is_one_minute() {
assert_eq!(ORPHAN_MIN_AGE_SECS, 60);
}
#[test]
fn orphan_targets_include_claude_and_codex() {
assert!(ORPHAN_SCAN_TARGETS.contains(&"claude"));
assert!(ORPHAN_SCAN_TARGETS.contains(&"codex"));
}
#[test]
fn scan_completes_without_panic_on_linux() {
let r = scan_and_kill_orphans();
assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
}
}