Skip to main content

sqlite_graphrag/
llm_slots.rs

1//! GAP-004 (v1.0.82): cross-process semaphore for spawning LLM subprocesses.
2//!
3//! When N Claude Code sessions run in parallel on the same host, each `remember`/`edit`/
4//! `recall`/`hybrid-search`/`enrich`/`deep-research`/`ingest` wants to spawn its own
5//! `codex exec` or `claude -p` subprocess. Without coordination, N subprocesses saturate
6//! the shared OAuth rate limit (observed: 19+ concurrent codex in the transcript
7//! of 2026-06-15).
8//!
9//! ## Solution
10//! - Slot files at `${XDG_RUNTIME_DIR:-~/.local/share}/sqlite-graphrag/llm-slots/slot-{0..N}.lock`
11//! - `fs4::FileExt::try_lock_exclusive` for atomic cross-process acquire (fcntl on Unix,
12//!   LockFileEx on Windows — `fs4` 0.9 with trustScore 9.6 confirmed via context7)
13//! - RAII guard `LlmSlotGuard` with `Drop` releases automatically on panic
14//! - Integration with `reaper.rs::scan_and_kill_orphans` to detect orphaned slots
15//!
16//! ## Usage
17//! ```rust,ignore
18//! use crate::llm_slots::acquire_llm_slot;
19//!
20//! let _guard = acquire_llm_slot(4, 30)?;
21//! // ... spawn LLM subprocess ...
22//! // the guard releases the slot automatically when it leaves scope
23//! ```
24
25use fs4::fs_std::FileExt;
26use std::fs::{self, File, OpenOptions};
27use std::path::PathBuf;
28use std::time::{Duration, Instant};
29
30use crate::errors::AppError;
31
32/// RAII guard that releases the slot automatically on panic, abrupt cancellation,
33/// or normal scope exit.
34pub struct LlmSlotGuard {
35    #[allow(dead_code)]
36    slot_file: File,
37    slot_id: u32,
38    acquired_at: Instant,
39}
40
41impl LlmSlotGuard {
42    /// Returns the slot id (0..max-1) this guard holds. Used by
43    /// `slots release --slot-id N` to map back to the file path.
44    pub fn slot_id(&self) -> u32 {
45        self.slot_id
46    }
47}
48
49impl Drop for LlmSlotGuard {
50    fn drop(&mut self) {
51        // Libera o lock do filesystem E remove o slot file.
52        // The flock is released automatically when `slot_file` is dropped (RAII).
53        let path = slot_path(self.slot_id);
54        if let Err(e) = fs::remove_file(&path) {
55            tracing::debug!(slot_id = self.slot_id, error = %e, "slot file removal failed (already gone?)");
56        }
57        tracing::debug!(
58            slot_id = self.slot_id,
59            held_ms = self.acquired_at.elapsed().as_millis() as u64,
60            "llm slot released"
61        );
62    }
63}
64
65/// Acquires a free LLM slot, waiting up to `wait_secs` seconds.
66///
67/// Iterates over `slot_id` in `[0, max_concurrent)` and tries `create_new` + `try_lock_exclusive`.
68/// If all slots are busy, polls with `sleep(100ms)` until `wait_secs` expires.
69///
70/// ## Errors
71/// - `AppError::LockBusy` (exit 75) if `wait_secs` expires without a free slot
72/// - `AppError::Io` if the filesystem fails
73pub fn acquire_llm_slot(max_concurrent: u32, wait_secs: u64) -> Result<LlmSlotGuard, AppError> {
74    if max_concurrent == 0 {
75        return Err(AppError::Validation(
76            crate::i18n::validation::llm_slot_ceiling_must_be_positive(),
77        ));
78    }
79    let dir = slots_dir();
80    fs::create_dir_all(&dir).map_err(|e| {
81        AppError::Io(std::io::Error::new(
82            e.kind(),
83            format!("failed to create slots dir {}: {e}", dir.display()),
84        ))
85    })?;
86
87    let stale = find_stale_slots(max_concurrent);
88    for slot_id in &stale {
89        let _ = force_release(*slot_id);
90        tracing::info!(slot_id, "released stale LLM slot (PID dead)");
91    }
92
93    let start = Instant::now();
94    let timeout = Duration::from_secs(wait_secs);
95
96    loop {
97        for slot_id in 0..max_concurrent {
98            let path = slot_path(slot_id);
99            match OpenOptions::new().write(true).create_new(true).open(&path) {
100                Ok(mut file) => {
101                    if file.try_lock_exclusive().is_ok() {
102                        let pid = std::process::id();
103                        // Write pid into the file so diagnostics can report the holder
104                        use std::io::Write;
105                        let _ = writeln!(file, "pid={pid}");
106                        tracing::debug!(slot_id, pid, "llm slot acquired");
107                        return Ok(LlmSlotGuard {
108                            slot_file: file,
109                            slot_id,
110                            acquired_at: Instant::now(),
111                        });
112                    }
113                    // The slot file exists but another process holds its lock.
114                }
115                Err(_) => {
116                    // The slot file already exists (rare race) — try the next.
117                }
118            }
119        }
120        // All slots busy — polling
121        if start.elapsed() >= timeout {
122            return Err(AppError::LockBusy(
123                crate::i18n::errors_ops::llm_slot_acquire_timeout(wait_secs, max_concurrent),
124            ));
125        }
126        std::thread::sleep(Duration::from_millis(
127            crate::constants::LLM_SLOT_POLL_INTERVAL_MS,
128        ));
129    }
130}
131
132/// Returns the current status of the LLM slots (for the `slots status --json` subcommand).
133#[derive(Debug, Clone, serde::Serialize)]
134pub struct SlotStatus {
135    /// Max.
136    pub max: u32,
137    /// Active.
138    pub active: u32,
139    /// Pids.
140    pub pids: Vec<u32>,
141}
142
143/// Read status.
144pub fn read_status(max_concurrent: u32) -> SlotStatus {
145    let mut active = 0u32;
146    let mut pids = Vec::new();
147    for slot_id in 0..max_concurrent {
148        let path = slot_path(slot_id);
149        if path.exists() {
150            active += 1;
151            if let Ok(content) = fs::read_to_string(&path) {
152                if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
153                    if let Ok(pid) = pid_line[4..].parse::<u32>() {
154                        pids.push(pid);
155                    }
156                }
157            }
158        }
159    }
160    SlotStatus {
161        max: max_concurrent,
162        active,
163        pids,
164    }
165}
166
167/// Releases a specific slot (for the `slots release --slot-id N --yes` subcommand).
168pub fn force_release(slot_id: u32) -> Result<(), AppError> {
169    let path = slot_path(slot_id);
170    if path.exists() {
171        fs::remove_file(&path).map_err(|e| {
172            AppError::Io(std::io::Error::new(
173                e.kind(),
174                format!("failed to release slot {slot_id}: {e}"),
175            ))
176        })?;
177    }
178    Ok(())
179}
180
181/// Lists stale slot IDs (orphaned PIDs) — for automatic cleanup.
182pub fn find_stale_slots(max_concurrent: u32) -> Vec<u32> {
183    let mut stale = Vec::new();
184    for slot_id in 0..max_concurrent {
185        let path = slot_path(slot_id);
186        if path.exists() {
187            if let Ok(content) = fs::read_to_string(&path) {
188                if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
189                    if let Ok(pid) = pid_line[4..].parse::<u32>() {
190                        if !pid_alive(pid) {
191                            stale.push(slot_id);
192                        }
193                    }
194                }
195            }
196        }
197    }
198    stale
199}
200
201/// Checks whether a PID is alive on the system (best-effort cross-platform).
202#[cfg(unix)]
203fn pid_alive(pid: u32) -> bool {
204    // Try signal 0 (no-op) to check process existence
205    unsafe { libc::kill(pid as i32, 0) == 0 }
206}
207
208#[cfg(not(unix))]
209fn pid_alive(pid: u32) -> bool {
210    // On Windows there is no direct equivalent, so a slot whose file exists is
211    // assumed alive. `slots cleanup --yes` is the manual way out.
212    let _ = pid;
213    true
214}
215
216/// Slots dir.
217pub fn slots_dir() -> PathBuf {
218    // GAP-SG-94: never hardcode "/tmp". Prefer XDG runtime, then the shared
219    // cache resolver (same root as lock files), then the OS temp directory.
220    if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
221        if !runtime.is_empty() {
222            return PathBuf::from(runtime).join("sqlite-graphrag/llm-slots");
223        }
224    }
225    if let Ok(Some(cache)) = crate::config::get_setting("cache.dir") {
226        if !cache.is_empty() {
227            return PathBuf::from(cache).join("llm-slots");
228        }
229    }
230    // `paths::cache_dir` returns Result; fall back to OS temp on failure.
231    match crate::paths::cache_dir() {
232        Ok(cache) => cache.join("llm-slots"),
233        Err(_) => std::env::temp_dir().join("sqlite-graphrag/llm-slots"),
234    }
235}
236
237/// Slot path.
238pub fn slot_path(id: u32) -> PathBuf {
239    slots_dir().join(format!("slot-{id}.lock"))
240}
241
242/// Resolves the default LLM max-host-concurrency value.
243///
244/// Calibrated for the LLM-only build: each worker holds one subprocess
245/// `codex` or `claude` invocation. The formula mirrors the CLI semaphore
246/// in `lock::calculate_safe_concurrency`:
247///   `min(ncpus, available_memory_mb / LLM_WORKER_RSS_MB)`
248///
249/// Falls back to `MAX_CONCURRENT_CLI_INSTANCES` (16) when `sysinfo`
250/// cannot read `/proc/meminfo` (rare).
251pub fn default_max_concurrency() -> u32 {
252    let cpus = std::thread::available_parallelism()
253        .map(|n| n.get() as u32)
254        .unwrap_or(4);
255    // Without `sysinfo` at hand here, fall back to a conservative estimate.
256    // `lock::calculate_safe_concurrency` is the source of truth when exact
257    // memory data is available; this only keeps the LLM slot default in the
258    // same order of magnitude.
259    let assumed_available_mb = crate::constants::LLM_SLOT_ASSUMED_AVAILABLE_MB;
260    // Estimate, not a measurement — read through the XDG-aware resolver so an
261    // operator who profiled the real footprint is not stuck with the default.
262    let per_worker = u32::try_from(crate::constants::llm_worker_rss_mb()).unwrap_or(u32::MAX);
263    let safe = assumed_available_mb / per_worker.max(1);
264    let capped = safe.min(crate::constants::MAX_CONCURRENT_CLI_INSTANCES as u32);
265    cpus.min(capped).max(1)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use std::sync::Arc;
272    use std::sync::Barrier;
273    use std::thread;
274
275    // Serialises every test that mutates the process-global slot env
276    // (XDG_RUNTIME_DIR / --cache-dir / cache.dir). Without this, parallel
277    // tests clobber each other's env and collide in the same slots dir.
278    static SLOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
279
280    fn unique_test_dir() -> PathBuf {
281        let mut dir = std::env::temp_dir();
282        dir.push(format!(
283            "llm-slots-test-{}-{}",
284            std::process::id(),
285            std::time::SystemTime::now()
286                .duration_since(std::time::UNIX_EPOCH)
287                .unwrap()
288                .as_nanos()
289        ));
290        dir
291    }
292
293    fn isolate_slots_env() -> (Option<String>, Option<String>) {
294        let orig_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
295        // Use unique XDG_RUNTIME_DIR so slots do not collide (no product env).
296        std::env::set_var("XDG_RUNTIME_DIR", unique_test_dir());
297        (orig_xdg, None)
298    }
299
300    fn restore_slots_env(orig_xdg: Option<String>, _orig_cache: Option<String>) {
301        match orig_xdg {
302            Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
303            None => std::env::remove_var("XDG_RUNTIME_DIR"),
304        }
305    }
306
307    #[test]
308    fn slot_enforces_max_concurrency() {
309        let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
310        let (orig_xdg, orig_cache) = isolate_slots_env();
311
312        let _g1 = acquire_llm_slot(2, 5).expect("first slot");
313        let _g2 = acquire_llm_slot(2, 5).expect("second slot");
314        let start = std::time::Instant::now();
315        let result = acquire_llm_slot(2, 1);
316        assert!(result.is_err(), "third slot should fail with max=2");
317        assert!(
318            start.elapsed() >= std::time::Duration::from_secs(1),
319            "should wait full timeout before failing"
320        );
321
322        restore_slots_env(orig_xdg, orig_cache);
323    }
324
325    #[test]
326    fn slot_releases_on_drop() {
327        let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
328        let (orig_xdg, orig_cache) = isolate_slots_env();
329
330        let g1 = acquire_llm_slot(1, 5).expect("first slot");
331        drop(g1);
332        let _g2 = acquire_llm_slot(1, 5).expect("second slot after drop");
333
334        restore_slots_env(orig_xdg, orig_cache);
335    }
336
337    #[test]
338    fn slot_max_concurrent_zero_is_validation_error() {
339        let result = acquire_llm_slot(0, 1);
340        assert!(matches!(result, Err(AppError::Validation(_))));
341    }
342
343    #[test]
344    fn read_status_reflects_active_slots() {
345        let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346        let (orig_xdg, orig_cache) = isolate_slots_env();
347
348        let _g1 = acquire_llm_slot(4, 5).expect("first slot");
349        let status = read_status(4);
350        assert_eq!(status.max, 4);
351        assert!(status.active >= 1);
352        assert!(!status.pids.is_empty());
353
354        restore_slots_env(orig_xdg, orig_cache);
355    }
356
357    #[test]
358    fn concurrent_acquires_with_2_threads_serialize() {
359        let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
360        let (orig_xdg, orig_cache) = isolate_slots_env();
361
362        let barrier = Arc::new(Barrier::new(3));
363        let mut handles = vec![];
364        for _ in 0..3 {
365            let b = barrier.clone();
366            handles.push(thread::spawn(move || {
367                b.wait();
368                acquire_llm_slot(2, 5)
369            }));
370        }
371        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
372        let successes = results.iter().filter(|r| r.is_ok()).count();
373        // max=2 -> at most 2 simultaneous successes (but the test serializes)
374        assert!(successes >= 1);
375
376        restore_slots_env(orig_xdg, orig_cache);
377    }
378}