Skip to main content

sqlite_graphrag/
reaper.rs

1//! G28: Reaper for orphan external processes.
2//!
3//! When the CLI crashes or is killed (SIGKILL, OOM, machine reset), child
4//! processes spawned by `claude -p` or `codex exec` may be left running.
5//! Without cleanup they accumulate as zombies that consume CPU, RAM, and
6//! MCP-spawned subprocess trees (the 2026-06-03 incident: 1.877 processes
7//! total, load average 276 on a 10-CPU host).
8//!
9//! [`crate::reaper::scan_and_kill_orphans`] walks the process table at startup and
10//! terminates any invocation whose `PPID` is `1`
11//! (reparented to `init`/`launchd` after the parent died) and that is
12//! older than the `ORPHAN_MIN_AGE_SECS` constant. The scan is conservative: it only
13//! kills processes that (a) match a known target name, AND (b) are
14//! orphaned, AND (c) are older than the threshold. A short-lived CLI
15//! that is just starting up is left alone.
16//!
17//! # Portability
18//!
19//! GAP-SG-261: the walk reads the process table through `sysinfo`, not through
20//! `/proc`. The `/proc` implementation was gated on `#[cfg(unix)]`, which is
21//! TRUE on macOS — a platform with no `/proc` — so there the very first
22//! `read_dir` failed and the caller was told "no orphan subprocesses detected".
23//! A verdict reported without a measurement is worse than an error, because it
24//! reads like one.
25//!
26//! Only `terminate_pid` still splits by platform, because asking a process to
27//! stop is where the systems genuinely differ: `SIGTERM` on Unix, and
28//! `sysinfo`'s own request on Windows. The split is at that one call rather
29//! than around the whole scan.
30
31// GAP-SG-261: the constants used to be gated behind `cfg(unix)` because the
32// scan itself was, and on Windows they would have been dead code. The scan is
33// portable now, so the gate would be the thing making them dead.
34const ORPHAN_MIN_AGE_SECS: u64 = 60;
35
36const ORPHAN_SCAN_TARGETS: &[&str] = &["sqlite-graphrag"];
37
38/// The PPID an orphan is reparented to once its parent dies.
39///
40/// `1` is `init` on Linux and `launchd` on macOS. Windows has no reparenting
41/// contract of this shape, which is why [`orphan_pids`] answers with an empty
42/// set there rather than pretending otherwise.
43const REPARENTED_PPID: u32 = 1;
44
45/// Reaper report.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct ReaperReport {
48    /// Number of orphan processes detected.
49    pub found: usize,
50    /// Number of orphan processes successfully terminated.
51    pub killed: usize,
52    /// Number that we could not terminate (permission, ESRCH, etc).
53    pub failed: usize,
54    /// Elapsed wall time of the scan.
55    pub elapsed_ms: u64,
56}
57
58/// Walks the process table and kills orphan LLM invocations.
59///
60/// The scan is best-effort and never panics: on any unexpected error it
61/// logs the failure and returns a report with `killed = 0`.
62pub fn scan_and_kill_orphans() -> ReaperReport {
63    let start = std::time::Instant::now();
64    let mut report = ReaperReport {
65        found: 0,
66        killed: 0,
67        failed: 0,
68        elapsed_ms: 0,
69    };
70
71    for (pid, name) in orphan_pids(ORPHAN_MIN_AGE_SECS) {
72        report.found += 1;
73        match terminate_pid(pid) {
74            Ok(()) => {
75                report.killed += 1;
76                tracing::info!(target: "reaper", pid, comm = %name, "killed orphan LLM subprocess");
77            }
78            Err(e) => {
79                report.failed += 1;
80                tracing::warn!(target: "reaper", pid, comm = %name, error = %e, "failed to kill orphan");
81            }
82        }
83    }
84
85    let max = crate::llm_slots::default_max_concurrency();
86    let stale = crate::llm_slots::find_stale_slots(max);
87    for slot_id in &stale {
88        let _ = crate::llm_slots::force_release(*slot_id);
89        tracing::info!(target: "reaper", slot_id, "released stale LLM slot (PID dead)");
90    }
91
92    report.elapsed_ms = start.elapsed().as_millis() as u64;
93    if report.killed > 0 {
94        tracing::warn!(
95            target: "reaper",
96            found = report.found,
97            killed = report.killed,
98            failed = report.failed,
99            "reaped orphan LLM subprocesses"
100        );
101    } else {
102        tracing::info!(target: "reaper", found = report.found, "no orphan LLM subprocesses detected");
103    }
104    report
105}
106
107/// Every PID that matches a scan target, is reparented, and is old enough.
108///
109/// GAP-SG-261: this used to read `/proc` under `#[cfg(unix)]`. That gate is
110/// WRONG for macOS, which is `unix` and has no `/proc`, so `read_dir` failed at
111/// the first call and the reaper reported zero orphans on a host it had never
112/// actually looked at. Reporting "no orphans detected" without having read the
113/// process table is worse than reporting an error, because the log line reads
114/// like a measurement.
115///
116/// `sysinfo` is already a dependency of this crate — `system_load` and
117/// `llm_slots` use it — and it is pure Rust, so the portable path costs no new
118/// crate and no C toolchain. It also replaces a heuristic with the real value:
119/// process age came from the mtime of `/proc/<pid>/stat`, which is a proxy,
120/// while [`sysinfo::Process::run_time`] is the elapsed running time itself.
121///
122/// Returns an empty vector rather than an error when nothing matches, so the
123/// caller cannot tell "scan failed" from "scan found nothing" by accident —
124/// that distinction lives in the `Result` this does not return, and the scan is
125/// documented as best-effort.
126fn orphan_pids(min_age_secs: u64) -> Vec<(u32, String)> {
127    use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
128
129    // Only the process list is refreshed: the reaper never asks about CPU,
130    // memory or disk, and refreshing everything would walk data this function
131    // discards on every host it runs on.
132    let mut system =
133        System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
134    system.refresh_processes(ProcessesToUpdate::All, true);
135
136    let own_pid = std::process::id();
137    let mut out = Vec::new();
138    for (pid, process) in system.processes() {
139        let pid = pid.as_u32();
140        if pid == own_pid {
141            continue;
142        }
143        // Reparented to init/launchd is what makes a process an ORPHAN rather
144        // than a peer someone is still supervising.
145        if process.parent().map(sysinfo::Pid::as_u32) != Some(REPARENTED_PPID) {
146            continue;
147        }
148        let name = process.name().to_string_lossy().to_string();
149        if !ORPHAN_SCAN_TARGETS.iter().any(|target| name == *target) {
150            continue;
151        }
152        // Never race a peer that just started. The threshold is the safety
153        // margin, not an optimisation.
154        if process.run_time() < min_age_secs {
155            continue;
156        }
157        out.push((pid, name));
158    }
159    out
160}
161
162/// Asks one process to terminate.
163///
164/// Unix sends `SIGTERM` and returns without waiting: a follow-up sweep can
165/// escalate to `SIGKILL` if the process ignores it. Windows has no signal of
166/// this shape, and `sysinfo::Process::kill` is the portable request there —
167/// which is why the platform split lives HERE, at the one call that genuinely
168/// differs, instead of around the whole scan as it used to.
169fn terminate_pid(pid: u32) -> std::io::Result<()> {
170    #[cfg(unix)]
171    {
172        // SAFETY: `kill` with a PID this scan just read from the process table
173        // and a constant signal number. It cannot violate memory safety; the
174        // worst outcome is `ESRCH` for a process that exited in between, which
175        // is returned as an error rather than ignored.
176        let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
177        if rc == 0 {
178            Ok(())
179        } else {
180            Err(std::io::Error::last_os_error())
181        }
182    }
183    #[cfg(not(unix))]
184    {
185        use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
186        let mut system = System::new_with_specifics(
187            RefreshKind::new().with_processes(ProcessRefreshKind::new()),
188        );
189        system.refresh_processes(ProcessesToUpdate::All, true);
190        match system.process(sysinfo::Pid::from_u32(pid)) {
191            Some(process) if process.kill() => Ok(()),
192            Some(_) => Err(std::io::Error::other("the platform refused the request")),
193            None => Err(std::io::Error::new(
194                std::io::ErrorKind::NotFound,
195                "the process exited before the request reached it",
196            )),
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn reaper_report_starts_zeroed() {
207        let r = ReaperReport {
208            found: 0,
209            killed: 0,
210            failed: 0,
211            elapsed_ms: 0,
212        };
213        assert_eq!(r.found, 0);
214        assert_eq!(r.killed, 0);
215        assert_eq!(r.failed, 0);
216    }
217
218    #[test]
219    fn orphan_min_age_is_one_minute() {
220        // G28: the threshold of 60s is the safety margin that prevents
221        // a CLI invocation from killing a concurrent peer that just
222        // started 5s ago.
223        assert_eq!(ORPHAN_MIN_AGE_SECS, 60);
224    }
225
226    #[test]
227    fn orphan_targets_include_sqlite_graphrag() {
228        assert!(ORPHAN_SCAN_TARGETS.contains(&"sqlite-graphrag"));
229    }
230
231    #[test]
232    fn scan_completes_without_panic() {
233        // Just ensure the function returns a ReaperReport on the test host.
234        // In containers we may be PID 1; the report will simply have found=0.
235        let r = scan_and_kill_orphans();
236        assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
237    }
238
239    #[test]
240    fn the_scan_reads_the_process_table_without_proc() {
241        // GAP-SG-261. The previous implementation opened `/proc` under
242        // `#[cfg(unix)]`, which is true on macOS, where `/proc` does not exist
243        // — so the scan failed at its first call and the caller was told "no
244        // orphans detected". This asserts the portable path instead: the table
245        // is read through `sysinfo`, and the answer is a real measurement on
246        // every platform this crate builds for.
247        //
248        // The invariant that survives a host with no orphans: the scan must
249        // SEE processes. A run that enumerates nothing would satisfy any
250        // assertion about the result being empty, which is exactly the blindness
251        // the old gate produced.
252        use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
253        let mut system = System::new_with_specifics(
254            RefreshKind::new().with_processes(ProcessRefreshKind::new()),
255        );
256        system.refresh_processes(ProcessesToUpdate::All, true);
257        assert!(
258            !system.processes().is_empty(),
259            "the process table must be readable, or the reaper reports a verdict \
260             it never measured"
261        );
262
263        // This process is in the table and is NOT a candidate: it is neither
264        // reparented nor foreign, so the filter must exclude it.
265        let own = std::process::id();
266        assert!(
267            !orphan_pids(0).iter().any(|(pid, _)| *pid == own),
268            "the scan must never target the running process, at any age threshold"
269        );
270    }
271}