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", "sqlite-graphrag"];
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ReaperReport {
34 pub found: usize,
36 pub killed: usize,
38 pub failed: usize,
40 pub elapsed_ms: u64,
42}
43
44pub 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 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 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 let state = parts.next().unwrap_or("");
133 let ppid: i32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(-1);
134
135 if ppid != 1 {
138 continue;
139 }
140
141 if state.starts_with('Z') {
143 continue;
144 }
145
146 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 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 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#[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 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 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 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 let r = scan_and_kill_orphans();
310 assert!(r.elapsed_ms < 30_000, "scan must finish in <30s");
311 }
312}