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) {
129 let Some(dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
130 return;
131 };
132 let _ = std::fs::create_dir_all(&dir);
133 let ts_path = dir.join(format!(".{}-starts.log", sanitize_lock_name(process_name)));
134
135 let now = std::time::SystemTime::now()
136 .duration_since(std::time::UNIX_EPOCH)
137 .unwrap_or_default()
138 .as_secs();
139
140 let cutoff = now.saturating_sub(CRASH_LOOP_WINDOW_SECS);
141
142 let mut recent: Vec<u64> = std::fs::read_to_string(&ts_path)
143 .unwrap_or_default()
144 .lines()
145 .filter_map(|l| l.trim().parse::<u64>().ok())
146 .filter(|&ts| ts >= cutoff)
147 .collect();
148 recent.push(now);
149
150 if let Ok(mut f) = std::fs::File::create(&ts_path) {
151 for ts in &recent {
152 let _ = writeln!(f, "{ts}");
153 }
154 }
155
156 if recent.len() > CRASH_LOOP_THRESHOLD {
157 let restarts_over = recent.len() - CRASH_LOOP_THRESHOLD;
158 let backoff_secs =
159 (2u64.saturating_pow(restarts_over as u32)).min(CRASH_LOOP_MAX_BACKOFF_SECS);
160
161 if crate::core::runtime_flags::mcp_server_enabled() {
166 tracing::warn!(
167 "crash-loop detected ({} starts in {CRASH_LOOP_WINDOW_SECS}s) — \
168 skipping {backoff_secs}s sleep in MCP mode (client controls retry)",
169 recent.len()
170 );
171 } else {
172 let msg = format!(
173 "lean-ctx: crash-loop protection — {process_name} started {} times in {CRASH_LOOP_WINDOW_SECS}s, \
174 waiting {backoff_secs}s before accepting connections. \
175 If your IDE is slow to initialize, this is normal.",
176 recent.len()
177 );
178 tracing::warn!("{msg}");
179 eprintln!("{msg}");
180 std::thread::sleep(Duration::from_secs(backoff_secs));
181 }
182 }
183}
184
185pub fn reset_crash_loop(process_name: &str) {
187 let Some(dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
188 return;
189 };
190 let ts_path = dir.join(format!(".{}-starts.log", sanitize_lock_name(process_name)));
191 let _ = std::fs::remove_file(&ts_path);
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 struct EnvVarGuard {
199 key: &'static str,
200 prev: Option<String>,
201 }
202
203 impl EnvVarGuard {
204 fn set(key: &'static str, value: &std::path::Path) -> Self {
205 let prev = std::env::var(key).ok();
206 crate::test_env::set_var(key, value);
207 Self { key, prev }
208 }
209 }
210
211 impl Drop for EnvVarGuard {
212 fn drop(&mut self) {
213 match self.prev.as_deref() {
214 Some(v) => crate::test_env::set_var(self.key, v),
215 None => crate::test_env::remove_var(self.key),
216 }
217 }
218 }
219
220 #[test]
221 fn lock_acquire_and_release() {
222 let _env = crate::core::data_dir::test_env_lock();
223 let dir = tempfile::tempdir().unwrap();
224 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
225
226 let g = try_acquire_lock(
227 "unit-test",
228 Duration::from_millis(200),
229 Duration::from_secs(30),
230 );
231 assert!(g.is_some());
232
233 let lock_path = dir.path().join(".unit-test.lock");
234 assert!(lock_path.exists());
235
236 drop(g);
237 assert!(!lock_path.exists());
238 }
239
240 #[test]
241 fn lock_times_out_while_held() {
242 let _env = crate::core::data_dir::test_env_lock();
243 let dir = tempfile::tempdir().unwrap();
244 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
245
246 let g1 = try_acquire_lock(
247 "unit-test-2",
248 Duration::from_millis(200),
249 Duration::from_secs(30),
250 )
251 .expect("first lock should acquire");
252 let g2 = try_acquire_lock(
253 "unit-test-2",
254 Duration::from_millis(60),
255 Duration::from_secs(30),
256 );
257 assert!(g2.is_none());
258
259 drop(g1);
260 let g3 = try_acquire_lock(
261 "unit-test-2",
262 Duration::from_millis(200),
263 Duration::from_secs(30),
264 );
265 assert!(g3.is_some());
266 }
267
268 #[test]
269 fn dead_owner_lock_is_reclaimed_immediately() {
270 let _env = crate::core::data_dir::test_env_lock();
271 let dir = tempfile::tempdir().unwrap();
272 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
273
274 let lock_path = dir.path().join(".dead-owner.lock");
276 std::fs::write(&lock_path, "4294967294\n").unwrap();
277
278 let g = try_acquire_lock(
281 "dead-owner",
282 Duration::from_millis(300),
283 Duration::from_secs(30),
284 );
285 assert!(
286 g.is_some(),
287 "lock with a dead owner PID must be reclaimable"
288 );
289 }
290
291 #[test]
292 fn crash_loop_thresholds_are_resilient() {
293 let threshold = CRASH_LOOP_THRESHOLD;
294 let window = CRASH_LOOP_WINDOW_SECS;
295 let backoff = CRASH_LOOP_MAX_BACKOFF_SECS;
296 assert!(
297 threshold >= 8,
298 "threshold must tolerate IDE restart patterns (was {threshold})"
299 );
300 assert!(
301 window >= 60,
302 "window must cover slow IDE startup (was {window}s)"
303 );
304 assert!(
305 backoff <= 30,
306 "max backoff must not be too aggressive (was {backoff}s)"
307 );
308 }
309
310 #[test]
311 fn crash_loop_backoff_under_threshold_no_sleep() {
312 let _env = crate::core::data_dir::test_env_lock();
313 let dir = tempfile::tempdir().unwrap();
314 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
315
316 let start = std::time::Instant::now();
317 for _ in 0..CRASH_LOOP_THRESHOLD {
318 crash_loop_backoff("test-no-sleep");
319 }
320 assert!(
321 start.elapsed() < Duration::from_secs(5),
322 "under threshold should not sleep (elapsed {:?})",
323 start.elapsed()
324 );
325 }
326
327 #[test]
328 fn reset_crash_loop_clears_history() {
329 let _env = crate::core::data_dir::test_env_lock();
330 let dir = tempfile::tempdir().unwrap();
331 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
332
333 for _ in 0..5 {
334 crash_loop_backoff("test-reset");
335 }
336 let log_path = dir.path().join(".test-reset-starts.log");
337 assert!(log_path.exists(), "crash loop log should exist after calls");
338
339 reset_crash_loop("test-reset");
340 assert!(
341 !log_path.exists(),
342 "crash loop log should be removed after reset"
343 );
344 }
345
346 #[test]
347 fn reset_crash_loop_nonexistent_is_noop() {
348 let _env = crate::core::data_dir::test_env_lock();
349 let dir = tempfile::tempdir().unwrap();
350 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
351
352 reset_crash_loop("never-existed");
353 }
354
355 #[test]
360 fn handshake_reset_keeps_healthy_restarts_below_threshold() {
361 let _env = crate::core::data_dir::test_env_lock();
362 let dir = tempfile::tempdir().unwrap();
363 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
364
365 let start = std::time::Instant::now();
366 for _ in 0..2 {
370 for _ in 0..CRASH_LOOP_THRESHOLD {
371 crash_loop_backoff("test-handshake");
372 }
373 reset_crash_loop("test-handshake");
374 }
375 assert!(
376 start.elapsed() < Duration::from_secs(1),
377 "healthy start/handshake cycles must never trigger the backoff sleep"
378 );
379 }
380
381 #[test]
382 fn crash_loop_log_only_keeps_recent_entries() {
383 let _env = crate::core::data_dir::test_env_lock();
384 let dir = tempfile::tempdir().unwrap();
385 let _guard = EnvVarGuard::set("LEAN_CTX_DATA_DIR", dir.path());
386
387 let log_path = dir.path().join(".test-prune-starts.log");
388 let old_ts = 1000u64;
389 std::fs::write(&log_path, format!("{old_ts}\n")).unwrap();
390
391 crash_loop_backoff("test-prune");
392
393 let content = std::fs::read_to_string(&log_path).unwrap();
394 let lines: Vec<&str> = content.lines().collect();
395 assert_eq!(
396 lines.len(),
397 1,
398 "old entry should be pruned, only current remains"
399 );
400 let ts: u64 = lines[0].parse().unwrap();
401 assert!(ts > old_ts, "remaining entry should be recent");
402 }
403
404 #[test]
405 fn sanitize_lock_name_strips_special_chars() {
406 assert_eq!(sanitize_lock_name("mcp-stdio"), "mcp-stdio");
407 assert_eq!(sanitize_lock_name("mcp_http"), "mcp_http");
408 assert_eq!(sanitize_lock_name("a/b\\c:d"), "a_b_c_d");
409 assert_eq!(sanitize_lock_name("name with spaces"), "name_with_spaces");
410 }
411
412 #[test]
413 fn crash_loop_backoff_formula_correctness() {
414 assert_eq!(
415 2u64.saturating_pow(1).min(CRASH_LOOP_MAX_BACKOFF_SECS),
416 2,
417 "1 over threshold = 2s backoff"
418 );
419 assert_eq!(
420 2u64.saturating_pow(2).min(CRASH_LOOP_MAX_BACKOFF_SECS),
421 4,
422 "2 over threshold = 4s backoff"
423 );
424 assert_eq!(
425 2u64.saturating_pow(3).min(CRASH_LOOP_MAX_BACKOFF_SECS),
426 8,
427 "3 over threshold = 8s backoff"
428 );
429 assert_eq!(
430 2u64.saturating_pow(4).min(CRASH_LOOP_MAX_BACKOFF_SECS),
431 16,
432 "4 over threshold = 16s backoff"
433 );
434 assert_eq!(
435 2u64.saturating_pow(5).min(CRASH_LOOP_MAX_BACKOFF_SECS),
436 30,
437 "5 over threshold = capped at 30s"
438 );
439 assert_eq!(
440 2u64.saturating_pow(10).min(CRASH_LOOP_MAX_BACKOFF_SECS),
441 30,
442 "10 over threshold = still capped at 30s"
443 );
444 }
445}