const ORPHAN_MIN_AGE_SECS: u64 = 60;
const ORPHAN_SCAN_TARGETS: &[&str] = &["sqlite-graphrag"];
const REPARENTED_PPID: u32 = 1;
#[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,
};
for (pid, name) in orphan_pids(ORPHAN_MIN_AGE_SECS) {
report.found += 1;
match terminate_pid(pid) {
Ok(()) => {
report.killed += 1;
tracing::info!(target: "reaper", pid, comm = %name, "killed orphan LLM subprocess");
}
Err(e) => {
report.failed += 1;
tracing::warn!(target: "reaper", pid, comm = %name, error = %e, "failed to kill orphan");
}
}
}
let max = crate::llm_slots::default_max_concurrency();
let stale = crate::llm_slots::find_stale_slots(max);
for slot_id in &stale {
let _ = crate::llm_slots::force_release(*slot_id);
tracing::info!(target: "reaper", slot_id, "released stale LLM slot (PID dead)");
}
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
}
fn orphan_pids(min_age_secs: u64) -> Vec<(u32, String)> {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
let mut system =
System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
system.refresh_processes(ProcessesToUpdate::All, true);
let own_pid = std::process::id();
let mut out = Vec::new();
for (pid, process) in system.processes() {
let pid = pid.as_u32();
if pid == own_pid {
continue;
}
if process.parent().map(sysinfo::Pid::as_u32) != Some(REPARENTED_PPID) {
continue;
}
let name = process.name().to_string_lossy().to_string();
if !ORPHAN_SCAN_TARGETS.iter().any(|target| name == *target) {
continue;
}
if process.run_time() < min_age_secs {
continue;
}
out.push((pid, name));
}
out
}
fn terminate_pid(pid: u32) -> std::io::Result<()> {
#[cfg(unix)]
{
let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(not(unix))]
{
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
let mut system = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::new()),
);
system.refresh_processes(ProcessesToUpdate::All, true);
match system.process(sysinfo::Pid::from_u32(pid)) {
Some(process) if process.kill() => Ok(()),
Some(_) => Err(std::io::Error::other("the platform refused the request")),
None => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"the process exited before the request reached it",
)),
}
}
}
#[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_sqlite_graphrag() {
assert!(ORPHAN_SCAN_TARGETS.contains(&"sqlite-graphrag"));
}
#[test]
fn scan_completes_without_panic() {
let r = scan_and_kill_orphans();
assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
}
#[test]
fn the_scan_reads_the_process_table_without_proc() {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
let mut system = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::new()),
);
system.refresh_processes(ProcessesToUpdate::All, true);
assert!(
!system.processes().is_empty(),
"the process table must be readable, or the reaper reports a verdict \
it never measured"
);
let own = std::process::id();
assert!(
!orphan_pids(0).iter().any(|(pid, _)| *pid == own),
"the scan must never target the running process, at any age threshold"
);
}
}