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