Skip to main content

lean_ctx/core/
startup_guard.rs

1use std::io::Write as _;
2use std::path::PathBuf;
3use std::time::Duration;
4
5pub const CRASH_LOOP_WINDOW_SECS: u64 = 60;
6pub const CRASH_LOOP_THRESHOLD: usize = 8;
7pub const CRASH_LOOP_MAX_BACKOFF_SECS: u64 = 30;
8
9pub const MCP_PROCESS_NAME: &str = "mcp-server";
10
11pub fn crash_loop_log_path(process_name: &str) -> Option<PathBuf> {
12    crate::core::data_dir::lean_ctx_data_dir()
13        .ok()
14        .map(|dir| dir.join(format!(".{}-starts.log", sanitize_lock_name(process_name))))
15}
16
17pub struct StartupLockGuard {
18    path: PathBuf,
19}
20
21impl StartupLockGuard {
22    pub fn touch(&self) {
23        // Refresh the lock's mtime so stale eviction doesn't reclaim an active
24        // long-running holder, while preserving the owner PID line so a crashed
25        // holder can still be detected as dead by other processes.
26        if let Ok(mut f) = std::fs::OpenOptions::new()
27            .write(true)
28            .truncate(true)
29            .open(&self.path)
30        {
31            let _ = writeln!(f, "{}", std::process::id());
32        }
33    }
34}
35
36/// Decides whether a currently-held lock file can be reclaimed by a waiter.
37///
38/// A lock whose recorded owner PID is no longer alive is reclaimed immediately —
39/// this is what stops a crashed/killed holder's lock from lingering until
40/// `stale_after` elapses (the cause of the stale `.graph-idx-*.lock` build-up).
41/// If the owner is alive, or the lock predates PID tracking (legacy 0-byte
42/// file), we fall back to the long-standing mtime staleness safety valve.
43fn lock_is_reclaimable(path: &std::path::Path, stale_after: Duration) -> bool {
44    if let Ok(content) = std::fs::read_to_string(path)
45        && let Some(pid) = content
46            .lines()
47            .next()
48            .and_then(|l| l.trim().parse::<u32>().ok())
49        && !crate::ipc::process::is_alive(pid)
50    {
51        return true;
52    }
53    if let Ok(meta) = std::fs::metadata(path)
54        && let Ok(modified) = meta.modified()
55    {
56        return modified.elapsed().unwrap_or_default() > stale_after;
57    }
58    false
59}
60
61impl Drop for StartupLockGuard {
62    fn drop(&mut self) {
63        let _ = std::fs::remove_file(&self.path);
64    }
65}
66
67fn sanitize_lock_name(name: &str) -> String {
68    name.chars()
69        .map(|c| {
70            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
71                c
72            } else {
73                '_'
74            }
75        })
76        .collect()
77}
78
79/// Best-effort cross-process lock (create_new + stale eviction).
80///
81/// Returns `None` if the data dir can't be resolved or if the lock can't be acquired
82/// within `timeout`.
83pub fn try_acquire_lock(
84    name: &str,
85    timeout: Duration,
86    stale_after: Duration,
87) -> Option<StartupLockGuard> {
88    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
89    let _ = std::fs::create_dir_all(&dir);
90
91    let name = sanitize_lock_name(name);
92    let path = dir.join(format!(".{name}.lock"));
93
94    let deadline = std::time::Instant::now().checked_add(timeout)?;
95    let mut sleep_ms: u64 = 10;
96
97    loop {
98        match std::fs::OpenOptions::new()
99            .write(true)
100            .create_new(true)
101            .open(&path)
102        {
103            Ok(mut f) => {
104                // Record the owner PID so a crashed holder's lock can be
105                // reclaimed immediately instead of waiting out `stale_after`.
106                let _ = writeln!(f, "{}", std::process::id());
107                return Some(StartupLockGuard { path });
108            }
109            Err(_) => {
110                if lock_is_reclaimable(&path, stale_after) {
111                    let _ = std::fs::remove_file(&path);
112                }
113            }
114        }
115
116        if std::time::Instant::now() >= deadline {
117            return None;
118        }
119
120        std::thread::sleep(Duration::from_millis(sleep_ms));
121        sleep_ms = (sleep_ms.saturating_mul(2)).min(120);
122    }
123}
124
125/// Detects rapid restart loops (e.g., IDE keeps respawning a crashing MCP server).
126/// Records each startup timestamp; if too many happen within the window, sleeps
127/// with exponential backoff to break the loop and avoid host degradation.
128///
129/// Returns the backoff this start triggered (`None` below the threshold), so
130/// callers and tests can observe the decision itself instead of timing it.
131/// In MCP server mode the backoff is reported but not slept (#1294).
132pub fn crash_loop_backoff(process_name: &str) -> Option<Duration> {
133    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
134    let _ = std::fs::create_dir_all(&dir);
135    let ts_path = dir.join(format!(".{}-starts.log", sanitize_lock_name(process_name)));
136
137    let now = std::time::SystemTime::now()
138        .duration_since(std::time::UNIX_EPOCH)
139        .unwrap_or_default()
140        .as_secs();
141
142    let cutoff = now.saturating_sub(CRASH_LOOP_WINDOW_SECS);
143
144    let mut recent: Vec<u64> = std::fs::read_to_string(&ts_path)
145        .unwrap_or_default()
146        .lines()
147        .filter_map(|l| l.trim().parse::<u64>().ok())
148        .filter(|&ts| ts >= cutoff)
149        .collect();
150    recent.push(now);
151
152    if let Ok(mut f) = std::fs::File::create(&ts_path) {
153        for ts in &recent {
154            let _ = writeln!(f, "{ts}");
155        }
156    }
157
158    if recent.len() > CRASH_LOOP_THRESHOLD {
159        let restarts_over = recent.len() - CRASH_LOOP_THRESHOLD;
160        let backoff_secs =
161            (2u64.saturating_pow(restarts_over as u32)).min(CRASH_LOOP_MAX_BACKOFF_SECS);
162
163        // #1294: In MCP server mode the client controls restarts and expects a
164        // fast `initialize` response. Sleeping here causes the client to close
165        // stdin → EOF → ConnectionClosed error. Only log the warning; the crash
166        // history still accumulates so a persistent loop is visible in logs.
167        if crate::core::runtime_flags::mcp_server_enabled() {
168            tracing::warn!(
169                "crash-loop detected ({} starts in {CRASH_LOOP_WINDOW_SECS}s) — \
170                 skipping {backoff_secs}s sleep in MCP mode (client controls retry)",
171                recent.len()
172            );
173        } else {
174            let msg = format!(
175                "lean-ctx: crash-loop protection — {process_name} started {} times in {CRASH_LOOP_WINDOW_SECS}s, \
176                 waiting {backoff_secs}s before accepting connections. \
177                 If your IDE is slow to initialize, this is normal.",
178                recent.len()
179            );
180            tracing::warn!("{msg}");
181            eprintln!("{msg}");
182            std::thread::sleep(Duration::from_secs(backoff_secs));
183        }
184        return Some(Duration::from_secs(backoff_secs));
185    }
186    None
187}
188
189/// Clears the crash-loop history file, resetting any active backoff.
190pub fn reset_crash_loop(process_name: &str) {
191    let Some(dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
192        return;
193    };
194    let ts_path = dir.join(format!(".{}-starts.log", sanitize_lock_name(process_name)));
195    let _ = std::fs::remove_file(&ts_path);
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    struct EnvVarGuard {
203        key: &'static str,
204        prev: Option<String>,
205    }
206
207    impl EnvVarGuard {
208        fn set(key: &'static str, value: &std::path::Path) -> Self {
209            let prev = std::env::var(key).ok();
210            crate::test_env::set_var(key, value);
211            Self { key, prev }
212        }
213    }
214
215    impl Drop for EnvVarGuard {
216        fn drop(&mut self) {
217            match self.prev.as_deref() {
218                Some(v) => crate::test_env::set_var(self.key, v),
219                None => crate::test_env::remove_var(self.key),
220            }
221        }
222    }
223
224    #[test]
225    fn lock_acquire_and_release() {
226        let _env = crate::core::data_dir::test_env_lock();
227        let dir = tempfile::tempdir().unwrap();
228        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
229
230        let g = try_acquire_lock(
231            "unit-test",
232            Duration::from_millis(200),
233            Duration::from_secs(30),
234        );
235        assert!(g.is_some());
236
237        let lock_path = dir.path().join(".unit-test.lock");
238        assert!(lock_path.exists());
239
240        drop(g);
241        assert!(!lock_path.exists());
242    }
243
244    #[test]
245    fn lock_times_out_while_held() {
246        let _env = crate::core::data_dir::test_env_lock();
247        let dir = tempfile::tempdir().unwrap();
248        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
249
250        let g1 = try_acquire_lock(
251            "unit-test-2",
252            Duration::from_millis(200),
253            Duration::from_secs(30),
254        )
255        .expect("first lock should acquire");
256        let g2 = try_acquire_lock(
257            "unit-test-2",
258            Duration::from_millis(60),
259            Duration::from_secs(30),
260        );
261        assert!(g2.is_none());
262
263        drop(g1);
264        let g3 = try_acquire_lock(
265            "unit-test-2",
266            Duration::from_millis(200),
267            Duration::from_secs(30),
268        );
269        assert!(g3.is_some());
270    }
271
272    #[test]
273    fn dead_owner_lock_is_reclaimed_immediately() {
274        let _env = crate::core::data_dir::test_env_lock();
275        let dir = tempfile::tempdir().unwrap();
276        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
277
278        // Pre-seed a held lock owned by a PID that cannot be alive.
279        let lock_path = dir.path().join(".dead-owner.lock");
280        std::fs::write(&lock_path, "4294967294\n").unwrap();
281
282        // The lock's mtime is fresh (just written), so the mtime safety valve
283        // would NOT reclaim it within stale_after — only the dead-PID check can.
284        let g = try_acquire_lock(
285            "dead-owner",
286            Duration::from_millis(300),
287            Duration::from_secs(30),
288        );
289        assert!(
290            g.is_some(),
291            "lock with a dead owner PID must be reclaimable"
292        );
293    }
294
295    #[test]
296    fn crash_loop_thresholds_are_resilient() {
297        let threshold = CRASH_LOOP_THRESHOLD;
298        let window = CRASH_LOOP_WINDOW_SECS;
299        let backoff = CRASH_LOOP_MAX_BACKOFF_SECS;
300        assert!(
301            threshold >= 8,
302            "threshold must tolerate IDE restart patterns (was {threshold})"
303        );
304        assert!(
305            window >= 60,
306            "window must cover slow IDE startup (was {window}s)"
307        );
308        assert!(
309            backoff <= 30,
310            "max backoff must not be too aggressive (was {backoff}s)"
311        );
312    }
313
314    #[test]
315    fn crash_loop_backoff_under_threshold_no_sleep() {
316        let _env = crate::core::data_dir::test_env_lock();
317        let dir = tempfile::tempdir().unwrap();
318        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
319
320        // Observe the backoff decision itself: a wall-clock bound flakes on a
321        // loaded CI runner, where plain file I/O can take longer than a second.
322        for start in 1..=CRASH_LOOP_THRESHOLD {
323            assert_eq!(
324                crash_loop_backoff("test-no-sleep"),
325                None,
326                "start {start} is under the threshold and must not back off"
327            );
328        }
329    }
330
331    #[test]
332    fn crash_loop_backoff_reports_the_backoff_above_threshold() {
333        let _env = crate::core::data_dir::test_env_lock();
334        let dir = tempfile::tempdir().unwrap();
335        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
336        let now = std::time::SystemTime::now()
337            .duration_since(std::time::UNIX_EPOCH)
338            .unwrap()
339            .as_secs();
340        let history = format!("{now}\n").repeat(CRASH_LOOP_THRESHOLD);
341        std::fs::write(dir.path().join(".test-over-starts.log"), history).unwrap();
342
343        // One start over the threshold: the smallest backoff (2 s, slept here).
344        // Proves the `None` assertions elsewhere observe a real decision.
345        assert_eq!(
346            crash_loop_backoff("test-over"),
347            Some(Duration::from_secs(2))
348        );
349    }
350
351    #[test]
352    fn reset_crash_loop_clears_history() {
353        let _env = crate::core::data_dir::test_env_lock();
354        let dir = tempfile::tempdir().unwrap();
355        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
356
357        for _ in 0..5 {
358            crash_loop_backoff("test-reset");
359        }
360        let log_path = dir.path().join(".test-reset-starts.log");
361        assert!(log_path.exists(), "crash loop log should exist after calls");
362
363        reset_crash_loop("test-reset");
364        assert!(
365            !log_path.exists(),
366            "crash loop log should be removed after reset"
367        );
368    }
369
370    #[test]
371    fn reset_crash_loop_nonexistent_is_noop() {
372        let _env = crate::core::data_dir::test_env_lock();
373        let dir = tempfile::tempdir().unwrap();
374        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
375
376        reset_crash_loop("never-existed");
377    }
378
379    /// GH #694 multi-window scenario: every healthy server resets the start
380    /// history after its completed handshake, so N windows × M client retries
381    /// can never accumulate into a fake crash loop whose pre-handshake backoff
382    /// would then *cause* the client timeouts it exists to prevent.
383    #[test]
384    fn handshake_reset_keeps_healthy_restarts_below_threshold() {
385        let _env = crate::core::data_dir::test_env_lock();
386        let dir = tempfile::tempdir().unwrap();
387        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
388
389        // Two full rounds of "threshold-many starts, then one handshake":
390        // without the reset the second round would exceed CRASH_LOOP_THRESHOLD
391        // and back off. The decision is asserted directly — timing the loop
392        // flaked on loaded CI runners where the file I/O alone exceeded 1 s.
393        for round in 0..2 {
394            for start in 1..=CRASH_LOOP_THRESHOLD {
395                assert_eq!(
396                    crash_loop_backoff("test-handshake"),
397                    None,
398                    "healthy start/handshake cycles must never trigger the backoff \
399                     (round {round}, start {start})"
400                );
401            }
402            reset_crash_loop("test-handshake");
403        }
404    }
405
406    #[test]
407    fn crash_loop_log_only_keeps_recent_entries() {
408        let _env = crate::core::data_dir::test_env_lock();
409        let dir = tempfile::tempdir().unwrap();
410        let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
411
412        let log_path = dir.path().join(".test-prune-starts.log");
413        let old_ts = 1000u64;
414        std::fs::write(&log_path, format!("{old_ts}\n")).unwrap();
415
416        crash_loop_backoff("test-prune");
417
418        let content = std::fs::read_to_string(&log_path).unwrap();
419        let lines: Vec<&str> = content.lines().collect();
420        assert_eq!(
421            lines.len(),
422            1,
423            "old entry should be pruned, only current remains"
424        );
425        let ts: u64 = lines[0].parse().unwrap();
426        assert!(ts > old_ts, "remaining entry should be recent");
427    }
428
429    #[test]
430    fn sanitize_lock_name_strips_special_chars() {
431        assert_eq!(sanitize_lock_name("mcp-stdio"), "mcp-stdio");
432        assert_eq!(sanitize_lock_name("mcp_http"), "mcp_http");
433        assert_eq!(sanitize_lock_name("a/b\\c:d"), "a_b_c_d");
434        assert_eq!(sanitize_lock_name("name with spaces"), "name_with_spaces");
435    }
436
437    #[test]
438    fn crash_loop_backoff_formula_correctness() {
439        assert_eq!(
440            2u64.saturating_pow(1).min(CRASH_LOOP_MAX_BACKOFF_SECS),
441            2,
442            "1 over threshold = 2s backoff"
443        );
444        assert_eq!(
445            2u64.saturating_pow(2).min(CRASH_LOOP_MAX_BACKOFF_SECS),
446            4,
447            "2 over threshold = 4s backoff"
448        );
449        assert_eq!(
450            2u64.saturating_pow(3).min(CRASH_LOOP_MAX_BACKOFF_SECS),
451            8,
452            "3 over threshold = 8s backoff"
453        );
454        assert_eq!(
455            2u64.saturating_pow(4).min(CRASH_LOOP_MAX_BACKOFF_SECS),
456            16,
457            "4 over threshold = 16s backoff"
458        );
459        assert_eq!(
460            2u64.saturating_pow(5).min(CRASH_LOOP_MAX_BACKOFF_SECS),
461            30,
462            "5 over threshold = capped at 30s"
463        );
464        assert_eq!(
465            2u64.saturating_pow(10).min(CRASH_LOOP_MAX_BACKOFF_SECS),
466            30,
467            "10 over threshold = still capped at 30s"
468        );
469    }
470}