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}