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//! [`scan_and_kill_orphans`] walks the process table at startup and
10//! terminates any `claude` or `codex` 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 LLM CLI 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// v1.0.74: gate the orphan-reaper internals behind `cfg(unix)` so the
18// constants and the `Duration` import are not flagged as dead code on
19// Windows. The tests that reference them also need the same gate so the
20// Windows test compilation does not break (the tests assert the values
21// match the contract documented in CHANGELOG G28).
22#[cfg(unix)]
23use std::time::Duration;
24
25#[cfg(unix)]
26const ORPHAN_MIN_AGE_SECS: u64 = 60;
27
28#[cfg(unix)]
29const ORPHAN_SCAN_TARGETS: &[&str] = &["claude", "codex", "sqlite-graphrag"];
30
31/// Reaper report.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ReaperReport {
34    /// Number of orphan processes detected.
35    pub found: usize,
36    /// Number of orphan processes successfully terminated.
37    pub killed: usize,
38    /// Number that we could not terminate (permission, ESRCH, etc).
39    pub failed: usize,
40    /// Elapsed wall time of the scan.
41    pub elapsed_ms: u64,
42}
43
44/// Walks the process table and kills orphan LLM invocations.
45///
46/// The scan is best-effort and never panics: on any unexpected error it
47/// logs the failure and returns a report with `killed = 0`.
48pub fn scan_and_kill_orphans() -> ReaperReport {
49    let start = std::time::Instant::now();
50    let mut report = ReaperReport {
51        found: 0,
52        killed: 0,
53        failed: 0,
54        elapsed_ms: 0,
55    };
56
57    #[cfg(unix)]
58    {
59        if let Err(e) = scan_unix(&mut report) {
60            tracing::warn!(target: "reaper", error = %e, "orphan scan failed");
61        }
62        // G42/S4 (v1.0.79): also remove stale `codex-home-{pid}`
63        // isolation directories left behind by crashed invocations.
64        clean_stale_codex_homes();
65    }
66
67    let max = crate::llm_slots::default_max_concurrency();
68    let stale = crate::llm_slots::find_stale_slots(max);
69    for slot_id in &stale {
70        let _ = crate::llm_slots::force_release(*slot_id);
71        tracing::info!(target: "reaper", slot_id, "released stale LLM slot (PID dead)");
72    }
73
74    #[cfg(not(unix))]
75    {
76        tracing::debug!(target: "reaper", "orphan scan is a no-op on non-Unix platforms");
77    }
78
79    report.elapsed_ms = start.elapsed().as_millis() as u64;
80    if report.killed > 0 {
81        tracing::warn!(
82            target: "reaper",
83            found = report.found,
84            killed = report.killed,
85            failed = report.failed,
86            "reaped orphan LLM subprocesses"
87        );
88    } else {
89        tracing::info!(target: "reaper", found = report.found, "no orphan LLM subprocesses detected");
90    }
91    report
92}
93
94#[cfg(unix)]
95fn scan_unix(report: &mut ReaperReport) -> std::io::Result<()> {
96    use std::fs;
97    use std::path::Path;
98
99    let proc = Path::new("/proc");
100    let entries = fs::read_dir(proc)?;
101    for entry in entries.flatten() {
102        let name = entry.file_name();
103        let Some(name_str) = name.to_str() else {
104            continue;
105        };
106        if !name_str.chars().all(|c| c.is_ascii_digit()) {
107            continue;
108        }
109        let pid: i32 = match name_str.parse() {
110            Ok(p) => p,
111            Err(_) => continue,
112        };
113        if pid == std::process::id() as i32 {
114            continue;
115        }
116
117        let stat_path = entry.path().join("stat");
118        let stat = match fs::read_to_string(&stat_path) {
119            Ok(s) => s,
120            Err(_) => continue,
121        };
122
123        // /proc/[pid]/stat has the form: `pid (comm) state ppid ...`
124        // The comm field can contain spaces and parens; the last `)`
125        // separates the comm from the rest.
126        let Some(close_paren) = stat.rfind(')') else {
127            continue;
128        };
129        let after = &stat[close_paren + 1..];
130        let mut parts = after.split_whitespace();
131        // parts[0] = state (e.g. "R"), parts[1] = ppid, parts[2] = pgrp, ...
132        let state = parts.next().unwrap_or("");
133        let ppid: i32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(-1);
134
135        // Only target processes orphaned to init (PPID 1 on Linux/Unix
136        // when the parent is gone) or whose parent is also dead.
137        if ppid != 1 {
138            continue;
139        }
140
141        // Skip zombies (state Z) — they need no kill.
142        if state.starts_with('Z') {
143            continue;
144        }
145
146        // Resolve the comm field. proc/[pid]/comm is the short program
147        // name (no path); we use it instead of parsing the bracketed
148        // comm from stat to avoid encoding edge cases.
149        let comm_path = entry.path().join("comm");
150        let comm = match fs::read_to_string(&comm_path) {
151            Ok(s) => s.trim().to_string(),
152            Err(_) => continue,
153        };
154
155        if !ORPHAN_SCAN_TARGETS.iter().any(|t| comm == *t) {
156            continue;
157        }
158
159        // Age check: skip processes that just spawned (under 60s old) so
160        // we never race with a concurrent CLI invocation.
161        let age_ok = check_process_age(pid, ORPHAN_MIN_AGE_SECS);
162        if !age_ok {
163            continue;
164        }
165
166        report.found += 1;
167        match terminate_pid(pid) {
168            Ok(()) => {
169                report.killed += 1;
170                tracing::info!(target: "reaper", pid, comm = %comm, "killed orphan LLM subprocess");
171            }
172            Err(e) => {
173                report.failed += 1;
174                tracing::warn!(target: "reaper", pid, comm = %comm, error = %e, "failed to kill orphan");
175            }
176        }
177    }
178    Ok(())
179}
180
181#[cfg(unix)]
182fn check_process_age(pid: i32, min_age_secs: u64) -> bool {
183    use std::fs;
184    // /proc/[pid]/stat field 22 is start_time in clock ticks since boot.
185    // We instead use the simpler heuristic: stat file mtime.
186    let stat_path = std::path::Path::new("/proc")
187        .join(pid.to_string())
188        .join("stat");
189    let Ok(meta) = fs::metadata(&stat_path) else {
190        return false;
191    };
192    let Ok(modified) = meta.modified() else {
193        return false;
194    };
195    let Ok(elapsed) = std::time::SystemTime::now().duration_since(modified) else {
196        return false;
197    };
198    elapsed >= Duration::from_secs(min_age_secs)
199}
200
201/// G42/S4 (v1.0.79): removes `~/.local/share/sqlite-graphrag/codex-home-{pid}`
202/// directories whose owning PID is no longer alive.
203///
204/// `prepare_isolated_codex_home` creates one directory per process and
205/// never deletes it (deleting on exit would race a concurrent invocation
206/// re-using the same PID number). The reaper is the right owner for the
207/// cleanup: at startup it removes every stale dir in one sweep.
208///
209/// Best-effort and conservative: a dir is removed only when (a) the name
210/// parses as `codex-home-<pid>`, (b) `kill(pid, 0)` reports the process
211/// gone (ESRCH), and (c) the pid is not our own.
212#[cfg(unix)]
213fn clean_stale_codex_homes() {
214    let Ok(home) = std::env::var("HOME") else {
215        return;
216    };
217    let base = std::path::Path::new(&home).join(".local/share/sqlite-graphrag");
218    let Ok(entries) = std::fs::read_dir(&base) else {
219        return;
220    };
221    let mut removed = 0usize;
222    for entry in entries.flatten() {
223        let name = entry.file_name();
224        let Some(name_str) = name.to_str() else {
225            continue;
226        };
227        let Some(pid_str) = name_str.strip_prefix("codex-home-") else {
228            continue;
229        };
230        let Ok(pid) = pid_str.parse::<i32>() else {
231            continue;
232        };
233        if pid == std::process::id() as i32 {
234            continue;
235        }
236        // kill(pid, 0): signal 0 performs the permission/existence check
237        // without delivering a signal. ESRCH means the process is gone.
238        let alive = unsafe { libc::kill(pid, 0) } == 0
239            || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
240        if alive {
241            continue;
242        }
243        if std::fs::remove_dir_all(entry.path()).is_ok() {
244            removed += 1;
245        }
246    }
247    if removed > 0 {
248        tracing::info!(target: "reaper", removed, "removed stale codex-home isolation dirs");
249    }
250}
251
252#[cfg(unix)]
253fn terminate_pid(pid: i32) -> std::io::Result<()> {
254    // SIGTERM first; if the process ignores it for >2s, the caller can
255    // escalate to SIGKILL. For the reaper we send TERM and return; a
256    // follow-up sweep can send KILL if needed.
257    let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
258    if rc == 0 {
259        Ok(())
260    } else {
261        Err(std::io::Error::last_os_error())
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn reaper_report_starts_zeroed() {
271        let r = ReaperReport {
272            found: 0,
273            killed: 0,
274            failed: 0,
275            elapsed_ms: 0,
276        };
277        assert_eq!(r.found, 0);
278        assert_eq!(r.killed, 0);
279        assert_eq!(r.failed, 0);
280    }
281
282    #[cfg(unix)]
283    #[test]
284    fn orphan_min_age_is_one_minute() {
285        // G28: the threshold of 60s is the safety margin that prevents
286        // a CLI invocation from killing a concurrent peer that just
287        // started 5s ago.
288        assert_eq!(ORPHAN_MIN_AGE_SECS, 60);
289    }
290
291    #[cfg(unix)]
292    #[test]
293    fn orphan_targets_include_claude_and_codex() {
294        assert!(ORPHAN_SCAN_TARGETS.contains(&"claude"));
295        assert!(ORPHAN_SCAN_TARGETS.contains(&"codex"));
296    }
297
298    #[cfg(unix)]
299    #[test]
300    fn orphan_targets_include_sqlite_graphrag() {
301        assert!(ORPHAN_SCAN_TARGETS.contains(&"sqlite-graphrag"));
302    }
303
304    #[test]
305    fn scan_completes_without_panic_on_linux() {
306        // Just ensure the function returns a ReaperReport on the test
307        // host. On Linux CI we may be PID 1 in containers; the report
308        // will simply have found=0.
309        let r = scan_and_kill_orphans();
310        assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
311    }
312}