lean_ctx/core/
startup_guard.rs1use 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 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
36fn 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
79pub 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 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
125pub 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 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
189pub 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 let lock_path = dir.path().join(".dead-owner.lock");
280 std::fs::write(&lock_path, "4294967294\n").unwrap();
281
282 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 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 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 #[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 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}