1use lsp_types::Uri;
19use std::collections::HashMap;
20use std::path::Path;
21use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
22use std::time::{Duration, Instant};
23
24use super::backend::LspBackend;
25use super::client::{LspClient, file_path_to_uri};
26use super::config::{ResolvedServer, check_server_available, language_for_extension};
27use super::jetbrains_backend::JetBrainsHttpBackend;
28use super::port_discovery;
29
30const REAP_INTERVAL: Duration = Duration::from_mins(1);
33
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35struct BackendKey {
36 project_root: String,
37 language: &'static str,
38}
39
40impl BackendKey {
41 fn new(project_root: &str, language: &'static str) -> Self {
42 Self {
43 project_root: crate::core::index_paths::normalize_project_root(project_root),
44 language,
45 }
46 }
47}
48
49type Slot = Arc<Mutex<Option<Box<dyn LspBackend>>>>;
51
52struct Entry {
53 slot: Slot,
54 last_used: Instant,
55}
56
57static BACKENDS: std::sync::LazyLock<Mutex<HashMap<BackendKey, Entry>>> =
58 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
59
60fn registry() -> MutexGuard<'static, HashMap<BackendKey, Entry>> {
63 BACKENDS.lock().unwrap_or_else(PoisonError::into_inner)
64}
65
66fn expand_tilde(path: &str) -> String {
67 if let Some(rest) = path.strip_prefix("~/")
68 && let Some(home) = dirs::home_dir()
69 {
70 return format!("{}/{rest}", home.display());
71 }
72 path.to_string()
73}
74
75fn select_backend(
90 language: &str,
91 project_root: &str,
92 policy: StartPolicy,
93 start_timeout: Option<Duration>,
94) -> Result<Box<dyn LspBackend>, String> {
95 let cfg = crate::core::config::Config::load_for_project_root(project_root);
96 let mode = cfg.lsp.get(language).map(String::as_str);
97
98 let want_b = matches!(mode, None | Some("auto" | "jetbrains"));
99 let b_only = mode == Some("jetbrains");
100
101 if want_b {
102 if let Some(pf) = port_discovery::read_port_file(project_root)
103 && port_discovery::pid_alive(pf.pid)
104 && port_discovery::health_ok(&pf)
105 {
106 return Ok(Box::new(JetBrainsHttpBackend::new(
107 pf.port,
108 pf.token,
109 project_root.to_string(),
110 pf.pid,
111 )));
112 }
113 if b_only {
114 return Err(format!(
115 "LSP backend 'jetbrains' configured for '{language}' but the IDE is not reachable \
116 (no live port file / health check failed)"
117 ));
118 }
119 }
120
121 if policy == StartPolicy::ReuseOnly {
123 return Err(format!(
124 "{NOT_RUNNING}: no running semantic backend for '{language}' in {project_root}"
125 ));
126 }
127
128 let server = standalone_server_with(&cfg, language, project_root)?;
130 let root_uri = file_path_to_uri(project_root)?;
131 let client = LspClient::start(&server, &root_uri, start_timeout)?;
132 Ok(Box::new(client) as Box<dyn LspBackend>)
133}
134
135pub(crate) fn standalone_server(
140 language: &str,
141 project_root: &str,
142) -> Result<ResolvedServer, String> {
143 let cfg = crate::core::config::Config::load_for_project_root(project_root);
144 standalone_server_with(&cfg, language, project_root)
145}
146
147fn standalone_server_with(
148 cfg: &crate::core::config::Config,
149 language: &str,
150 project_root: &str,
151) -> Result<ResolvedServer, String> {
152 match cfg.lsp.get(language).map(String::as_str) {
153 Some(custom) if !matches!(custom, "auto" | "jetbrains") => {
154 let command = expand_tilde(custom);
155 let command = if Path::new(&command).is_file() {
156 command
157 } else {
158 super::config::find_runnable_server(&command)
159 .ok_or_else(|| {
160 format!(
161 "Configured language server '{command}' for '{language}' not found or not runnable"
162 )
163 })?
164 .to_string_lossy()
165 .into_owned()
166 };
167 Ok(super::config::configured_server(
168 language,
169 &command,
170 Path::new(project_root),
171 ))
172 }
173 _ => check_server_available(language, Path::new(project_root)),
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum LiveIdentity {
180 Known(String),
182 NotRunning,
184 Busy,
186}
187
188pub fn live_identity(file_path: &str, project_root: &str) -> LiveIdentity {
192 let Some(language) = Path::new(file_path)
193 .extension()
194 .and_then(|e| e.to_str())
195 .and_then(language_for_extension)
196 else {
197 return LiveIdentity::NotRunning;
198 };
199 let key = BackendKey::new(project_root, language);
200 let Some(slot) = registry().get(&key).map(|e| Arc::clone(&e.slot)) else {
201 return LiveIdentity::NotRunning;
202 };
203 match slot.try_lock() {
204 Ok(guard) => guard.as_ref().map_or(LiveIdentity::NotRunning, |b| {
205 LiveIdentity::Known(b.backend_info().identity())
206 }),
207 Err(std::sync::TryLockError::WouldBlock) => LiveIdentity::Busy,
208 Err(std::sync::TryLockError::Poisoned(_)) => LiveIdentity::NotRunning,
209 }
210}
211
212fn slot_for(key: &BackendKey) -> Slot {
214 let now = Instant::now();
215 let mut reg = registry();
216 let entry = reg.entry(key.clone()).or_insert_with(|| Entry {
217 slot: Arc::default(),
218 last_used: now,
219 });
220 entry.last_used = now;
221 Arc::clone(&entry.slot)
222}
223
224fn ensure_backend<'a>(
227 slot: &'a mut Option<Box<dyn LspBackend>>,
228 project_root: &str,
229 select: impl FnOnce() -> Result<Box<dyn LspBackend>, String>,
230) -> Result<&'a mut (dyn LspBackend + 'static), String> {
231 if slot.as_ref().is_some_and(|b| b.is_stale(project_root)) {
232 *slot = None;
233 }
234 if slot.is_none() {
235 *slot = Some(select()?);
236 }
237 slot.as_deref_mut()
238 .ok_or_else(|| "LSP backend slot unexpectedly empty".to_string())
239}
240
241pub const NOT_RUNNING: &str = "SEMANTIC_BACKEND_NOT_RUNNING";
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum StartPolicy {
247 Lazy,
249 ReuseOnly,
252}
253
254pub fn with_backend<F, R>(file_path: &str, project_root: &str, f: F) -> Result<R, String>
255where
256 F: FnOnce(&mut dyn LspBackend, &str) -> Result<R, String>,
257{
258 with_backend_policy(file_path, project_root, StartPolicy::Lazy, f)
259}
260
261pub fn with_backend_policy<F, R>(
262 file_path: &str,
263 project_root: &str,
264 policy: StartPolicy,
265 f: F,
266) -> Result<R, String>
267where
268 F: FnOnce(&mut dyn LspBackend, &str) -> Result<R, String>,
269{
270 with_backend_opts(file_path, project_root, policy, BackendOpts::INTERACTIVE, f)
271}
272
273pub const BUSY: &str = "SEMANTIC_BACKEND_BUSY";
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct BackendOpts {
279 pub wait: bool,
282 pub start_timeout: Option<Duration>,
285}
286
287impl BackendOpts {
288 pub const INTERACTIVE: Self = Self {
290 wait: true,
291 start_timeout: None,
292 };
293}
294
295pub fn with_backend_opts<F, R>(
299 file_path: &str,
300 project_root: &str,
301 policy: StartPolicy,
302 opts: BackendOpts,
303 f: F,
304) -> Result<R, String>
305where
306 F: FnOnce(&mut dyn LspBackend, &str) -> Result<R, String>,
307{
308 let ext = Path::new(file_path)
309 .extension()
310 .and_then(|e| e.to_str())
311 .unwrap_or("");
312
313 let language = language_for_extension(ext).ok_or_else(|| {
314 format!(
315 "No LSP server configured for extension '.{ext}'. Supported: rs, ts, tsx, js, py, go"
316 )
317 })?;
318
319 let slot = slot_for(&BackendKey::new(project_root, language));
320 let locked = if opts.wait {
321 slot.lock().map_err(std::sync::TryLockError::Poisoned)
322 } else {
323 slot.try_lock()
324 };
325 let mut guard = match locked {
326 Ok(guard) => guard,
327 Err(std::sync::TryLockError::WouldBlock) => {
328 return Err(format!(
329 "{BUSY}: '{language}' backend for {project_root} is serving another call"
330 ));
331 }
332 Err(std::sync::TryLockError::Poisoned(poisoned)) => {
335 slot.clear_poison();
336 let mut guard = poisoned.into_inner();
337 *guard = None;
338 guard
339 }
340 };
341
342 let backend = ensure_backend(&mut guard, project_root, || {
343 let backend = select_backend(language, project_root, policy, opts.start_timeout)?;
344 start_idle_reaper();
345 crate::core::semantic::coverage::record_negotiated(language, &backend.backend_info());
347 Ok(backend)
348 })?;
349 crate::core::graph_enricher::schedule_semantic_refresh(project_root);
352 let result = f(backend, language);
353
354 if result.is_err()
356 && guard
357 .as_ref()
358 .is_some_and(|b| b.is_dead_after_error(project_root))
359 {
360 *guard = None;
361 }
362 result
363}
364
365pub fn open_file(file_path: &str, project_root: &str) -> Result<Uri, String> {
366 let ext = Path::new(file_path)
367 .extension()
368 .and_then(|e| e.to_str())
369 .unwrap_or("");
370 language_for_extension(ext).ok_or_else(|| {
371 format!(
372 "No LSP server configured for extension '.{ext}'. Supported: rs, ts, tsx, js, py, go"
373 )
374 })?;
375
376 let content = std::fs::read_to_string(file_path)
377 .map_err(|e| format!("Cannot read '{file_path}': {e}"))?;
378
379 let uri = file_path_to_uri(file_path)?;
380
381 with_backend(file_path, project_root, |backend, language| {
382 backend.open_file(&uri, language, &content)?;
383 Ok(uri.clone())
384 })
385}
386
387fn take_idle(now: Instant, ttl: Duration) -> Vec<Entry> {
391 let mut reg = registry();
392 let idle: Vec<BackendKey> = reg
393 .iter()
394 .filter(|(_, e)| {
395 now.saturating_duration_since(e.last_used) >= ttl && Arc::strong_count(&e.slot) == 1
396 })
397 .map(|(key, _)| key.clone())
398 .collect();
399 idle.iter().filter_map(|key| reg.remove(key)).collect()
400}
401
402fn start_idle_reaper() {
404 static STARTED: std::sync::Once = std::sync::Once::new();
405 STARTED.call_once(|| {
406 let _ = std::thread::Builder::new()
407 .name("lsp-idle-reaper".into())
408 .spawn(|| {
409 loop {
410 std::thread::sleep(REAP_INTERVAL);
411 let cfg = crate::core::config::Config::load();
412 let ttl = crate::core::config::MemoryProfile::effective(&cfg).lsp_idle_ttl();
413 drop(take_idle(Instant::now(), ttl));
414 }
415 });
416 });
417}
418
419pub fn has_live_backend(project_root: &str) -> bool {
423 let root = crate::core::index_paths::normalize_project_root(project_root);
424 let warm = registry().iter().any(|(key, entry)| {
425 key.project_root == root
426 && match entry.slot.try_lock() {
427 Ok(slot) => slot.is_some(),
428 Err(std::sync::TryLockError::WouldBlock) => true,
429 Err(std::sync::TryLockError::Poisoned(_)) => false,
430 }
431 });
432 warm || port_discovery::read_port_file(project_root)
433 .is_some_and(|pf| port_discovery::pid_alive(pf.pid))
434}
435
436pub fn backend_busy(project_root: &str) -> bool {
439 let root = crate::core::index_paths::normalize_project_root(project_root);
440 let slots: Vec<Slot> = registry()
441 .iter()
442 .filter(|(key, _)| key.project_root == root)
443 .map(|(_, e)| Arc::clone(&e.slot))
444 .collect();
445 slots
446 .iter()
447 .any(|s| matches!(s.try_lock(), Err(std::sync::TryLockError::WouldBlock)))
448}
449
450pub fn shutdown_all() {
451 let drained: Vec<Entry> = registry().drain().map(|(_, entry)| entry).collect();
452 drop(drained);
453}
454
455#[cfg(any(test, feature = "test-support"))]
456pub fn seed_stub_backend(project_root: &str, language: &'static str, backend: Box<dyn LspBackend>) {
457 registry().insert(
458 BackendKey::new(project_root, language),
459 Entry {
460 slot: Arc::new(Mutex::new(Some(backend))),
461 last_used: Instant::now(),
462 },
463 );
464}
465
466#[cfg(any(test, feature = "test-support"))]
470pub fn stub_test_lock() -> std::sync::MutexGuard<'static, ()> {
471 static LOCK: Mutex<()> = Mutex::new(());
472 LOCK.lock()
473 .unwrap_or_else(std::sync::PoisonError::into_inner)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::lsp::backend::Truncation;
480 use std::sync::atomic::{AtomicBool, Ordering};
481
482 #[test]
483 fn no_port_file_means_no_backing_b() {
484 let pf = port_discovery::read_port_file("/nonexistent/leanctx/proj/xyz");
487 assert!(pf.is_none(), "unexpected port file for nonexistent root");
488 }
489
490 struct Stub {
493 id: u32,
494 stale: AtomicBool,
495 dies_on_call: bool,
496 }
497
498 fn stub(id: u32) -> Box<dyn LspBackend> {
499 Box::new(Stub {
500 id,
501 stale: AtomicBool::new(false),
502 dies_on_call: false,
503 })
504 }
505
506 impl LspBackend for Stub {
507 fn open_file(&mut self, _u: &Uri, _l: &str, _t: &str) -> Result<(), String> {
508 Ok(())
509 }
510 fn references(
511 &mut self,
512 _u: &Uri,
513 _p: lsp_types::Position,
514 _s: &str,
515 ) -> Result<Vec<lsp_types::Location>, String> {
516 if self.dies_on_call {
517 self.stale.store(true, Ordering::Release);
518 return Err("LSP server closed connection (EOF)".into());
519 }
520 Ok(vec![])
521 }
522 fn definition(
523 &mut self,
524 _u: &Uri,
525 _p: lsp_types::Position,
526 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
527 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
528 }
529 fn implementations(
530 &mut self,
531 _u: &Uri,
532 _p: lsp_types::Position,
533 _s: &str,
534 ) -> Result<Vec<lsp_types::Location>, String> {
535 Ok(vec![])
536 }
537 fn rename(
538 &mut self,
539 _u: &Uri,
540 _p: lsp_types::Position,
541 _n: &str,
542 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
543 Ok(None)
544 }
545 fn is_stale(&self, _project_root: &str) -> bool {
546 self.stale.load(Ordering::Acquire)
547 }
548 fn last_truncation(&self) -> Option<Truncation> {
549 Some(Truncation {
550 truncated: false,
551 total: self.id,
552 })
553 }
554 }
555
556 fn backend_id(root: &str) -> Result<Option<u32>, String> {
557 with_backend(&format!("{root}/x.rs"), root, |b, _| {
558 Ok(b.last_truncation().map(|t| t.total))
559 })
560 }
561
562 #[test]
563 fn backends_are_isolated_per_project_root() {
564 let _lock = stub_test_lock();
565 seed_stub_backend("/leanctx-router-test/a", "rust", stub(1));
566 seed_stub_backend("/leanctx-router-test/b", "rust", stub(2));
567
568 assert_eq!(backend_id("/leanctx-router-test/a"), Ok(Some(1)));
569 assert_eq!(backend_id("/leanctx-router-test/b"), Ok(Some(2)));
570 assert_eq!(backend_id("/leanctx-router-test/a/"), Ok(Some(1)));
572 }
573
574 #[test]
575 fn reuse_only_uses_a_warm_backend_and_never_starts_one() {
576 let _lock = stub_test_lock();
577 let warm = "/leanctx-router-test/warm";
578 seed_stub_backend(warm, "rust", stub(5));
579 let id = with_backend_policy(
580 &format!("{warm}/x.rs"),
581 warm,
582 StartPolicy::ReuseOnly,
583 |b, _| Ok(b.last_truncation().map(|t| t.total)),
584 );
585 assert_eq!(id, Ok(Some(5)));
586
587 assert!(has_live_backend(warm));
588 assert_eq!(
589 live_identity(&format!("{warm}/x.rs"), warm),
590 LiveIdentity::Known("lsp:unknown@unknown".into())
591 );
592 let held = Arc::clone(®istry()[&BackendKey::new(warm, "rust")].slot);
593 let guard = held.lock().unwrap();
594 assert_eq!(
595 live_identity(&format!("{warm}/x.rs"), warm),
596 LiveIdentity::Busy
597 );
598 assert!(backend_busy(warm));
599 drop(guard);
600 assert!(!backend_busy(warm));
601
602 let unseen = "/leanctx-router-test/unseen";
604 assert_eq!(
605 live_identity(&format!("{unseen}/x.rs"), unseen),
606 LiveIdentity::NotRunning
607 );
608 assert!(!registry().contains_key(&BackendKey::new(unseen, "rust")));
609
610 let cold = "/leanctx-router-test/cold";
611 assert!(!has_live_backend(cold));
612 let err = with_backend_policy(
613 &format!("{cold}/x.rs"),
614 cold,
615 StartPolicy::ReuseOnly,
616 |_, _| Ok(()),
617 )
618 .unwrap_err();
619 assert!(err.starts_with(NOT_RUNNING), "got: {err}");
620 }
621
622 #[test]
623 fn slow_backend_does_not_block_another_project() {
624 let _lock = stub_test_lock();
625 seed_stub_backend("/leanctx-router-test/slow", "rust", stub(1));
626 seed_stub_backend("/leanctx-router-test/fast", "rust", stub(2));
627
628 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
629 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
630 let slow = std::thread::spawn(move || {
631 let root = "/leanctx-router-test/slow";
632 with_backend(&format!("{root}/x.rs"), root, |_, _| {
633 entered_tx.send(()).unwrap();
634 Ok(release_rx.recv_timeout(Duration::from_secs(10)).is_ok())
637 })
638 });
639
640 entered_rx.recv().unwrap();
641 assert_eq!(backend_id("/leanctx-router-test/fast"), Ok(Some(2)));
642 release_tx.send(()).unwrap();
643 assert_eq!(slow.join().unwrap(), Ok(true));
644 }
645
646 #[test]
647 fn stale_backend_is_replaced_and_fresh_backend_reused() {
648 let mut slot = Some(Box::new(Stub {
649 id: 1,
650 stale: AtomicBool::new(true),
651 dies_on_call: false,
652 }) as Box<dyn LspBackend>);
653 let replaced = ensure_backend(&mut slot, "/any", || Ok(stub(7))).unwrap();
654 assert_eq!(replaced.last_truncation().map(|t| t.total), Some(7));
655
656 let reused = ensure_backend(&mut slot, "/any", || Err("must not reselect".into())).unwrap();
657 assert_eq!(reused.last_truncation().map(|t| t.total), Some(7));
658 }
659
660 #[test]
661 fn backend_that_dies_mid_call_is_evicted() {
662 let _lock = stub_test_lock();
663 let root = "/leanctx-router-test/dies";
664 seed_stub_backend(
665 root,
666 "rust",
667 Box::new(Stub {
668 id: 1,
669 stale: AtomicBool::new(false),
670 dies_on_call: true,
671 }),
672 );
673 let uri = file_path_to_uri(&format!("{root}/x.rs")).unwrap();
674 let pos = lsp_types::Position {
675 line: 0,
676 character: 0,
677 };
678 let out = with_backend(&format!("{root}/x.rs"), root, |b, _| {
679 b.references(&uri, pos, "project")
680 });
681 assert!(out.is_err());
682
683 let slot = Arc::clone(®istry()[&BackendKey::new(root, "rust")].slot);
684 assert!(
685 slot.lock().unwrap().is_none(),
686 "dead backend must be evicted"
687 );
688 }
689
690 #[test]
691 fn idle_reaper_takes_only_unused_entries() {
692 let _lock = stub_test_lock();
693 let idle = BackendKey::new("/leanctx-router-test/idle", "rust");
694 let busy = BackendKey::new("/leanctx-router-test/busy", "rust");
695 seed_stub_backend(&idle.project_root, "rust", stub(1));
696 seed_stub_backend(&busy.project_root, "rust", stub(2));
697 let in_use = slot_for(&busy);
698
699 let later = Instant::now() + Duration::from_hours(1);
700 drop(take_idle(later, Duration::from_mins(1)));
701
702 let reg = registry();
703 assert!(!reg.contains_key(&idle), "idle entry must be reaped");
704 assert!(reg.contains_key(&busy), "entry held by a caller must stay");
705 drop(reg);
706 drop(in_use);
707 }
708}