sqlite_graphrag/
reaper.rs1#[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"];
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct ReaperReport {
33 pub found: usize,
35 pub killed: usize,
37 pub failed: usize,
39 pub elapsed_ms: u64,
41}
42
43pub fn scan_and_kill_orphans() -> ReaperReport {
48 let start = std::time::Instant::now();
49 let mut report = ReaperReport {
50 found: 0,
51 killed: 0,
52 failed: 0,
53 elapsed_ms: 0,
54 };
55
56 #[cfg(unix)]
57 {
58 if let Err(e) = scan_unix(&mut report) {
59 tracing::warn!(target: "reaper", error = %e, "orphan scan failed");
60 }
61 }
62
63 #[cfg(not(unix))]
64 {
65 tracing::debug!(target: "reaper", "orphan scan is a no-op on non-Unix platforms");
66 }
67
68 report.elapsed_ms = start.elapsed().as_millis() as u64;
69 if report.killed > 0 {
70 tracing::warn!(
71 target: "reaper",
72 found = report.found,
73 killed = report.killed,
74 failed = report.failed,
75 "reaped orphan LLM subprocesses"
76 );
77 } else {
78 tracing::info!(target: "reaper", found = report.found, "no orphan LLM subprocesses detected");
79 }
80 report
81}
82
83#[cfg(unix)]
84fn scan_unix(report: &mut ReaperReport) -> std::io::Result<()> {
85 use std::fs;
86 use std::path::Path;
87
88 let proc = Path::new("/proc");
89 let entries = fs::read_dir(proc)?;
90 for entry in entries.flatten() {
91 let name = entry.file_name();
92 let Some(name_str) = name.to_str() else {
93 continue;
94 };
95 if !name_str.chars().all(|c| c.is_ascii_digit()) {
96 continue;
97 }
98 let pid: i32 = match name_str.parse() {
99 Ok(p) => p,
100 Err(_) => continue,
101 };
102 if pid == std::process::id() as i32 {
103 continue;
104 }
105
106 let stat_path = entry.path().join("stat");
107 let stat = match fs::read_to_string(&stat_path) {
108 Ok(s) => s,
109 Err(_) => continue,
110 };
111
112 let Some(close_paren) = stat.rfind(')') else {
116 continue;
117 };
118 let after = &stat[close_paren + 1..];
119 let mut parts = after.split_whitespace();
120 let state = parts.next().unwrap_or("");
122 let ppid: i32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(-1);
123
124 if ppid != 1 {
127 continue;
128 }
129
130 if state.starts_with('Z') {
132 continue;
133 }
134
135 let comm_path = entry.path().join("comm");
139 let comm = match fs::read_to_string(&comm_path) {
140 Ok(s) => s.trim().to_string(),
141 Err(_) => continue,
142 };
143
144 if !ORPHAN_SCAN_TARGETS.iter().any(|t| comm == *t) {
145 continue;
146 }
147
148 let age_ok = check_process_age(pid, ORPHAN_MIN_AGE_SECS);
151 if !age_ok {
152 continue;
153 }
154
155 report.found += 1;
156 match terminate_pid(pid) {
157 Ok(()) => {
158 report.killed += 1;
159 tracing::info!(target: "reaper", pid, comm = %comm, "killed orphan LLM subprocess");
160 }
161 Err(e) => {
162 report.failed += 1;
163 tracing::warn!(target: "reaper", pid, comm = %comm, error = %e, "failed to kill orphan");
164 }
165 }
166 }
167 Ok(())
168}
169
170#[cfg(unix)]
171fn check_process_age(pid: i32, min_age_secs: u64) -> bool {
172 use std::fs;
173 let stat_path = std::path::Path::new("/proc")
176 .join(pid.to_string())
177 .join("stat");
178 let Ok(meta) = fs::metadata(&stat_path) else {
179 return false;
180 };
181 let Ok(modified) = meta.modified() else {
182 return false;
183 };
184 let Ok(elapsed) = std::time::SystemTime::now().duration_since(modified) else {
185 return false;
186 };
187 elapsed >= Duration::from_secs(min_age_secs)
188}
189
190#[cfg(unix)]
191fn terminate_pid(pid: i32) -> std::io::Result<()> {
192 let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
196 if rc == 0 {
197 Ok(())
198 } else {
199 Err(std::io::Error::last_os_error())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn reaper_report_starts_zeroed() {
209 let r = ReaperReport {
210 found: 0,
211 killed: 0,
212 failed: 0,
213 elapsed_ms: 0,
214 };
215 assert_eq!(r.found, 0);
216 assert_eq!(r.killed, 0);
217 assert_eq!(r.failed, 0);
218 }
219
220 #[cfg(unix)]
221 #[test]
222 fn orphan_min_age_is_one_minute() {
223 assert_eq!(ORPHAN_MIN_AGE_SECS, 60);
227 }
228
229 #[cfg(unix)]
230 #[test]
231 fn orphan_targets_include_claude_and_codex() {
232 assert!(ORPHAN_SCAN_TARGETS.contains(&"claude"));
233 assert!(ORPHAN_SCAN_TARGETS.contains(&"codex"));
234 }
235
236 #[test]
237 fn scan_completes_without_panic_on_linux() {
238 let r = scan_and_kill_orphans();
242 assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
243 }
244}