Skip to main content

lean_ctx/lsp/
router.rs

1//! Code-intelligence backend registry.
2//!
3//! Backends are cached per **(normalized project root, language)**, so a
4//! long-lived daemon serving several repositories never answers project B with
5//! a server initialized for project A.
6//!
7//! Locking is two-level: the registry lock only guards lookup/insert/evict and
8//! is never held across backend I/O; each backend sits in its own slot mutex.
9//! A slow or hung server therefore blocks only callers of that same
10//! project+language (who need that server anyway), and concurrent first calls
11//! for one key start exactly one server (single-flight).
12//!
13//! Lifecycle: a stale backend (IDE gone, server exited/crashed) is replaced
14//! before use, and evicted after a failed call so the next call recovers. The
15//! failed operation itself is never retried — `rename`/`move` have side
16//! effects. Idle servers are shut down after the memory profile's TTL.
17
18use 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
30/// How often the idle reaper wakes up. Bounds how far past its TTL an idle
31/// server may live; the TTL itself comes from the memory profile.
32const 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
49/// `None` until the first call for this key selects a backend.
50type 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
60/// The registry holds no invariant a panicking holder could break mid-update
61/// (single insert/remove calls), so a poisoned lock is safe to reuse.
62fn 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
75/// Selects a code-intelligence backend for `language` (§4.3).
76///
77/// Config `cfg.lsp[language]` (HashMap<String,String>):
78///   - absent      → "auto" = B-first (JetBrains if reachable, else rust-analyzer)
79///   - "auto"      → same as absent
80///   - "jetbrains" → B only (error if the IDE is not reachable; no fallback)
81///   - anything else → an explicit language-server binary path = A only
82///
83/// Reachability = live port file + pid alive + `/health` ping. On any miss in
84/// "auto" mode we fall back to Backing A deterministically (one ~300ms timeout max).
85///
86/// Configuration is read for `project_root` itself, never for the process's
87/// working directory: a daemon serving several repositories must select and
88/// start the backend `project_root` asks for.
89fn 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    // A live IDE is attached to, never spawned; a language server is.
122    if policy == StartPolicy::ReuseOnly {
123        return Err(format!(
124            "{NOT_RUNNING}: no running semantic backend for '{language}' in {project_root}"
125        ));
126    }
127
128    // Backing A: a standalone language server.
129    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
135/// The standalone language server lean-ctx would start for `language` in
136/// `project_root`: the binary configured as `[lsp] <language> = "<path>"`,
137/// or else the one resolved for the project. Also answers status surfaces,
138/// so they report what would actually run.
139pub(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/// What a non-mutating look at the registry finds for a language.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum LiveIdentity {
180    /// A backend is cached and idle; its identity.
181    Known(String),
182    /// No backend is cached for this project and language.
183    NotRunning,
184    /// A backend is cached but serving another call right now.
185    Busy,
186}
187
188/// Identity of the backend cached for `file_path`'s language, without
189/// creating a registry entry, refreshing its idle clock, waiting, or starting
190/// anything — safe to call on every cache hit.
191pub 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
212/// Returns the slot for `key` (creating an empty one) and marks it used.
213fn 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
224/// Makes `slot` hold a usable backend: a stale one is dropped, an empty slot
225/// is filled by `select`. A healthy cached backend is reused as-is.
226fn 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
241/// Error prefix of a [`StartPolicy::ReuseOnly`] call that found nothing running.
242pub const NOT_RUNNING: &str = "SEMANTIC_BACKEND_NOT_RUNNING";
243
244/// Whether a call may start a language server that is not running yet.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum StartPolicy {
247    /// Start one on demand (interactive tools such as `ctx_refactor`).
248    Lazy,
249    /// Use only a backend that is already warm in this process or a live IDE.
250    /// Background work uses this so it never spawns a heavyweight server.
251    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
273/// Error prefix of a non-waiting call that found the backend busy.
274pub const BUSY: &str = "SEMANTIC_BACKEND_BUSY";
275
276/// How a call may wait on the backend.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct BackendOpts {
279    /// `false`: a backend serving another call (e.g. an interactive
280    /// `ctx_refactor`) is not waited for — fail fast with [`BUSY`].
281    pub wait: bool,
282    /// Bound for starting a server, if one must be started (`None` = the
283    /// server's default `initialize` timeout).
284    pub start_timeout: Option<Duration>,
285}
286
287impl BackendOpts {
288    /// Interactive tools: wait for the backend, default start-up time.
289    pub const INTERACTIVE: Self = Self {
290        wait: true,
291        start_timeout: None,
292    };
293}
294
295/// Like [`with_backend_policy`], with explicit waiting / start-up limits.
296/// Opportunistic semantic work never waits and bounds start-up by its own
297/// remaining budget, so it can neither delay interactive tools nor overrun.
298pub 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        // A previous call panicked mid-request: the server's protocol state is
333        // unknown, so discard it and let this call start a fresh one.
334        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        // What it negotiated, for status surfaces in other processes too.
346        crate::core::semantic::coverage::record_negotiated(language, &backend.backend_info());
347        Ok(backend)
348    })?;
349    // A backend is live here: let `auto` mode put it to use for the graph
350    // (rate-limited; runs on its own thread once this call is done).
351    crate::core::graph_enricher::schedule_semantic_refresh(project_root);
352    let result = f(backend, language);
353
354    // The server died during the call: evict it so the next call recovers.
355    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
387/// Removes entries unused for at least `ttl` that no caller currently holds.
388/// They are returned rather than dropped so server shutdown (up to a few
389/// seconds each) runs after the registry lock is released.
390fn 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
402/// Started on the first backend selection; one thread for the process.
403fn 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
419/// Whether any semantic backend is live for `project_root` right now: a warm
420/// language server in this process, or a reachable JetBrains IDE (port file
421/// with a live pid — no HTTP). Never blocks on a busy backend (busy = live).
422pub 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
436/// Whether any backend of `project_root` is serving a call right now — a
437/// non-blocking registry peek.
438pub 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/// Serializes tests that seed `BACKENDS` stubs: `BACKENDS` is process-global,
467/// so two parallel tests seeding the same key would race (one stub overwrites
468/// the other between seed and use). Hold this for the whole test.
469#[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        // With no IDE port file for an unlikely root, discovery yields None →
485        // select_backend would deterministically fall through to Backing A.
486        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    /// Identifies itself via `last_truncation().total`; `dies_on_call` makes
491    /// `references` fail and flip the backend stale, like a crashed server.
492    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        // Same project spelled with a trailing slash → same normalized key.
571        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(&registry()[&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        // A pure peek: it never creates a registry entry for an unseen root.
603        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                // Released by the main thread only after its own call returned;
635                // under a registry-wide lock that call would block until timeout.
636                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(&registry()[&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}