Skip to main content

mcpls_core/
lib.rs

1//! # mcpls-core
2//!
3//! Core library for MCP (Model Context Protocol) to LSP (Language Server Protocol) translation.
4//!
5//! This crate provides the fundamental building blocks for bridging AI agents with
6//! language servers, enabling semantic code intelligence through MCP tools.
7//!
8//! ## Architecture
9//!
10//! The library is organized into several modules:
11//!
12//! - [`lsp`] - LSP client implementation for communicating with language servers
13//! - [`mcp`] - MCP tool definitions and handlers
14//! - [`bridge`] - Translation layer between MCP and LSP protocols
15//! - [`config`] - Configuration types and loading
16//! - [`mod@error`] - Error types for the library
17//!
18//! ## Example
19//!
20//! ```rust,ignore
21//! use mcpls_core::{serve, serve_with, Transport, ServerConfig};
22//!
23//! #[tokio::main]
24//! async fn main() {
25//!     let config = ServerConfig::load().expect("failed to load config");
26//!     // Stdio (default):
27//!     let result = serve(config).await;
28//!     // HTTP (requires `transport-http` feature):
29//!     // let http = mcpls_core::HttpConfig::new("127.0.0.1:3000".parse().unwrap(), "/mcp");
30//!     // let result = serve_with(config, Transport::Http(http)).await;
31//!
32//!     // See `serve`/`serve_with`'s "Shutdown" docs: process::exit avoids a
33//!     // runtime-shutdown hang under the stdio transport.
34//!     std::process::exit(if result.is_ok() { 0 } else { 1 });
35//! }
36//! ```
37
38#![cfg_attr(docsrs, feature(doc_cfg))]
39
40pub mod bridge;
41pub mod config;
42pub mod error;
43pub mod lsp;
44pub mod mcp;
45pub mod transport;
46mod util;
47
48#[cfg(test)]
49#[allow(clippy::unwrap_used, clippy::expect_used)]
50mod test_lsp;
51
52use std::collections::{HashMap, HashSet};
53use std::path::{Component, Path, PathBuf};
54use std::sync::Arc;
55use std::time::Duration;
56
57use bridge::resources::make_uri;
58use bridge::{NotificationCache, SubscriptionRegistry, Translator};
59pub use config::{ProjectConfigTrust, ServerConfig};
60use config::{ServerId, ToolRouter};
61pub use error::Error;
62use lsp::{LspNotification, LspServer, ServerInitConfig};
63use lsp_types::Uri;
64use rmcp::model::ResourceUpdatedNotificationParam;
65use tokio::sync::{Mutex, OnceCell};
66use tokio::task::{JoinHandle, JoinSet};
67use tracing::{debug, error, info, warn};
68#[cfg(feature = "transport-http")]
69#[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
70pub use transport::HttpConfig;
71pub use transport::Transport;
72#[cfg(feature = "transport-http")]
73use transport::run_http;
74use transport::{ShutdownSignal, run_stdio};
75
76/// Whether `uri` falls within one of `workspace_roots`.
77///
78/// Used to reject diagnostics for out-of-workspace URIs before caching them:
79/// a misbehaving or compromised LSP server could otherwise publish
80/// diagnostics for an unbounded number of fabricated (often non-existent)
81/// URIs, defeating `MAX_DIAGNOSTIC_ENTRIES`'s FIFO cap by flushing every
82/// legitimate entry out of the cache before it (see #234). Thin wrapper
83/// around [`bridge::uri_in_workspace_roots`], the same containment check
84/// rename/code-action `WorkspaceEdit` results use for the identical
85/// untrusted-URI problem -- see that function's docs for why read-only
86/// navigation results are deliberately exempt (and how `document_symbols`'
87/// flat response shape takes a different, non-filtering approach), plus the
88/// canonicalization and preconditions this inherits.
89fn diagnostic_path_in_workspace(uri: &Uri, workspace_roots: &[PathBuf]) -> bool {
90    bridge::uri_in_workspace_roots(uri, workspace_roots)
91}
92
93/// `Arc`-backed state shared by every `diagnostics_pump` task spawned for one
94/// `serve_with` run, factored out of `diagnostics_pump`'s parameter list to
95/// keep it under clippy's argument-count lint. `Clone` is cheap (`Arc`
96/// clones only).
97#[derive(Clone)]
98pub(crate) struct PumpShared {
99    pub(crate) notification_cache: Arc<Mutex<NotificationCache>>,
100    /// Aggregated across every live MCP session's own subscription set (one
101    /// per HTTP session, or the single set stdio ever has), so the pump can
102    /// ask "does *any* live session want this URI?" without holding a
103    /// reference to any one session's set -- see [`SubscriptionRegistry`].
104    pub(crate) subs: SubscriptionRegistry,
105    pub(crate) peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
106    /// Used to reject diagnostics for out-of-workspace URIs; see
107    /// `diagnostic_path_in_workspace`.
108    pub(crate) workspace_roots: Arc<[PathBuf]>,
109}
110
111/// Background task that drains LSP notifications, writes them to the cache,
112/// and forwards `resources/updated` to the MCP peer when subscribed.
113///
114/// Selects over two independent lanes (P3) rather than one: `rx` carries
115/// diagnostics/log/showMessage, `lifecycle_rx` carries `$/progress`
116/// `begin`/`end` frames and `Other` (which carries e.g. rust-analyzer's
117/// `experimental/serverStatus`). Splitting them means a high-volume
118/// diagnostics publisher (rust-analyzer republishing whole-workspace
119/// diagnostics on every save) can never starve out a low-volume readiness
120/// signal, or vice versa -- see `lsp::client::LspClient::message_loop_inner`
121/// for where each notification is classified onto its lane.
122///
123/// The task operates in two phases without explicit state:
124/// - **Phase A** (before peer is set): caches every notification, skips peer notify.
125/// - **Phase B** (after peer is set): additionally fires `notify_resource_updated`
126///   for subscribed `PublishDiagnostics` URIs.
127///
128/// The task exits when:
129/// - **Both** lanes have closed (`rx.recv()` and `lifecycle_rx.recv()` both
130///   returned `None`) -- in practice both senders live inside the same
131///   `LspClient` and close together, but each lane is tracked independently
132///   so one closing early can never stop the other from still being drained.
133/// - The cancellation watch fires (or the sender is dropped).
134/// - `notify_resource_updated` returns an error (peer disconnect / transport closed).
135///
136/// # Lock independence
137/// Cache writes acquire only `Arc<Mutex<NotificationCache>>`, a lock entirely
138/// separate from `translator`'s own internal locks (`Arc<Translator>` has no
139/// outer mutex; each field manages its own short-lived, independent lock).
140/// Neither an in-flight LSP round-trip (e.g. `textDocument/diagnostic`) nor
141/// any other translator-side work holds the notification-cache lock, so this
142/// pump is never blocked by tool-call activity: a `publishDiagnostics`
143/// notification arriving mid-request is cached immediately instead of being
144/// silently dropped. This matters because the LSP transport forwards
145/// notifications via `mpsc::Sender::try_send`, which drops on a full channel
146/// rather than blocking — a pump stalled behind someone else's lock would
147/// previously lose notifications under sustained push traffic.
148pub(crate) async fn diagnostics_pump(
149    server_id: ServerId,
150    mut rx: tokio::sync::mpsc::Receiver<LspNotification>,
151    mut lifecycle_rx: tokio::sync::mpsc::Receiver<LspNotification>,
152    mut cancel_rx: tokio::sync::watch::Receiver<bool>,
153    caches_diagnostics: bool,
154    shared: PumpShared,
155) {
156    let PumpShared {
157        notification_cache,
158        subs,
159        peer_cell,
160        workspace_roots,
161    } = shared;
162    let mut notification_closed = false;
163    let mut lifecycle_closed = false;
164    loop {
165        if notification_closed && lifecycle_closed {
166            break;
167        }
168        tokio::select! {
169            // Exit when cancellation is requested or the sender is dropped.
170            result = cancel_rx.changed() => {
171                // Err means the sender was dropped; treat as cancellation.
172                if result.is_err() || *cancel_rx.borrow() {
173                    break;
174                }
175            }
176            msg = rx.recv(), if !notification_closed => {
177                let Some(notif) = msg else {
178                    notification_closed = true;
179                    continue;
180                };
181                match notif {
182                    LspNotification::PublishDiagnostics(p) => {
183                        // Only the server the router resolves `Diagnostics` to for
184                        // this notification's language caches (and notifies
185                        // subscribers of) it -- see #174 §8. A server that was
186                        // never the diagnostics route, or lost it without a live
187                        // catch-all to rebind to, is not the authoritative source
188                        // for this language's diagnostics; skip publishing so it
189                        // doesn't overwrite (or spuriously notify about) another
190                        // server's cache entry.
191                        if !caches_diagnostics {
192                            continue;
193                        }
194                        if !diagnostic_path_in_workspace(&p.uri, &workspace_roots) {
195                            debug!(
196                                "dropping diagnostics for out-of-workspace URI: {}",
197                                p.uri.as_ref()
198                            );
199                            continue;
200                        }
201                        {
202                            let mut cache = notification_cache.lock().await;
203                            cache.store_diagnostics(&server_id, &p.uri, p.version, p.diagnostics);
204                        }
205
206                        // One snapshot of live sessions, queried twice below
207                        // (empty check, then contains check), instead of
208                        // `is_all_empty`/`any_contains` each independently
209                        // locking the registry and re-upgrading every `Weak`.
210                        let sessions = subs.live_sessions();
211
212                        // Fast path: skip URI construction when nothing is subscribed.
213                        let mut any_subscribed = false;
214                        for session in &sessions {
215                            if !session.is_empty().await {
216                                any_subscribed = true;
217                                break;
218                            }
219                        }
220                        if !any_subscribed {
221                            continue;
222                        }
223
224                        // Notify only when peer is ready and URI is subscribed.
225                        let Some(peer) = peer_cell.get() else { continue };
226                        let Some(path) = bridge::uri_to_path(&p.uri) else { continue };
227                        let Ok(mcp_uri) = make_uri(&path) else { continue };
228
229                        let mut subscribed_here = false;
230                        for session in &sessions {
231                            if session.contains(&mcp_uri).await {
232                                subscribed_here = true;
233                                break;
234                            }
235                        }
236                        if !subscribed_here {
237                            continue;
238                        }
239
240                        if peer
241                            .notify_resource_updated(ResourceUpdatedNotificationParam::new(
242                                mcp_uri,
243                            ))
244                            .await
245                            .is_err()
246                        {
247                            // Peer disconnected; stop the pump.
248                            break;
249                        }
250                    }
251                    LspNotification::LogMessage(m) => {
252                        let mut cache = notification_cache.lock().await;
253                        cache.store_log(m.kind.into(), m.message);
254                    }
255                    LspNotification::ShowMessage(m) => {
256                        let mut cache = notification_cache.lock().await;
257                        cache.store_message(m.kind.into(), m.message);
258                    }
259                    // Never classified onto this lane -- see `LspClient::message_loop_inner`'s routing.
260                    LspNotification::Progress(_) | LspNotification::Other { .. } => {}
261                }
262            }
263            msg = lifecycle_rx.recv(), if !lifecycle_closed => {
264                let Some(notif) = msg else {
265                    lifecycle_closed = true;
266                    continue;
267                };
268                bridge::apply_lifecycle_notification(
269                    &mut *notification_cache.lock().await,
270                    &server_id,
271                    notif,
272                );
273            }
274        }
275    }
276}
277
278/// Result of [`register_servers`]: everything the caller needs to start the
279/// per-server diagnostics pump tasks.
280pub(crate) struct RegisteredServers {
281    /// Notification and lifecycle-lane (P3) receivers extracted from each
282    /// server before registration, paired per server rather than kept in
283    /// two separate maps: both are always extracted together in
284    /// [`register_servers`]'s single population loop, so there is no
285    /// "notification receiver without a matching lifecycle receiver" state
286    /// to represent or handle at the call site (Fix 4 -- a `HashMap<_,
287    /// (Receiver, Receiver)>` makes that case unrepresentable instead of
288    /// needing an `unwrap`/`expect` to rule it out).
289    pub(crate) receivers: HashMap<
290        ServerId,
291        (
292            tokio::sync::mpsc::Receiver<lsp::LspNotification>,
293            tokio::sync::mpsc::Receiver<lsp::LspNotification>,
294        ),
295    >,
296    /// Whether each server is the one the (rebound) router resolves
297    /// `ToolKind::Diagnostics` to for its language -- see #174 §8. Computed
298    /// here, right after the rebind, so it always reflects the post-rebind
299    /// router rather than a stale pre-rebind view.
300    pub(crate) diagnostics_flags: HashMap<ServerId, bool>,
301}
302
303/// Register initialized LSP servers with the translator, rebind the router to
304/// the set that actually registered, and extract notification receivers.
305///
306/// Takes ownership of the `ServerInitResult`, extracts `notification_rx` from
307/// each server before registration. Registration itself is a sequence of
308/// short, independently-locked map inserts (see `Translator`'s field docs),
309/// so no external synchronization is required here; the rebind that follows
310/// relies only on all of *this* function's inserts having completed, which
311/// the sequential code below guarantees.
312///
313/// `configs` supplies the `ServerInitConfig` each surviving server was
314/// spawned from, keyed by routing identity, so the translator can respawn it
315/// later if its process dies (see `Translator::respawn_if_dead`).
316pub(crate) fn register_servers(
317    mut result: lsp::ServerInitResult,
318    translator: &bridge::Translator,
319    configs: &HashMap<ServerId, ServerInitConfig>,
320) -> RegisteredServers {
321    let mut receivers = HashMap::new();
322    for (id, server) in &mut result.servers {
323        receivers.insert(
324            id.clone(),
325            (server.take_notification_rx(), server.take_lifecycle_rx()),
326        );
327    }
328
329    let registered: HashSet<ServerId> = result.servers.keys().cloned().collect();
330
331    let mut language_by_id = HashMap::new();
332    for (id, server) in result.servers {
333        let client = server.client().clone();
334        language_by_id.insert(id.clone(), client.language_id().to_string());
335        translator.register_client(id.clone(), client);
336        if let Some(config) = configs.get(&id) {
337            translator.register_server_config(id.clone(), config.clone());
338        } else {
339            // Would silently turn auto-respawn into a no-op for this server
340            // (surfacing as `Error::ServerUnavailable` instead of actually
341            // recovering) -- the keys are derived identically on both sides
342            // (`LspServerConfig::id()`), so this should never happen; warn
343            // rather than fail, since the server is otherwise usable.
344            warn!(
345                "No respawn config registered for LSP server '{id}'; auto-respawn on crash will be unavailable for it"
346            );
347        }
348        translator.register_server(id, server);
349    }
350
351    translator.rebind_router(&registered);
352
353    let diagnostics_flags = language_by_id
354        .into_iter()
355        .map(|(id, language)| {
356            let is_diagnostics_server = translator.is_diagnostics_route(&language, &id);
357            (id, is_diagnostics_server)
358        })
359        .collect();
360
361    RegisteredServers {
362        receivers,
363        diagnostics_flags,
364    }
365}
366
367/// Resolve workspace roots against an absolute base directory.
368///
369/// If no workspace roots are provided, the base directory itself is used.
370/// Configured relative roots are joined to the base directory. Every existing
371/// path is canonicalized before it can reach workspace heuristics, LSP
372/// initialization, path validation, or diagnostics filtering.
373///
374/// # Returns
375///
376/// A vector of absolute workspace root paths. A relative root that cannot be
377/// canonicalized is rejected as invalid configuration rather than being left
378/// to fail later during `file://` URI conversion. An absolute root retains the
379/// previous fallback behavior and is kept as-is if canonicalization fails.
380fn resolve_workspace_roots(
381    config_roots: &[PathBuf],
382    base_dir: &Path,
383) -> Result<Vec<PathBuf>, Error> {
384    if !base_dir.is_absolute() {
385        return Err(Error::InvalidConfig(format!(
386            "workspace root base must be absolute: {}",
387            base_dir.display()
388        )));
389    }
390
391    if config_roots.is_empty() {
392        let root = match dunce::canonicalize(base_dir) {
393            Ok(canonical) => canonical,
394            Err(e) => {
395                warn!(
396                    "Failed to canonicalize workspace base directory {}: {e}, using non-canonical absolute path",
397                    base_dir.display()
398                );
399                base_dir.to_path_buf()
400            }
401        };
402        info!("Using workspace base directory as root: {}", root.display());
403        Ok(vec![root])
404    } else {
405        canonicalize_workspace_roots(config_roots, base_dir)
406    }
407}
408
409/// Resolve and canonicalize each configured workspace root.
410///
411/// Relative roots are resolved against `base_dir` and must exist. Absolute
412/// roots keep the historical fallback behavior: if canonicalization fails
413/// (for example because the directory is created after startup), the original
414/// absolute path is retained.
415///
416/// Uses [`dunce::canonicalize`] rather than [`Path::canonicalize`]: on
417/// Windows, the latter returns the `\\?\`-prefixed verbatim form (e.g.
418/// `\\?\C:\...`), which a URI-derived path from `Url::to_file_path` (never
419/// verbatim-prefixed) can never `starts_with`-match, silently dropping every
420/// diagnostic. `dunce::canonicalize` resolves symlinks identically but
421/// returns the ordinary `C:\...` form when the result doesn't require the
422/// verbatim syntax (i.e. essentially always, for realistic workspace paths).
423fn canonicalize_workspace_roots(roots: &[PathBuf], base_dir: &Path) -> Result<Vec<PathBuf>, Error> {
424    roots
425        .iter()
426        .map(|root| {
427            let is_relative = root.is_relative();
428            let resolved = if is_relative {
429                join_relative_root(base_dir, root)
430            } else {
431                root.clone()
432            };
433
434            match dunce::canonicalize(&resolved) {
435                Ok(canonical) => Ok(canonical),
436                Err(source) if is_relative => Err(Error::InvalidConfig(format!(
437                    "workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized: {source}",
438                    root.display(),
439                    base_dir.display(),
440                    resolved.display()
441                ))),
442                Err(source) => {
443                    warn!(
444                        "Failed to canonicalize absolute workspace root {}: {source}, using non-canonical path",
445                        resolved.display()
446                    );
447                    Ok(resolved)
448                }
449            }
450        })
451        .collect()
452}
453
454/// Join a relative `root` onto `base_dir`, correctly handling a root that
455/// [`Path::is_relative`] classifies `true` yet still carries a leading
456/// [`Component::Prefix`] and/or [`Component::RootDir`] -- on Windows,
457/// `is_absolute()` requires *both* a prefix and a root, so two distinct
458/// shapes are `is_relative() == true` despite being (partially) rooted:
459/// - no prefix, has root (e.g. `\workspace`) -- rooted on whichever drive is
460///   current.
461/// - has prefix, no root (e.g. `C:workspace`) -- drive-relative, resolved
462///   against that drive's own current directory.
463///
464/// Plain `base_dir.join(root)` would hit [`PathBuf::push`]'s documented
465/// special cases for both shapes, each discarding some or all of `base_dir`
466/// (e.g. `C:\proj\.agents`.join(`\workspace`) -> `C:\workspace`, and
467/// `C:\proj\.agents`.join(`C:workspace`) -> `C:workspace` -- `proj\.agents`
468/// is silently dropped either way). Skipping any leading `Prefix`/`RootDir`
469/// components before joining sidesteps both: only the ordinary relative tail
470/// (`Normal`/`CurDir`/`ParentDir` components) is ever appended to `base_dir`.
471/// For an already-ordinary relative root (the common case, no such leading
472/// components), this is equivalent to `base_dir.join(root)` up to a trailing
473/// separator (`.join` preserves one from a trailing empty/`CurDir`
474/// component; `.extend` does not -- immaterial after canonicalization, and
475/// the one case where it mattered, an empty root, is now rejected by
476/// `validate()`). Detected via `Component` iteration (not
477/// `#[cfg(windows)]`), so the logic itself is exercised by a unit test on
478/// any host -- see `#348`.
479fn join_relative_root(base_dir: &Path, root: &Path) -> PathBuf {
480    let mut joined = base_dir.to_path_buf();
481    joined.extend(
482        root.components()
483            .skip_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)),
484    );
485    joined
486}
487
488/// Start the MCPLS server with the given configuration over stdio.
489///
490/// This is the backward-compatible entry point. It is equivalent to calling
491/// `serve_with(config, Transport::Stdio)`.
492///
493/// # Errors
494///
495/// Returns an error if:
496/// - All LSP servers fail to initialize
497/// - MCP server setup fails
498/// - Configuration is invalid
499///
500/// # Graceful Degradation
501///
502/// - **All servers succeed**: Service runs normally
503/// - **Partial success**: Logs warnings for failures, continues with available servers
504/// - **All servers fail**: Returns `Error::AllServersFailedToInit` with details
505///
506/// # Shutdown
507///
508/// See [`serve_with`]'s "Shutdown" section — this function uses
509/// [`Transport::Stdio`], so the same `std::process::exit` requirement
510/// applies to callers.
511pub async fn serve(config: ServerConfig) -> Result<(), Error> {
512    serve_with(config, Transport::Stdio).await
513}
514
515/// Start the MCPLS server with an explicit transport.
516///
517/// Performs all shared setup (workspace discovery, LSP spawning, translator
518/// initialization, diagnostic pump tasks) and then delegates to the
519/// appropriate transport runner.
520///
521/// # Errors
522///
523/// Returns an error if:
524/// - All LSP servers fail to initialize
525/// - The MCP server or transport fails to start
526/// - Configuration is invalid, including two applicable `[[lsp_servers]]`
527///   entries whose per-tool routing is ambiguous in this workspace (shared
528///   routing identity, two catch-alls, or the same tool claimed by both) --
529///   see `config::ToolRouter::from_configs`
530///
531/// # DNS rebinding protection (HTTP transport)
532///
533/// When using `Transport::Http`, the underlying rmcp service validates the
534/// inbound `Host` header against an allowlist that defaults to loopback
535/// addresses only (`localhost`, `127.0.0.1`, `::1`). Requests with any other
536/// `Host` value are rejected with `421 Misdirected Request`.
537///
538/// If you bind to a non-loopback address (e.g. `0.0.0.0:3000`) and expose the
539/// service through a reverse proxy, the proxy must forward `Host: localhost`
540/// (or another loopback alias) to the mcpls process. Direct non-loopback
541/// access is intentionally blocked to prevent DNS-rebinding attacks.
542///
543/// # Shutdown
544///
545/// [`Transport::Stdio`] is backed by `tokio::io::stdin()`, which internally
546/// parks an uncancellable blocking-pool thread in a raw `read()` syscall
547/// that only returns on more input or EOF. If your `main` uses
548/// `#[tokio::main]` and simply returns after awaiting this function, the
549/// macro-generated runtime-shutdown wrapper blocks waiting for that thread
550/// -- hanging indefinitely on `SIGTERM`/`SIGINT` as long as the MCP
551/// client's stdin write end is still open, since that never triggers EOF.
552/// Call `std::process::exit` right after this function resolves instead of
553/// returning normally from `main`, as in the example below (see mcpls's own
554/// `mcpls-cli` binary; tracked as #308). This does not apply to
555/// `Transport::Http`, which never touches `tokio::io::stdin()`.
556///
557/// # Examples
558///
559/// ```rust,ignore
560/// use mcpls_core::{serve_with, Transport, ServerConfig};
561///
562/// #[tokio::main]
563/// async fn main() {
564///     let config = ServerConfig::load().expect("failed to load config");
565///     let exit_code = match serve_with(config, Transport::Stdio).await {
566///         Ok(()) => 0,
567///         Err(_) => 1,
568///     };
569///     // See "Shutdown" above: process::exit avoids a runtime-shutdown hang.
570///     std::process::exit(exit_code);
571/// }
572/// ```
573#[allow(clippy::too_many_lines)]
574pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
575    info!("Starting MCPLS server...");
576
577    // Registered before any other startup work -- including
578    // `spawn_lsp_servers_background` below, which spawns LSP child processes
579    // concurrently on another worker thread -- so a `SIGTERM`/`SIGINT`
580    // arriving during config validation, workspace-root heuristics, or LSP
581    // spawning is caught rather than hitting the OS's default disposition
582    // (immediate termination, orphaning any LSP child mid-spawn; see #270)
583    // and skipping the `shutdown()` cleanup below entirely. See
584    // `ShutdownSignal`'s docs for why this must be a single instance carried
585    // through by value rather than re-registered later.
586    let shutdown_signal = ShutdownSignal::new();
587
588    // `ServerConfig::load`/`load_from` already validate the TOML-loading
589    // path; this covers the other one -- a caller building `ServerConfig`
590    // programmatically (e.g. a library embedder) previously hit no
591    // diagnosable error here, only silent clamping at accessor level (e.g.
592    // `LspClient::request_timeout`). `serve` delegates to this function, so
593    // one call site here covers both public entry points (`serve` and
594    // `serve_with`); note this does mean a config loaded via the CLI's
595    // `load_from` -> `serve` path is validated twice (harmless -- `validate`
596    // is a pure check with no side effects beyond a `tracing::warn!` for a
597    // non-fatal duplicate-name case, which will simply log twice).
598    //
599    // Considered wrapping this in a `Validated<ServerConfig>` marker type to
600    // make "already validated" a compile-time guarantee instead of a runtime
601    // check here; rejected as unnecessary ceremony for a pre-1.0 API (#282).
602    config.validate()?;
603
604    // `current_dir()` always returns an absolute path. Configs loaded from a
605    // TOML file have already had relative roots rebased to that file's
606    // directory in `ServerConfig::load_from`; this second pass covers
607    // caller-built `ServerConfig`s, whose relative roots are defined against
608    // the process cwd. Only actually called when a root needs it (empty
609    // `roots`, which defaults to cwd, or at least one relative root): a
610    // fully-absolute `workspace.roots` must not fail startup just because
611    // cwd happens to be unreadable/removed (#348).
612    let workspace_roots = if config.workspace.roots.is_empty()
613        || config.workspace.roots.iter().any(|root| root.is_relative())
614    {
615        let workspace_base = std::env::current_dir().map_err(Error::Io)?;
616        resolve_workspace_roots(&config.workspace.roots, &workspace_base)?
617    } else {
618        // Every root is absolute already, so `base_dir` is never joined
619        // against inside `canonicalize_workspace_roots` -- pass an
620        // arbitrary placeholder rather than paying for `current_dir()`.
621        canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
622    };
623    let extension_map = config.build_effective_extension_map();
624    let max_depth = Some(config.workspace.heuristics_max_depth);
625
626    let applicable_configs: Vec<ServerInitConfig> = config
627        .lsp_servers
628        .iter()
629        .filter_map(|lsp_config| {
630            let should_spawn = workspace_roots
631                .iter()
632                .any(|root| lsp_config.should_spawn(root, max_depth));
633
634            if !should_spawn {
635                info!(
636                    "Skipping LSP server '{}' ({}): no project markers found",
637                    lsp_config.language_id, lsp_config.command
638                );
639                return None;
640            }
641
642            Some(ServerInitConfig {
643                server_config: lsp_config.clone(),
644                workspace_roots: workspace_roots.clone(),
645                initialization_options: lsp_config.initialization_options.clone(),
646                position_encodings: config.workspace.position_encodings.clone(),
647                notification_tx: None,
648            })
649        })
650        .collect();
651
652    info!(
653        "Attempting to spawn {} applicable LSP server(s)...",
654        applicable_configs.len()
655    );
656
657    // Built over the applicable (post-heuristics) configs only: this is where
658    // #174's workspace-scoped routing rules (duplicate ServerId, conflicting
659    // `handles` claims) are enforced -- a startup error naming the
660    // conflicting `[[lsp_servers]]` entries, not a silent drop.
661    let router = ToolRouter::from_configs(applicable_configs.iter().map(|c| &c.server_config))?;
662
663    // Built here (rather than alongside `subscription_registry`/`peer_cell` below) so
664    // it can be handed to the translator, which uses it to invalidate a
665    // respawned server's stale cached diagnostics -- see
666    // `Translator::with_notification_cache`. Independent of `translator`
667    // itself, which holds no outer lock: the pump only ever locks this
668    // cache, so it never contends with a request handler running an
669    // in-flight LSP round-trip.
670    let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
671
672    let mut translator = Translator::new()
673        .with_resource_limits(config.workspace.resource_limits())
674        .with_extensions(extension_map)
675        .with_router(router)
676        .with_notification_cache(Arc::clone(&notification_cache))
677        .with_indexing_ready_timeout(Duration::from_secs(
678            config.workspace.indexing_ready_timeout_seconds,
679        ));
680    // moved, not cloned -- `config`'s last use is above
681    let (project_config_ignored, mcp) = (config.project_config_ignored, config.mcp);
682    translator.set_workspace_roots(workspace_roots.clone());
683
684    // Mark applicable servers as "expected" so a tool call that arrives while
685    // its server is still initializing gets a clear "still initializing" error
686    // (instead of "no server configured"), telling the caller to wait and retry.
687    let expected_servers: HashSet<ServerId> = applicable_configs
688        .iter()
689        .map(|c| c.server_config.id())
690        .collect();
691    translator.set_expected_servers(expected_servers);
692
693    // Shared state, built BEFORE LSP initialization so the MCP server can answer
694    // `initialize` immediately. LSP servers (which can take minutes to initialize
695    // on a large solution, e.g. a 130-project Unity .sln via OmniSharp) are spawned
696    // in a background task and registered into this shared translator once ready.
697    // Blocking the MCP handshake on LSP init makes slow servers exceed the client's
698    // initialize-request timeout (Claude Code: ~60s) -> "Request timed out".
699    // Fixed for the server's lifetime: shared as a lock-free snapshot so
700    // cache-only handlers (e.g. `get_cached_diagnostics`, `read_resource`) can
701    // validate a path without locking `translator` below.
702    //
703    // `resolve_workspace_roots` canonicalizes before any consumer sees these
704    // paths. The snapshot can therefore stay allocation-only while preserving
705    // `diagnostic_path_in_workspace`'s canonical-root precondition and avoiding
706    // filesystem I/O on the hot per-notification path.
707    let workspace_roots_snapshot: Arc<[PathBuf]> = Arc::from(workspace_roots.clone());
708
709    let translator = Arc::new(translator);
710    // Shared across every session (one per HTTP session, or the sole stdio
711    // session): `McplsServer::new` and `McplsServer::for_new_session` each
712    // register a fresh, isolated `ResourceSubscriptions` set into it -- see
713    // `SubscriptionRegistry`.
714    let subscription_registry = SubscriptionRegistry::new();
715    // Peer cell is populated after the MCP transport is established (Phase B).
716    let peer_cell = Arc::new(OnceCell::new());
717
718    // Cancellation for pump tasks: send `true` to request shutdown.
719    let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
720
721    let lsp_init_handle = if applicable_configs.is_empty() {
722        warn!("No applicable LSP servers configured — starting in protocol-only mode");
723        None
724    } else {
725        info!(
726            "Spawning {} LSP server(s) in the background...",
727            applicable_configs.len()
728        );
729        Some(spawn_lsp_servers_background(
730            applicable_configs,
731            Arc::clone(&translator),
732            Arc::clone(&notification_cache),
733            subscription_registry.clone(),
734            Arc::clone(&peer_cell),
735            cancel_rx.clone(),
736            Arc::clone(&workspace_roots_snapshot),
737        ))
738    };
739
740    info!("Starting MCP server with rmcp...");
741    let mcp_server = mcp::McplsServer::new(
742        Arc::clone(&translator),
743        Arc::clone(&notification_cache),
744        Arc::clone(&workspace_roots_snapshot),
745        subscription_registry,
746        project_config_ignored,
747        mcp,
748    );
749    info!("MCPLS server initialized successfully");
750
751    let result = match transport {
752        Transport::Stdio => {
753            info!("Listening for MCP requests on stdio...");
754            run_stdio(mcp_server, &peer_cell, shutdown_signal).await
755        }
756        #[cfg(feature = "transport-http")]
757        Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
758    };
759
760    shutdown(&cancel_tx, &translator, lsp_init_handle).await;
761
762    info!("MCPLS server shutting down");
763    result
764}
765
766/// Bounds how long [`shutdown`] waits for the background LSP init task
767/// (see [`spawn_lsp_servers_background`]) to finish after cancellation is
768/// signaled. Deliberately shorter than [`Translator`]'s own per-server
769/// shutdown timeout: by the time `shutdown_servers` returns, every
770/// registered server's notification channel has closed, so the init task's
771/// diagnostics pumps should already be draining. This bound only matters
772/// for the rarer case where the init task is still mid-`initialize` (never
773/// registered anything for `shutdown_servers` to act on).
774const LSP_INIT_TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
775
776/// Awaits the background LSP init task's `JoinHandle` with a bounded
777/// `timeout`, logging a panic at `error` level (previously dropped
778/// silently, see #196) or an unresponsive task at `warn` level instead of
779/// letting either go unnoticed.
780///
781/// `timeout` is a parameter (rather than always
782/// [`LSP_INIT_TASK_SHUTDOWN_TIMEOUT`]) so tests can exercise the timeout
783/// branch without waiting out the real bound. Awaits `handle` by `&mut`
784/// (not by value): dropping an *owned* `JoinHandle` on timeout would only
785/// detach the task — it keeps running rather than stopping, contradicting
786/// the warning logged below. Retaining ownership lets `abort()` make that
787/// message true.
788///
789/// `abort()` only *requests* cancellation; the task's locals (which may own
790/// not-yet-registered `tokio::process::Child` handles for LSP servers
791/// [`spawn_lsp_servers_background`] is still spawning via `spawn_batch`,
792/// relying entirely on `kill_on_drop` to terminate them) are only actually
793/// dropped once the runtime polls the task to completion. `mcpls-cli`'s
794/// `main` calls `std::process::exit` right after `serve_with` returns (see
795/// #308), which skips the executor's own task teardown that used to do this
796/// polling implicitly — so this function awaits the aborted handle again,
797/// bounded, to drive that drop here instead of leaving it to chance.
798/// Otherwise a `SIGTERM` arriving mid-`spawn_batch` could orphan those LSP
799/// child processes, the exact failure mode #270 was filed to prevent.
800async fn await_lsp_init_handle(mut handle: JoinHandle<()>, timeout: Duration) {
801    match tokio::time::timeout(timeout, &mut handle).await {
802        Ok(Ok(())) => {}
803        Ok(Err(err)) => error!("Background LSP initialization task failed: {err}"),
804        Err(_) => {
805            warn!("Timed out waiting for background LSP initialization task to stop");
806            handle.abort();
807            let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
808        }
809    }
810}
811
812/// Aborts the wrapped [`JoinHandle`] when dropped, including on an unwind out
813/// of the enclosing scope — unlike a bare `.abort()` call placed at the end
814/// of a function body, which is skipped if that scope is left early (a
815/// panic, or a future `?` added above it).
816///
817/// `pub(crate)` (and its field along with it): also used by
818/// [`crate::lsp::client`]'s message loop to abort its background reader task
819/// (#451) -- see that module for the other caller.
820pub(crate) struct AbortOnDrop<'a, T>(pub(crate) &'a JoinHandle<T>);
821
822impl<T> Drop for AbortOnDrop<'_, T> {
823    fn drop(&mut self) {
824        self.0.abort();
825    }
826}
827
828/// Whether a shutdown signal caught during [`shutdown`]'s cleanup window
829/// should force an immediate `std::process::exit`, given how many such
830/// signals (including this one) have been received so far.
831///
832/// Extracted as a pure function, rather than inlined into the loop that
833/// calls it, so the threshold is unit-testable without actually invoking
834/// `std::process::exit` — which would tear down the test process itself
835/// under `cargo nextest` before any assertion could run.
836const fn should_escalate(repeat_signals: u32) -> bool {
837    repeat_signals >= 1
838}
839
840/// Post-transport shutdown sequence, run once the transport future
841/// (`run_stdio`/`run_http`) returns — whether that's because of a
842/// `SIGTERM`/`SIGINT`, stdio EOF, or (for HTTP) its own graceful shutdown.
843///
844/// Signals background pump tasks to exit, then gracefully shuts down every
845/// LSP server registered on `translator` (see
846/// [`Translator::shutdown_servers`] for what "gracefully" bounds and falls
847/// back to). Finally, if the background LSP init task (see
848/// [`spawn_lsp_servers_background`]) is still running, awaits it via
849/// [`await_lsp_init_handle`], giving its diagnostics pump tasks a chance to
850/// finish draining before `serve_with` returns. Extracted from
851/// [`serve_with`] so this sequence is exercised directly in tests without
852/// needing a full stdio/HTTP transport round trip.
853///
854/// # Signal handling during cleanup (#329)
855///
856/// The OS-level `SIGTERM`/`SIGINT` handler installed by [`ShutdownSignal::new`]
857/// stays installed for the rest of the process's life once registered —
858/// `tokio::signal` never uninstalls it, regardless of how many [`ShutdownSignal`]
859/// values are constructed or dropped. So dropping the instance built in
860/// `serve_with` and moved into `run_stdio`/`run_http` (which happens as soon
861/// as that transport function returns, right before this function runs)
862/// does *not* reopen a window where a repeat signal could hit the OS's
863/// default disposition. What it does instead: with no live [`ShutdownSignal`]
864/// subscribed, a signal delivered during `shutdown_servers`/
865/// `await_lsp_init_handle` (bounded by [`Translator::shutdown_servers`]'s own
866/// per-server timeout and [`LSP_INIT_TASK_SHUTDOWN_TIMEOUT`], ~15s worst
867/// case) is recorded and then silently discarded — there is no receiver to
868/// broadcast it to. Before this fix, that made cleanup **uninterruptible**:
869/// an operator's repeat `Ctrl-C`/`SIGTERM` during that window was a no-op
870/// short of `SIGKILL`.
871///
872/// This function re-registers a fresh `ShutdownSignal` first thing to give
873/// cleanup a listener again, restoring the ability to force-quit a stuck
874/// cleanup on request. A brief gap remains between the old registration's
875/// last live receiver dropping and this one subscribing, in which a signal
876/// can still be discarded the same way as before the fix — see the
877/// escalation behavior below for how that's bounded.
878///
879/// A signal caught here means "the operator wants out": the first one during
880/// cleanup ([`should_escalate`]) is logged and forces an immediate
881/// `std::process::exit(1)`, since the graceful default (waiting out
882/// `shutdown_servers`'s bounded timeouts) already had its chance before the
883/// operator intervened. This is deliberately not lenient — because a signal
884/// in the re-registration gap above is silently dropped rather than
885/// counted, requiring a second repeat before acting would let an unlucky
886/// operator's second press go unnoticed too. `exit(1)` skips unwinding, so
887/// it forfeits `Drop` (`kill_on_drop` on any still-running LSP child)
888/// exactly like the pre-existing panic/abort gap documented on
889/// [`Translator::shutdown_servers`]'s "Limitations" section — an explicit
890/// trade the operator is asking for, not a case this fix silently
891/// regresses.
892async fn shutdown(
893    cancel_tx: &tokio::sync::watch::Sender<bool>,
894    translator: &Translator,
895    lsp_init_handle: Option<JoinHandle<()>>,
896) {
897    let _ = cancel_tx.send(true);
898
899    let mut cleanup_signal = ShutdownSignal::new();
900    let force_exit_on_signal = tokio::spawn(async move {
901        let mut repeat_signals = 0u32;
902        loop {
903            cleanup_signal.recv().await;
904            repeat_signals += 1;
905            if should_escalate(repeat_signals) {
906                error!("shutdown signal received during cleanup, forcing immediate exit");
907                std::process::exit(1);
908            }
909        }
910    });
911    // Aborts `force_exit_on_signal` on every exit from this scope, including
912    // an unwind out of `shutdown_servers().await` below (debug builds only;
913    // release uses `panic = "abort"`) — otherwise that path would merely
914    // detach the task instead of stopping it, unlike the equivalent
915    // abort-on-timeout handling in `await_lsp_init_handle`.
916    let _abort_force_exit_on_signal = AbortOnDrop(&force_exit_on_signal);
917
918    info!("Shutting down LSP servers...");
919    translator.shutdown_servers().await;
920
921    if let Some(handle) = lsp_init_handle {
922        await_lsp_init_handle(handle, LSP_INIT_TASK_SHUTDOWN_TIMEOUT).await;
923    }
924}
925
926/// Spawn the applicable LSP servers in a background task and register them into
927/// the shared `translator` once ready.
928///
929/// This intentionally does NOT block the caller: `serve_with` starts the MCP
930/// server immediately so its `initialize` handshake returns before slow language
931/// servers (e.g. `OmniSharp` on a large Unity solution, which can take minutes to
932/// load) finish initializing. Tool calls that arrive before a server has
933/// registered return a `ServerInitializing` error telling the caller to wait and
934/// retry. If every server fails, the "expected servers" set is cleared so those
935/// calls fall back to a plain "no server configured" error instead.
936///
937/// Returns the task's `JoinHandle` so [`shutdown`] can await it: previously
938/// this handle was dropped, silently swallowing panics from
939/// `LspServer::spawn_batch`, `register_servers`, or a diagnostics pump task
940/// (see #196).
941fn spawn_lsp_servers_background(
942    applicable_configs: Vec<ServerInitConfig>,
943    translator: Arc<Translator>,
944    notification_cache: Arc<Mutex<NotificationCache>>,
945    subscription_registry: SubscriptionRegistry,
946    peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
947    cancel_rx: tokio::sync::watch::Receiver<bool>,
948    workspace_roots: Arc<[PathBuf]>,
949) -> JoinHandle<()> {
950    tokio::spawn(async move {
951        let configs_by_id: HashMap<ServerId, ServerInitConfig> = applicable_configs
952            .iter()
953            .map(|c| (c.server_config.id(), c.clone()))
954            .collect();
955        let result = LspServer::spawn_batch(&applicable_configs).await;
956
957        if result.all_failed() {
958            error!(
959                "All {} configured LSP server(s) failed to initialize",
960                result.failure_count()
961            );
962            for failure in &result.failures {
963                error!("Server initialization failed: {}", failure);
964            }
965            // No server will register: rebind against an empty registered
966            // set so every route drops (one rule, no special case -- see
967            // `ToolRouter::rebind_to_registered`), then stop reporting
968            // "still initializing". This path returns before
969            // `register_servers` ever runs, so it needs its own rebind call;
970            // skipping it would leave every route pointed at a dead server.
971            translator.rebind_router(&HashSet::new());
972            translator.clear_expected_servers();
973            return;
974        }
975
976        if result.partial_success() {
977            warn!(
978                "Partial server initialization: {} succeeded, {} failed",
979                result.server_count(),
980                result.failure_count()
981            );
982            for failure in &result.failures {
983                error!("Server initialization failed: {}", failure);
984            }
985        }
986
987        let server_count = result.server_count();
988        let registered = register_servers(result, &translator, &configs_by_id);
989        // Background initialization has completed; stop reporting "still
990        // initializing" (especially for servers that failed to spawn on
991        // partial success, which would otherwise return ServerInitializing
992        // forever instead of NoServerForLanguage/Tool).
993        translator.clear_expected_servers();
994        info!("Proceeding with {} LSP server(s)", server_count);
995
996        // Give each diagnostics-route server a fair share of the shared
997        // diagnostics cache budget now that the full set is known -- see
998        // `NotificationCache::set_diagnostics_route_count` (#266).
999        let diagnostics_route_count = registered
1000            .diagnostics_flags
1001            .values()
1002            .filter(|&&is_route| is_route)
1003            .count();
1004        {
1005            let mut cache = notification_cache.lock().await;
1006            cache.set_diagnostics_route_count(diagnostics_route_count);
1007            // Apply each server's IndexingPolicy (P4) before any pump starts, pinning Disabled to Unknown.
1008            for id in registered.receivers.keys() {
1009                if let Some(config) = configs_by_id.get(id) {
1010                    cache.set_indexing_policy(id.clone(), config.server_config.indexing);
1011                }
1012            }
1013        }
1014
1015        // Start diagnostics pump tasks now that servers are registered.
1016        let pump_shared = PumpShared {
1017            notification_cache,
1018            subs: subscription_registry,
1019            peer_cell,
1020            workspace_roots,
1021        };
1022        let mut pumps: JoinSet<()> = JoinSet::new();
1023        for (id, (rx, lifecycle_rx)) in registered.receivers {
1024            let caches_diagnostics = registered
1025                .diagnostics_flags
1026                .get(&id)
1027                .copied()
1028                .unwrap_or(false);
1029            pumps.spawn(diagnostics_pump(
1030                id,
1031                rx,
1032                lifecycle_rx,
1033                cancel_rx.clone(),
1034                caches_diagnostics,
1035                pump_shared.clone(),
1036            ));
1037        }
1038        while pumps.join_next().await.is_some() {}
1039    })
1040}
1041
1042/// Shared by any `#[cfg(test)]` module in this crate that needs to mutate
1043/// the process-wide working directory (`std::env::set_current_dir`). Such
1044/// tests must not run concurrently with each other or with any other test
1045/// that relies on cwd -- nextest runs each test in its own process, so this
1046/// only matters under a plain `cargo test`, but a single shared lock is what
1047/// makes that true across every module's tests in this crate, not just
1048/// within one module (#348).
1049#[cfg(test)]
1050#[allow(clippy::unwrap_used)]
1051mod test_support {
1052    use std::path::{Path, PathBuf};
1053    use std::sync::{Mutex, MutexGuard, PoisonError};
1054
1055    static CWD_LOCK: Mutex<()> = Mutex::new(());
1056
1057    /// RAII guard that serializes CWD-mutating tests behind [`CWD_LOCK`] and
1058    /// switches into `dir` for the guard's lifetime, restoring the original
1059    /// working directory on drop — including on an early return or panic.
1060    ///
1061    /// `pub`, not `pub(crate)`: this module is itself private (unexported),
1062    /// so `pub(crate)` on its items would be redundant -- see
1063    /// `clippy::redundant_pub_crate`. Still only reachable crate-internally
1064    /// via `crate::test_support::CwdGuard`, since the module isn't `pub`.
1065    pub struct CwdGuard {
1066        _lock: MutexGuard<'static, ()>,
1067        original_dir: PathBuf,
1068    }
1069
1070    impl CwdGuard {
1071        pub fn enter(dir: &Path) -> Self {
1072            let lock = CWD_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
1073            let original_dir = std::env::current_dir().unwrap();
1074            std::env::set_current_dir(dir).unwrap();
1075            Self {
1076                _lock: lock,
1077                original_dir,
1078            }
1079        }
1080    }
1081
1082    impl Drop for CwdGuard {
1083        fn drop(&mut self) {
1084            let restored = std::env::set_current_dir(&self.original_dir);
1085            // A failure here during an already-unwinding panic must not
1086            // panic again (double panic aborts the process, losing the
1087            // original failure's message). On the normal path, though,
1088            // silently swallowing this would leave the process cwd wrong
1089            // for every subsequent test with no diagnostic — panic loudly
1090            // instead, since that's exactly the failure mode this guard
1091            // exists to prevent.
1092            if !std::thread::panicking() {
1093                #[allow(clippy::expect_used)]
1094                restored.expect("CwdGuard failed to restore original working directory");
1095            }
1096        }
1097    }
1098
1099    #[cfg(test)]
1100    mod tests {
1101        use super::CwdGuard;
1102
1103        #[test]
1104        fn test_cwd_guard_restores_cwd_on_panic() {
1105            let original_dir = std::env::current_dir().unwrap();
1106            let tmp_dir = tempfile::TempDir::new().unwrap();
1107
1108            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1109                let _guard = CwdGuard::enter(tmp_dir.path());
1110                panic!("boom");
1111            }));
1112
1113            assert!(result.is_err());
1114            assert_eq!(std::env::current_dir().unwrap(), original_dir);
1115        }
1116    }
1117}
1118
1119#[cfg(test)]
1120#[allow(clippy::unwrap_used)]
1121mod tests {
1122    use bridge::{
1123        DEFAULT_INDEXING_READY_TIMEOUT_SECS, DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE,
1124    };
1125
1126    use super::*;
1127
1128    #[test]
1129    fn test_diagnostic_path_in_workspace_empty_roots_rejects_any_uri() {
1130        let uri: Uri = Uri::from("file:///anywhere/at/all.rs");
1131        assert!(!diagnostic_path_in_workspace(&uri, &[]));
1132    }
1133
1134    #[test]
1135    fn test_diagnostic_path_in_workspace_accepts_uri_under_root() {
1136        // `Url::to_file_path` on Windows requires the URL's first path
1137        // segment to be a drive letter; a Unix-style path with no drive
1138        // letter fails to convert at all (`uri_to_path` returns `None`),
1139        // trivially satisfying this assertion for the wrong reason. Use a
1140        // drive-letter path so the test actually exercises the prefix check
1141        // on every platform.
1142        #[cfg(windows)]
1143        let (root, uri_str) = (
1144            PathBuf::from(r"C:\workspace\project"),
1145            "file:///C:/workspace/project/src/main.rs",
1146        );
1147        #[cfg(not(windows))]
1148        let (root, uri_str) = (
1149            PathBuf::from("/workspace/project"),
1150            "file:///workspace/project/src/main.rs",
1151        );
1152        let uri: Uri = Uri::from(uri_str);
1153        assert!(diagnostic_path_in_workspace(&uri, &[root]));
1154    }
1155
1156    #[test]
1157    fn test_diagnostic_path_in_workspace_rejects_uri_outside_roots() {
1158        #[cfg(windows)]
1159        let (root, uri_str) = (
1160            PathBuf::from(r"C:\workspace\project"),
1161            "file:///C:/etc/passwd",
1162        );
1163        #[cfg(not(windows))]
1164        let (root, uri_str) = (PathBuf::from("/workspace/project"), "file:///etc/passwd");
1165        let uri: Uri = Uri::from(uri_str);
1166        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
1167    }
1168
1169    #[test]
1170    fn test_diagnostic_path_in_workspace_rejects_non_file_uri() {
1171        let root = PathBuf::from("/workspace/project");
1172        let uri: Uri = Uri::from("untitled:Untitled-1");
1173        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
1174    }
1175
1176    /// `Path::starts_with` is a lexical, component-wise comparison that does
1177    /// not resolve `.`/`..` — without an explicit check, a URI like
1178    /// `file:///workspace/project/../../etc/passwd` would lexically "start
1179    /// with" `/workspace/project` despite pointing outside it.
1180    #[test]
1181    fn test_diagnostic_path_in_workspace_rejects_parent_dir_traversal() {
1182        #[cfg(windows)]
1183        let (root, uri_str) = (
1184            PathBuf::from(r"C:\workspace\project"),
1185            "file:///C:/workspace/project/../../etc/passwd",
1186        );
1187        #[cfg(not(windows))]
1188        let (root, uri_str) = (
1189            PathBuf::from("/workspace/project"),
1190            "file:///workspace/project/../../etc/passwd",
1191        );
1192        let uri: Uri = Uri::from(uri_str);
1193        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
1194    }
1195
1196    #[test]
1197    fn test_canonicalize_workspace_roots_falls_back_on_nonexistent_absolute_path() {
1198        let temp_dir = tempfile::TempDir::new().unwrap();
1199        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1200        let missing = base.join("missing");
1201        let result = canonicalize_workspace_roots(std::slice::from_ref(&missing), &base).unwrap();
1202        assert_eq!(result, vec![missing]);
1203    }
1204
1205    #[test]
1206    fn test_canonicalize_workspace_roots_rejects_nonexistent_relative_path() {
1207        let temp_dir = tempfile::TempDir::new().unwrap();
1208        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1209        let missing = PathBuf::from("missing");
1210
1211        let err = canonicalize_workspace_roots(std::slice::from_ref(&missing), &base).unwrap_err();
1212
1213        let Error::InvalidConfig(message) = err else {
1214            panic!("expected InvalidConfig, got {err:?}");
1215        };
1216        assert!(message.contains("workspace root 'missing'"));
1217        assert!(message.contains(&base.display().to_string()));
1218    }
1219
1220    /// #234 round-3 regression: a symlinked workspace root must canonicalize
1221    /// to its real path, matching what LSP servers report in diagnostics --
1222    /// otherwise `diagnostic_path_in_workspace`'s uncanonicalized prefix check
1223    /// would silently drop every diagnostic for that workspace.
1224    #[test]
1225    #[cfg(unix)]
1226    fn test_canonicalize_workspace_roots_resolves_symlink() {
1227        use std::os::unix::fs::symlink;
1228
1229        use tempfile::TempDir;
1230
1231        let temp_dir = TempDir::new().unwrap();
1232        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1233        let real_dir = base.join("real");
1234        std::fs::create_dir(&real_dir).unwrap();
1235        let link_dir = base.join("link");
1236        symlink(&real_dir, &link_dir).unwrap();
1237
1238        let result = canonicalize_workspace_roots(&[link_dir], &base).unwrap();
1239        assert_eq!(result, vec![real_dir]);
1240    }
1241
1242    /// #348 case 3 (S2): direct, platform-independent test of
1243    /// `join_relative_root`'s `Component`-stripping logic. The bug it fixes
1244    /// (a root that's rooted-without-prefix, e.g. `\workspace`) only makes
1245    /// `Path::is_relative()` return `true` on Windows, so the end-to-end
1246    /// `#[cfg(windows)]` test below is the only one that reproduces the
1247    /// actual failure through the public call path -- but the underlying
1248    /// `Component` shape it strips (a leading `RootDir` with no preceding
1249    /// `Prefix`) is reproducible on any OS by calling the helper directly,
1250    /// bypassing the `is_relative()` gate that would otherwise route such
1251    /// input elsewhere on non-Windows hosts.
1252    #[test]
1253    fn test_join_relative_root_strips_leading_root_and_prefix_components() {
1254        let base = Path::new("/base/dir");
1255
1256        assert_eq!(
1257            join_relative_root(base, Path::new("/workspace")),
1258            PathBuf::from("/base/dir/workspace")
1259        );
1260        assert_eq!(
1261            join_relative_root(base, Path::new("/workspace/sub")),
1262            PathBuf::from("/base/dir/workspace/sub")
1263        );
1264        // An ordinary relative root (no leading `Prefix`/`RootDir`) is
1265        // unaffected -- equivalent to a plain `base_dir.join(root)`.
1266        assert_eq!(
1267            join_relative_root(base, Path::new("workspace")),
1268            PathBuf::from("/base/dir/workspace")
1269        );
1270        assert_eq!(
1271            join_relative_root(base, Path::new("..")),
1272            PathBuf::from("/base/dir/..")
1273        );
1274    }
1275
1276    /// #348 case 3: a configured root with no drive/UNC prefix (e.g.
1277    /// `\workspace`) is `Path::is_relative() == true` on Windows despite
1278    /// being rooted (`is_absolute()` requires a prefix there). Plain
1279    /// `base_dir.join(root)` would hit `PathBuf::push`'s "root without
1280    /// prefix" behavior and silently discard everything in `base_dir` past
1281    /// its own prefix -- only reproducible on Windows, since elsewhere a
1282    /// leading `/` either makes the root absolute (Unix) or isn't a
1283    /// separator at all.
1284    #[test]
1285    #[cfg(windows)]
1286    fn test_canonicalize_workspace_roots_windows_root_without_prefix() {
1287        let temp_dir = tempfile::TempDir::new().unwrap();
1288        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1289        let nested = base.join("workspace");
1290        std::fs::create_dir(&nested).unwrap();
1291
1292        let root = PathBuf::from(r"\workspace");
1293        assert!(root.is_relative());
1294
1295        let result = canonicalize_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
1296        assert_eq!(result, vec![nested]);
1297    }
1298
1299    /// #348 M2: a Windows drive-relative root (`C:workspace` -- a leading
1300    /// `Component::Prefix` with no `RootDir`) is also `is_relative() ==
1301    /// true`. Plain `base_dir.join(root)` would hit `PathBuf::push`'s
1302    /// "has a prefix" special case and discard `base_dir` entirely instead
1303    /// of joining under it -- the same class of failure as the
1304    /// rooted-without-prefix case above, via a prefix instead of a root
1305    /// separator. `join_relative_root` deliberately does not replicate
1306    /// native Windows drive-relative resolution (which resolves against
1307    /// that drive's own current directory); it joins under `base_dir`
1308    /// instead, consistent with every other relative root.
1309    #[test]
1310    #[cfg(windows)]
1311    fn test_canonicalize_workspace_roots_windows_drive_relative_root() {
1312        let temp_dir = tempfile::TempDir::new().unwrap();
1313        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1314        let nested = base.join("workspace");
1315        std::fs::create_dir(&nested).unwrap();
1316
1317        // Built from `base`'s own drive prefix so the test doesn't depend on
1318        // which drive CI happens to check the repo out onto.
1319        let drive_prefix = base
1320            .components()
1321            .find_map(|c| match c {
1322                Component::Prefix(p) => Some(p.as_os_str().to_owned()),
1323                _ => None,
1324            })
1325            .expect("temp dir path should have a Windows drive prefix");
1326        let mut root = drive_prefix;
1327        root.push("workspace");
1328        let root = PathBuf::from(root);
1329        assert!(root.is_relative());
1330
1331        let result = canonicalize_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
1332        assert_eq!(result, vec![nested]);
1333    }
1334
1335    #[test]
1336    fn test_resolve_workspace_roots_empty_config() {
1337        let cwd = std::env::current_dir().unwrap();
1338        let roots = resolve_workspace_roots(&[], &cwd).unwrap();
1339        assert_eq!(roots.len(), 1);
1340        assert!(
1341            roots[0].is_absolute(),
1342            "Workspace root should be absolute path"
1343        );
1344    }
1345
1346    #[test]
1347    fn test_resolve_workspace_roots_with_config() {
1348        let temp_dir = tempfile::TempDir::new().unwrap();
1349        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1350        let root = base.join("root");
1351        std::fs::create_dir(&root).unwrap();
1352
1353        let roots = resolve_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
1354        assert_eq!(roots, vec![root]);
1355    }
1356
1357    #[test]
1358    fn test_resolve_workspace_roots_multiple_paths() {
1359        let temp_dir = tempfile::TempDir::new().unwrap();
1360        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1361        let config_roots = vec![base.join("root1"), base.join("root2")];
1362        for root in &config_roots {
1363            std::fs::create_dir(root).unwrap();
1364        }
1365
1366        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1367        assert_eq!(roots, config_roots);
1368        assert_eq!(roots.len(), 2);
1369    }
1370
1371    #[test]
1372    fn test_resolve_workspace_roots_preserves_order() {
1373        let temp_dir = tempfile::TempDir::new().unwrap();
1374        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1375        let config_roots = vec![base.join("alpha"), base.join("beta"), base.join("gamma")];
1376        for root in &config_roots {
1377            std::fs::create_dir(root).unwrap();
1378        }
1379
1380        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1381        assert_eq!(roots, config_roots);
1382    }
1383
1384    #[test]
1385    fn test_resolve_workspace_roots_single_path() {
1386        let temp_dir = tempfile::TempDir::new().unwrap();
1387        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1388        let root = base.join("workspace");
1389        std::fs::create_dir(&root).unwrap();
1390
1391        let roots = resolve_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
1392        assert_eq!(roots.len(), 1);
1393        assert_eq!(roots[0], root);
1394    }
1395
1396    #[test]
1397    fn test_resolve_workspace_roots_empty_returns_cwd() {
1398        let cwd = std::env::current_dir().unwrap();
1399        let roots = resolve_workspace_roots(&[], &cwd).unwrap();
1400        assert_eq!(roots, vec![dunce::canonicalize(cwd).unwrap()]);
1401    }
1402
1403    #[test]
1404    fn test_resolve_workspace_roots_relative_paths() {
1405        let temp_dir = tempfile::TempDir::new().unwrap();
1406        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1407        let config_roots = vec![
1408            PathBuf::from("relative/path1"),
1409            PathBuf::from("relative/path2"),
1410        ];
1411        for root in &config_roots {
1412            std::fs::create_dir_all(base.join(root)).unwrap();
1413        }
1414
1415        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1416        assert_eq!(
1417            roots,
1418            vec![base.join("relative/path1"), base.join("relative/path2")]
1419        );
1420        assert!(roots.iter().all(|root| root.is_absolute()));
1421    }
1422
1423    #[test]
1424    fn test_resolve_workspace_roots_mixed_paths() {
1425        let temp_dir = tempfile::TempDir::new().unwrap();
1426        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1427        let absolute = base.join("absolute");
1428        let relative = PathBuf::from("relative/path");
1429        std::fs::create_dir(&absolute).unwrap();
1430        std::fs::create_dir_all(base.join(&relative)).unwrap();
1431        let config_roots = vec![absolute.clone(), relative];
1432        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1433        assert_eq!(roots.len(), 2);
1434        assert_eq!(roots[0], absolute);
1435        assert_eq!(roots[1], base.join("relative/path"));
1436        assert!(roots.iter().all(|root| root.is_absolute()));
1437    }
1438
1439    /// #348 case 1: `serve_with` skips `std::env::current_dir()` entirely
1440    /// for a fully-absolute `workspace.roots`, passing an unused placeholder
1441    /// base directory straight to `canonicalize_workspace_roots` instead of
1442    /// `resolve_workspace_roots`. Confirms that placeholder is never
1443    /// dereferenced when every root is already absolute.
1444    #[test]
1445    fn test_canonicalize_workspace_roots_ignores_base_dir_when_all_absolute() {
1446        let temp_dir = tempfile::TempDir::new().unwrap();
1447        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1448        let root = base.join("root");
1449        std::fs::create_dir(&root).unwrap();
1450
1451        let result =
1452            canonicalize_workspace_roots(std::slice::from_ref(&root), Path::new("")).unwrap();
1453        assert_eq!(result, vec![root]);
1454    }
1455
1456    #[test]
1457    fn test_resolve_workspace_roots_with_dot_path() {
1458        let temp_dir = tempfile::TempDir::new().unwrap();
1459        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1460        let config_roots = vec![PathBuf::from(".")];
1461        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1462        assert_eq!(roots, vec![base]);
1463        assert!(roots[0].is_absolute());
1464    }
1465
1466    #[test]
1467    fn test_resolve_workspace_roots_with_parent_path() {
1468        let temp_dir = tempfile::TempDir::new().unwrap();
1469        let parent = dunce::canonicalize(temp_dir.path()).unwrap();
1470        let base = parent.join("nested");
1471        std::fs::create_dir(&base).unwrap();
1472        let config_roots = vec![PathBuf::from("..")];
1473        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1474        assert_eq!(roots.len(), 1);
1475        assert_eq!(roots[0], parent);
1476        assert!(roots[0].is_absolute());
1477    }
1478
1479    #[test]
1480    fn test_resolve_workspace_roots_unicode_paths() {
1481        let temp_dir = tempfile::TempDir::new().unwrap();
1482        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1483        let config_roots = vec![
1484            PathBuf::from("workspace/テスト"),
1485            PathBuf::from("workspace/тест"),
1486        ];
1487        for root in &config_roots {
1488            std::fs::create_dir_all(base.join(root)).unwrap();
1489        }
1490
1491        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1492        assert_eq!(roots.len(), 2);
1493        assert_eq!(roots[0], base.join("workspace/テスト"));
1494        assert_eq!(roots[1], base.join("workspace/тест"));
1495    }
1496
1497    #[test]
1498    fn test_resolve_workspace_roots_spaces_in_paths() {
1499        let temp_dir = tempfile::TempDir::new().unwrap();
1500        let base = dunce::canonicalize(temp_dir.path()).unwrap();
1501        let config_roots = vec![
1502            PathBuf::from("workspace/path with spaces"),
1503            PathBuf::from("another path/workspace"),
1504        ];
1505        for root in &config_roots {
1506            std::fs::create_dir_all(base.join(root)).unwrap();
1507        }
1508
1509        let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
1510        assert_eq!(roots.len(), 2);
1511        assert_eq!(roots[0], base.join("workspace/path with spaces"));
1512        assert_eq!(roots[1], base.join("another path/workspace"));
1513    }
1514
1515    // Tests for graceful degradation behavior
1516    mod graceful_degradation_tests {
1517        use super::*;
1518        use crate::error::ServerSpawnFailure;
1519        use crate::lsp::ServerInitResult;
1520
1521        #[test]
1522        fn test_all_servers_failed_error_handling() {
1523            let mut result = ServerInitResult::new();
1524            result.add_failure(ServerSpawnFailure {
1525                server_id: ServerId::from("rust"),
1526                language_id: "rust".to_string(),
1527                command: "rust-analyzer".to_string(),
1528                message: "not found".to_string(),
1529            });
1530            result.add_failure(ServerSpawnFailure {
1531                server_id: ServerId::from("python"),
1532                language_id: "python".to_string(),
1533                command: "pyright".to_string(),
1534                message: "not found".to_string(),
1535            });
1536
1537            assert!(result.all_failed());
1538            assert_eq!(result.failure_count(), 2);
1539            assert_eq!(result.server_count(), 0);
1540        }
1541
1542        #[test]
1543        fn test_partial_success_detection() {
1544            use std::collections::HashMap;
1545
1546            let mut result = ServerInitResult::new();
1547            // Simulate one success and one failure
1548            result.servers = HashMap::new(); // Would have a real server in production
1549            result.add_failure(ServerSpawnFailure {
1550                server_id: ServerId::from("python"),
1551                language_id: "python".to_string(),
1552                command: "pyright".to_string(),
1553                message: "not found".to_string(),
1554            });
1555
1556            // Without actual servers, we can verify the failure was recorded
1557            assert_eq!(result.failure_count(), 1);
1558            assert_eq!(result.server_count(), 0);
1559        }
1560
1561        #[test]
1562        fn test_all_servers_succeeded_detection() {
1563            use std::collections::HashMap;
1564
1565            let mut result = ServerInitResult::new();
1566            result.servers = HashMap::new(); // Would have real servers in production
1567
1568            assert_eq!(result.failure_count(), 0);
1569            assert!(!result.all_failed());
1570            assert!(!result.partial_success());
1571        }
1572
1573        #[test]
1574        fn test_all_servers_failed_to_init_error() {
1575            let failures = vec![
1576                ServerSpawnFailure {
1577                    server_id: ServerId::from("rust"),
1578                    language_id: "rust".to_string(),
1579                    command: "rust-analyzer".to_string(),
1580                    message: "command not found".to_string(),
1581                },
1582                ServerSpawnFailure {
1583                    server_id: ServerId::from("python"),
1584                    language_id: "python".to_string(),
1585                    command: "pyright".to_string(),
1586                    message: "permission denied".to_string(),
1587                },
1588            ];
1589
1590            let err = Error::AllServersFailedToInit { count: 2, failures };
1591
1592            assert!(err.to_string().contains("all LSP servers failed"));
1593            assert!(err.to_string().contains("2 configured"));
1594
1595            // Verify failures are preserved
1596            if let Error::AllServersFailedToInit { count, failures: f } = err {
1597                assert_eq!(count, 2);
1598                assert_eq!(f.len(), 2);
1599                assert_eq!(f[0].language_id, "rust");
1600                assert_eq!(f[1].language_id, "python");
1601            } else {
1602                panic!("Expected AllServersFailedToInit error");
1603            }
1604        }
1605
1606        #[test]
1607        fn test_graceful_degradation_with_empty_config() {
1608            let result = ServerInitResult::new();
1609
1610            // Empty config means no servers configured
1611            assert!(!result.all_failed());
1612            assert!(!result.partial_success());
1613            assert!(!result.has_servers());
1614            assert_eq!(result.server_count(), 0);
1615            assert_eq!(result.failure_count(), 0);
1616        }
1617
1618        #[test]
1619        fn test_server_spawn_failure_display() {
1620            let failure = ServerSpawnFailure {
1621                server_id: ServerId::from("typescript"),
1622                language_id: "typescript".to_string(),
1623                command: "tsserver".to_string(),
1624                message: "executable not found in PATH".to_string(),
1625            };
1626
1627            let display = failure.to_string();
1628            assert!(display.contains("typescript"));
1629            assert!(display.contains("tsserver"));
1630            assert!(display.contains("executable not found"));
1631        }
1632
1633        #[test]
1634        fn test_result_helpers_consistency() {
1635            let mut result = ServerInitResult::new();
1636
1637            // Initially empty
1638            assert!(!result.has_servers());
1639            assert!(!result.all_failed());
1640            assert!(!result.partial_success());
1641
1642            // Add a failure
1643            result.add_failure(ServerSpawnFailure {
1644                server_id: ServerId::from("go"),
1645                language_id: "go".to_string(),
1646                command: "gopls".to_string(),
1647                message: "error".to_string(),
1648            });
1649
1650            assert!(result.all_failed());
1651            assert!(!result.has_servers());
1652            assert!(!result.partial_success());
1653        }
1654
1655        #[tokio::test]
1656        async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
1657            use crate::config::{LspServerConfig, WorkspaceConfig};
1658
1659            // A configured server whose command cannot spawn used to make serve()
1660            // fail synchronously with NoServersAvailable / AllServersFailedToInit.
1661            // LSP initialization now runs in a background task so the MCP
1662            // `initialize` handshake is never blocked, which means the spawn
1663            // failure is handled in the background instead: serve() starts the MCP
1664            // server in degraded mode (mirroring `test_serve_starts_with_empty_config`)
1665            // rather than failing fast. Any error it surfaces must therefore be a
1666            // transport/MCP error from the closed test connection, NOT a fail-fast
1667            // server-availability error.
1668            let config = ServerConfig {
1669                mcp: crate::config::McpConfig::default(),
1670                workspace: WorkspaceConfig {
1671                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1672                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1673                    language_extensions: vec![],
1674                    heuristics_max_depth: 10,
1675                    max_documents: DEFAULT_MAX_DOCUMENTS,
1676                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1677                    indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
1678                },
1679                lsp_servers: vec![LspServerConfig {
1680                    language_id: "rust".to_string(),
1681                    command: "nonexistent-command-that-will-fail-12345".to_string(),
1682                    args: vec![],
1683                    env: std::collections::HashMap::new(),
1684                    file_patterns: vec!["**/*.rs".to_string()],
1685                    initialization_options: None,
1686                    timeout_seconds: 10,
1687                    request_timeout_seconds: 10,
1688                    heuristics: None,
1689                    name: None,
1690                    handles: None,
1691                    indexing: crate::bridge::IndexingPolicy::Auto,
1692                }],
1693                project_config_ignored: false,
1694            };
1695
1696            // serve() proceeds to run the MCP server and blocks on the stdio
1697            // transport until EOF; bound it so the test can't hang if stdin stays
1698            // open (e.g. under multi-threaded `cargo test`, where several serve()
1699            // tests share the process stdin).
1700            let outcome =
1701                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1702
1703            match outcome {
1704                // Still serving after the deadline => it did not fail fast. Good.
1705                Err(_elapsed) => {}
1706                // Transport closed cleanly. Also fine.
1707                Ok(Ok(())) => {}
1708                // It returned an error: it must not be a fail-fast availability error.
1709                Ok(Err(err)) => assert!(
1710                    !matches!(err, Error::NoServersAvailable(_))
1711                        && !matches!(err, Error::AllServersFailedToInit { .. }),
1712                    "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
1713                ),
1714            }
1715        }
1716
1717        #[tokio::test]
1718        async fn test_serve_starts_with_empty_config() {
1719            use crate::config::WorkspaceConfig;
1720
1721            // Server starts in protocol-only mode when no LSP servers are configured.
1722            // serve() blocks until the MCP transport closes, so it will error with a
1723            // connection/transport error — not NoServersAvailable.
1724            let config = ServerConfig {
1725                mcp: crate::config::McpConfig::default(),
1726                workspace: WorkspaceConfig {
1727                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1728                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1729                    language_extensions: vec![],
1730                    heuristics_max_depth: 10,
1731                    max_documents: DEFAULT_MAX_DOCUMENTS,
1732                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1733                    indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
1734                },
1735                lsp_servers: vec![],
1736                project_config_ignored: false,
1737            };
1738
1739            let result = serve(config).await;
1740
1741            // serve() may succeed or fail with a transport error, but must NOT
1742            // return NoServersAvailable when the config simply has no servers.
1743            if let Err(ref err) = result {
1744                assert!(
1745                    !matches!(err, Error::NoServersAvailable(_)),
1746                    "serve() must not return NoServersAvailable for empty lsp_servers config"
1747                );
1748            }
1749        }
1750
1751        /// #348 case 1 (tester-flagged coverage gap): proves `serve_with`
1752        /// itself skips `current_dir()` for an all-absolute
1753        /// `workspace.roots`, not just that `canonicalize_workspace_roots`
1754        /// tolerates an unused base when called directly (see
1755        /// `test_canonicalize_workspace_roots_ignores_base_dir_when_all_absolute`
1756        /// in the outer `tests` module, which never exercises `serve_with`'s
1757        /// branch selection and would still pass if that `if` were inverted
1758        /// or deleted). Mutates the process cwd (chdir into a directory,
1759        /// then remove it -- `current_dir()` reliably fails afterward on
1760        /// Unix), so it uses the crate-shared `test_support::CwdGuard` --
1761        /// the same lock/restore-on-drop `config::tests` uses -- rather than
1762        /// a one-off guard, since both modules' tests mutate cwd and compile
1763        /// into one binary. Unix-only since removing a directory that is
1764        /// still a live process's cwd is a Windows-specific error case, not
1765        /// the same reproducible `current_dir()` failure.
1766        #[tokio::test]
1767        #[cfg(unix)]
1768        async fn test_serve_with_all_absolute_roots_skips_current_dir() {
1769            use crate::config::WorkspaceConfig;
1770            use crate::test_support::CwdGuard;
1771
1772            // Kept alive for the whole test so the configured workspace root
1773            // stays a valid, existing absolute directory distinct from the
1774            // cwd this test is about to remove.
1775            let workspace_root_dir = tempfile::TempDir::new().unwrap();
1776            let workspace_root = dunce::canonicalize(workspace_root_dir.path()).unwrap();
1777
1778            let doomed_cwd = tempfile::TempDir::new().unwrap();
1779            let _guard = CwdGuard::enter(doomed_cwd.path());
1780            doomed_cwd.close().unwrap();
1781
1782            let config = ServerConfig {
1783                mcp: crate::config::McpConfig::default(),
1784                workspace: WorkspaceConfig {
1785                    roots: vec![workspace_root],
1786                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1787                    language_extensions: vec![],
1788                    heuristics_max_depth: 10,
1789                    max_documents: DEFAULT_MAX_DOCUMENTS,
1790                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1791                    indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
1792                },
1793                lsp_servers: vec![],
1794                project_config_ignored: false,
1795            };
1796
1797            // serve() with no LSP servers configured blocks on the stdio
1798            // transport, same as `test_serve_starts_with_empty_config`;
1799            // bound it so the test can't hang.
1800            let outcome =
1801                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1802
1803            match outcome {
1804                // Still serving after the deadline => it did not fail fast. Good.
1805                Err(_elapsed) => {}
1806                // Transport closed cleanly. Also fine.
1807                Ok(Ok(())) => {}
1808                // It returned an error: it must not be the `current_dir()`
1809                // failure this test set up (`ErrorKind::NotFound` from the
1810                // removed cwd). Narrowed to that specific `io::ErrorKind`
1811                // rather than any `Error::Io`, since the latter would also
1812                // match an unrelated IO error from the stdio transport
1813                // within the timeout window.
1814                Ok(Err(err)) => assert!(
1815                    !matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
1816                    "serve() must not need a working process cwd for an all-absolute \
1817                     workspace.roots; got: {err:?}"
1818                ),
1819            }
1820        }
1821
1822        /// #282: a `ServerConfig` built programmatically (not via `load`/
1823        /// `load_from`, which already run `validate()`) previously skipped
1824        /// validation entirely, so `serve`/`serve_with` never rejected it —
1825        /// misconfiguration only surfaced later as silent accessor-level
1826        /// clamping. `serve` delegates straight to `serve_with`, so
1827        /// exercising it here also covers `serve_with`'s own `validate()`
1828        /// call. `validate()` runs before any LSP spawn or transport setup,
1829        /// so this returns immediately without needing a timeout guard.
1830        #[tokio::test]
1831        async fn test_serve_rejects_invalid_caller_supplied_config() {
1832            use crate::config::{LspServerConfig, WorkspaceConfig};
1833
1834            let config = ServerConfig {
1835                mcp: crate::config::McpConfig::default(),
1836                workspace: WorkspaceConfig {
1837                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1838                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1839                    language_extensions: vec![],
1840                    heuristics_max_depth: 10,
1841                    max_documents: DEFAULT_MAX_DOCUMENTS,
1842                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1843                    indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
1844                },
1845                lsp_servers: vec![LspServerConfig {
1846                    language_id: "rust".to_string(),
1847                    command: String::new(),
1848                    args: vec![],
1849                    env: std::collections::HashMap::new(),
1850                    file_patterns: vec!["**/*.rs".to_string()],
1851                    initialization_options: None,
1852                    timeout_seconds: 10,
1853                    request_timeout_seconds: 10,
1854                    heuristics: None,
1855                    name: None,
1856                    handles: None,
1857                    indexing: crate::bridge::IndexingPolicy::Auto,
1858                }],
1859                project_config_ignored: false,
1860            };
1861
1862            // `validate()` runs before any spawn/transport work and should
1863            // return immediately; bound it anyway so a regression that lets
1864            // an invalid config reach the stdio transport fails fast with a
1865            // clear timeout instead of hanging nextest for the default 120s
1866            // (mirroring the guard on `test_serve_degrades_when_all_servers_fail_to_spawn`).
1867            let outcome =
1868                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1869
1870            match outcome {
1871                Err(elapsed) => panic!(
1872                    "serve() must reject the invalid config immediately, not hang until \
1873                     timeout: {elapsed}"
1874                ),
1875                Ok(result) => assert!(
1876                    matches!(result, Err(Error::InvalidConfig(_))),
1877                    "serve() must reject a caller-supplied config with an empty `command` via \
1878                     Error::InvalidConfig, matching the load_from path; got: {result:?}"
1879                ),
1880            }
1881        }
1882
1883        /// #241: `serve_with`'s post-transport shutdown sequence must drain
1884        /// registered LSP servers rather than orphaning them. Exercises
1885        /// `shutdown()` directly (the exact code `serve_with` runs after its
1886        /// transport future returns) against a `Translator` with a real,
1887        /// registered `LspServer` — `serve_with` itself can't be driven
1888        /// through this path in a portable unit test, since it only
1889        /// registers a server after a successful LSP `initialize` handshake,
1890        /// which requires a real language server binary.
1891        #[tokio::test]
1892        async fn test_shutdown_drains_registered_lsp_server() {
1893            let translator = Translator::new();
1894            translator.register_server("fake-server", crate::lsp::fake_lsp_server());
1895            assert_eq!(translator.registered_server_count(), 1);
1896
1897            let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
1898
1899            let result = tokio::time::timeout(
1900                std::time::Duration::from_secs(20),
1901                super::super::shutdown(&cancel_tx, &translator, None),
1902            )
1903            .await;
1904
1905            assert!(
1906                result.is_ok(),
1907                "shutdown must not hang against a non-responsive mock LSP server"
1908            );
1909            assert_eq!(
1910                translator.registered_server_count(),
1911                0,
1912                "shutdown must drain every registered LSP server"
1913            );
1914            assert!(
1915                *cancel_rx.borrow(),
1916                "shutdown must signal background pump tasks to exit"
1917            );
1918        }
1919
1920        /// #196: `shutdown` must await the background LSP init task's
1921        /// `JoinHandle` (rather than leaving it detached) so a panic inside
1922        /// it surfaces as an `error!` log instead of being silently dropped.
1923        #[tokio::test]
1924        async fn test_shutdown_awaits_background_init_task() {
1925            use std::sync::atomic::{AtomicBool, Ordering};
1926
1927            let translator = Translator::new();
1928            let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
1929
1930            let completed = Arc::new(AtomicBool::new(false));
1931            let completed_clone = Arc::clone(&completed);
1932            let handle = tokio::spawn(async move {
1933                completed_clone.store(true, Ordering::SeqCst);
1934            });
1935
1936            let result = tokio::time::timeout(
1937                std::time::Duration::from_secs(5),
1938                super::super::shutdown(&cancel_tx, &translator, Some(handle)),
1939            )
1940            .await;
1941
1942            assert!(result.is_ok(), "shutdown must not hang on a live handle");
1943            assert!(
1944                completed.load(Ordering::SeqCst),
1945                "shutdown must await the background init task before returning"
1946            );
1947        }
1948
1949        /// A timed-out background init task must actually be stopped
1950        /// (`JoinHandle::abort`), not merely detached: awaiting the handle
1951        /// *by value* inside `tokio::time::timeout` would drop only the
1952        /// `JoinHandle` on timeout, which detaches the task without
1953        /// cancelling it — it keeps running (and its future is never
1954        /// dropped) despite the "timed out waiting ... to stop" log.
1955        ///
1956        /// Tests `await_lsp_init_handle` directly with a millisecond-scale
1957        /// `timeout` (rather than going through `shutdown` with the real
1958        /// multi-second `LSP_INIT_TASK_SHUTDOWN_TIMEOUT`) so this stays
1959        /// fast. A `completed`-style flag set at the end of the task
1960        /// couldn't tell "aborted" from "merely detached" apart here either
1961        /// way, since the task hasn't finished its (deliberately long)
1962        /// sleep yet in both cases — so this uses a `Drop`-signaling guard
1963        /// held across the `.await` instead: `abort()` drops the task's
1964        /// future promptly (well inside the grace period below), while a
1965        /// detached-but-still-running task would only drop it once its
1966        /// sleep actually finishes.
1967        #[tokio::test]
1968        async fn test_await_lsp_init_handle_aborts_on_timeout() {
1969            use std::sync::atomic::{AtomicBool, Ordering};
1970
1971            struct DropFlag(Arc<AtomicBool>);
1972            impl Drop for DropFlag {
1973                fn drop(&mut self) {
1974                    self.0.store(true, Ordering::SeqCst);
1975                }
1976            }
1977
1978            let future_dropped = Arc::new(AtomicBool::new(false));
1979            let guard = DropFlag(Arc::clone(&future_dropped));
1980            let handle = tokio::spawn(async move {
1981                let _guard = guard;
1982                // Far longer than the timeout below, so it only elapses if
1983                // the task is genuinely aborted rather than left running.
1984                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1985            });
1986
1987            super::super::await_lsp_init_handle(handle, std::time::Duration::from_millis(20)).await;
1988
1989            // Give the just-aborted task's cancellation a moment to land.
1990            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1991            assert!(
1992                future_dropped.load(Ordering::SeqCst),
1993                "timed-out background init task's future must be dropped via abort(), \
1994                 not left running detached until its own sleep completes"
1995            );
1996        }
1997
1998        /// #196: a panicking background init task must not hang or crash
1999        /// `shutdown`, and the panic must actually be logged (not merely
2000        /// swallowed while `shutdown` happens not to hang for other
2001        /// reasons) — asserted via a captured `tracing` event rather than
2002        /// just checking completion.
2003        #[tokio::test]
2004        async fn test_await_lsp_init_handle_logs_panic() {
2005            use tracing_subscriber::layer::SubscriberExt as _;
2006
2007            use crate::test_lsp::CapturedLogs;
2008
2009            let handle = tokio::spawn(async {
2010                panic!("simulated background LSP init panic");
2011            });
2012
2013            let captured = CapturedLogs::default();
2014            let subscriber = tracing_subscriber::registry().with(captured.clone());
2015            let guard = tracing::subscriber::set_default(subscriber);
2016
2017            super::super::await_lsp_init_handle(handle, std::time::Duration::from_secs(5)).await;
2018
2019            drop(guard);
2020
2021            let messages = captured.messages();
2022            assert!(
2023                messages
2024                    .iter()
2025                    .any(|m| m.contains("Background LSP initialization task failed")),
2026                "expected an error! log for the panicking background init task, got: {messages:?}"
2027            );
2028        }
2029    }
2030
2031    // ------------------------------------------------------------------
2032    // diagnostics_pump unit tests
2033    // ------------------------------------------------------------------
2034
2035    #[allow(clippy::unwrap_used, clippy::expect_used)]
2036    mod pump_tests {
2037        use lsp_types::{PublishDiagnosticsParams, Uri};
2038        use tokio::sync::{mpsc, watch};
2039
2040        use super::*;
2041        use crate::bridge::IndexingState;
2042
2043        fn make_cache() -> Arc<Mutex<NotificationCache>> {
2044            Arc::new(Mutex::new(NotificationCache::new()))
2045        }
2046
2047        fn make_subs() -> SubscriptionRegistry {
2048            SubscriptionRegistry::new()
2049        }
2050
2051        type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
2052
2053        fn make_peer_cell() -> PeerCell {
2054            Arc::new(OnceCell::new())
2055        }
2056
2057        /// A single real workspace root shared by the pump-mechanics tests
2058        /// below, cfg-gated because `Url::to_file_path` requires a drive
2059        /// letter on Windows -- mirrors
2060        /// `test_pump_drops_diagnostics_outside_workspace_roots`.
2061        #[cfg(windows)]
2062        fn test_workspace_roots() -> Arc<[PathBuf]> {
2063            Arc::from([PathBuf::from(r"C:\test")])
2064        }
2065        #[cfg(not(windows))]
2066        fn test_workspace_roots() -> Arc<[PathBuf]> {
2067            Arc::from([PathBuf::from("/test")])
2068        }
2069
2070        /// A `file://` URI for `file` beneath [`test_workspace_roots`]'s root.
2071        #[cfg(windows)]
2072        fn test_uri(file: &str) -> Uri {
2073            Uri::from(format!("file:///C:/test/{file}").as_str())
2074        }
2075        #[cfg(not(windows))]
2076        fn test_uri(file: &str) -> Uri {
2077            Uri::from(format!("file:///test/{file}").as_str())
2078        }
2079
2080        /// `PublishDiagnostics` is cached even when the peer is not yet connected.
2081        #[tokio::test]
2082        async fn test_pump_caches_before_peer_set() {
2083            let cache = make_cache();
2084            let subs = make_subs();
2085            let peer_cell = make_peer_cell();
2086            let (tx, rx) = mpsc::channel(8);
2087            let (_lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2088            // Keep _cancel_tx alive: dropping it causes cancel_rx.changed() to return Err,
2089            // which makes the pump exit before processing any messages.
2090            let (_cancel_tx, cancel_rx) = watch::channel(false);
2091
2092            let c = Arc::clone(&cache);
2093            tokio::spawn(diagnostics_pump(
2094                ServerId::from("rust"),
2095                rx,
2096                lifecycle_rx,
2097                cancel_rx,
2098                true,
2099                PumpShared {
2100                    notification_cache: c,
2101                    subs: subs.clone(),
2102                    peer_cell: Arc::clone(&peer_cell),
2103                    workspace_roots: test_workspace_roots(),
2104                },
2105            ));
2106
2107            let uri: Uri = test_uri("main.rs");
2108            tx.send(LspNotification::PublishDiagnostics(
2109                PublishDiagnosticsParams {
2110                    uri: uri.clone(),
2111                    diagnostics: vec![],
2112                    version: None,
2113                },
2114            ))
2115            .await
2116            .unwrap();
2117            drop(tx);
2118
2119            // Poll until the pump processes the message or we time out.
2120            let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2121                loop {
2122                    tokio::task::yield_now().await;
2123                    let found = {
2124                        let guard = cache.lock().await;
2125                        guard.diagnostics(uri.as_ref()).is_some()
2126                    };
2127                    if found {
2128                        return true;
2129                    }
2130                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2131                }
2132            })
2133            .await
2134            .expect("pump did not cache diagnostics within 5 s");
2135            assert!(cached, "diagnostics should be cached before peer is set");
2136        }
2137
2138        /// #234 (S1 hardening): diagnostics for URIs outside the configured
2139        /// workspace roots must be dropped rather than cached, closing the
2140        /// vector where a misbehaving server floods the FIFO-bounded cache
2141        /// with fabricated URIs to evict every legitimate entry.
2142        #[tokio::test]
2143        async fn test_pump_drops_diagnostics_outside_workspace_roots() {
2144            let cache = make_cache();
2145            let subs = make_subs();
2146            let peer_cell = make_peer_cell();
2147            let (tx, rx) = mpsc::channel(8);
2148            let (_lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2149            let (_cancel_tx, cancel_rx) = watch::channel(false);
2150
2151            // See `test_diagnostic_path_in_workspace_accepts_uri_under_root`
2152            // for why Windows needs a drive-letter path here.
2153            #[cfg(windows)]
2154            let (workspace_root, outside_uri_str, inside_uri_str) = (
2155                PathBuf::from(r"C:\workspace"),
2156                "file:///C:/etc/passwd",
2157                "file:///C:/workspace/src/main.rs",
2158            );
2159            #[cfg(not(windows))]
2160            let (workspace_root, outside_uri_str, inside_uri_str) = (
2161                PathBuf::from("/workspace"),
2162                "file:///etc/passwd",
2163                "file:///workspace/src/main.rs",
2164            );
2165            let workspace_roots: Arc<[PathBuf]> = Arc::from([workspace_root]);
2166
2167            tokio::spawn(diagnostics_pump(
2168                ServerId::from("rust"),
2169                rx,
2170                lifecycle_rx,
2171                cancel_rx,
2172                true,
2173                PumpShared {
2174                    notification_cache: Arc::clone(&cache),
2175                    subs: subs.clone(),
2176                    peer_cell: Arc::clone(&peer_cell),
2177                    workspace_roots,
2178                },
2179            ));
2180
2181            let outside_uri: Uri = Uri::from(outside_uri_str);
2182            let inside_uri: Uri = Uri::from(inside_uri_str);
2183
2184            tx.send(LspNotification::PublishDiagnostics(
2185                PublishDiagnosticsParams {
2186                    uri: outside_uri.clone(),
2187                    diagnostics: vec![],
2188                    version: None,
2189                },
2190            ))
2191            .await
2192            .unwrap();
2193            tx.send(LspNotification::PublishDiagnostics(
2194                PublishDiagnosticsParams {
2195                    uri: inside_uri.clone(),
2196                    diagnostics: vec![],
2197                    version: None,
2198                },
2199            ))
2200            .await
2201            .unwrap();
2202            drop(tx);
2203
2204            // Poll until the (later-sent) in-workspace sentinel is cached --
2205            // proves the pump already processed the earlier out-of-workspace
2206            // message too, since the channel preserves send order.
2207            tokio::time::timeout(std::time::Duration::from_secs(5), async {
2208                loop {
2209                    {
2210                        let guard = cache.lock().await;
2211                        if guard.diagnostics(inside_uri.as_ref()).is_some() {
2212                            return;
2213                        }
2214                    }
2215                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2216                }
2217            })
2218            .await
2219            .expect("pump did not cache in-workspace diagnostics within 5 s");
2220
2221            let found_outside = cache
2222                .lock()
2223                .await
2224                .diagnostics(outside_uri.as_ref())
2225                .is_some();
2226            assert!(
2227                !found_outside,
2228                "diagnostics for a URI outside workspace roots must not be cached"
2229            );
2230        }
2231
2232        /// Pump exits cleanly when the cancel watch sends `true`.
2233        #[tokio::test]
2234        async fn test_pump_exits_on_cancel() {
2235            let cache = make_cache();
2236            let subs = make_subs();
2237            let peer_cell = make_peer_cell();
2238            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
2239            let (_lifecycle_tx, lifecycle_rx) = mpsc::channel::<LspNotification>(8);
2240            let (cancel_tx, cancel_rx) = watch::channel(false);
2241
2242            let handle = tokio::spawn(diagnostics_pump(
2243                ServerId::from("rust"),
2244                rx,
2245                lifecycle_rx,
2246                cancel_rx,
2247                true,
2248                PumpShared {
2249                    notification_cache: cache,
2250                    subs,
2251                    peer_cell,
2252                    workspace_roots: test_workspace_roots(),
2253                },
2254            ));
2255
2256            cancel_tx.send(true).unwrap();
2257            // Pump must finish within a short time after cancellation.
2258            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
2259                .await
2260                .expect("pump did not exit within timeout")
2261                .unwrap();
2262        }
2263
2264        /// Pump exits when the cancel sender is dropped (Err branch).
2265        #[tokio::test]
2266        async fn test_pump_exits_when_cancel_sender_dropped() {
2267            let cache = make_cache();
2268            let subs = make_subs();
2269            let peer_cell = make_peer_cell();
2270            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
2271            let (_lifecycle_tx, lifecycle_rx) = mpsc::channel::<LspNotification>(8);
2272            let (cancel_tx, cancel_rx) = watch::channel(false);
2273
2274            let handle = tokio::spawn(diagnostics_pump(
2275                ServerId::from("rust"),
2276                rx,
2277                lifecycle_rx,
2278                cancel_rx,
2279                true,
2280                PumpShared {
2281                    notification_cache: cache,
2282                    subs,
2283                    peer_cell,
2284                    workspace_roots: test_workspace_roots(),
2285                },
2286            ));
2287
2288            drop(cancel_tx); // triggers Err in cancel_rx.changed()
2289            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
2290                .await
2291                .expect("pump did not exit within timeout")
2292                .unwrap();
2293        }
2294
2295        /// Regression test for #104: the pump must cache a notification promptly
2296        /// even while another task holds the translator lock for far longer than
2297        /// any acceptable pump latency. Before the `NotificationCache` split, the
2298        /// pump locked `Arc<Mutex<Translator>>` to cache diagnostics, so it would
2299        /// have stalled here until the holder released the lock.
2300        #[tokio::test]
2301        async fn test_pump_makes_progress_while_translator_lock_held() {
2302            let translator = Arc::new(Mutex::new(Translator::new()));
2303            let cache = make_cache();
2304            let subs = make_subs();
2305            let peer_cell = make_peer_cell();
2306            let (tx, rx) = mpsc::channel(8);
2307            let (_lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2308            let (_cancel_tx, cancel_rx) = watch::channel(false);
2309
2310            // Simulate a slow in-flight MCP request (e.g. `pull_diagnostics`)
2311            // holding the translator lock across an LSP round-trip.
2312            let lock_acquired = Arc::new(tokio::sync::Notify::new());
2313            let notify = Arc::clone(&lock_acquired);
2314            let holder = tokio::spawn(async move {
2315                let _guard = translator.lock().await;
2316                notify.notify_one();
2317                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2318            });
2319            lock_acquired.notified().await;
2320
2321            tokio::spawn(diagnostics_pump(
2322                ServerId::from("rust"),
2323                rx,
2324                lifecycle_rx,
2325                cancel_rx,
2326                true,
2327                PumpShared {
2328                    notification_cache: Arc::clone(&cache),
2329                    subs,
2330                    peer_cell,
2331                    workspace_roots: test_workspace_roots(),
2332                },
2333            ));
2334
2335            let uri: Uri = test_uri("locked.rs");
2336            tx.send(LspNotification::PublishDiagnostics(
2337                PublishDiagnosticsParams {
2338                    uri: uri.clone(),
2339                    diagnostics: vec![],
2340                    version: None,
2341                },
2342            ))
2343            .await
2344            .unwrap();
2345            drop(tx);
2346
2347            // Well within the 2 s translator lock hold: a translator-locking
2348            // pump would still be blocked at this point.
2349            tokio::time::timeout(std::time::Duration::from_millis(500), async {
2350                loop {
2351                    {
2352                        let guard = cache.lock().await;
2353                        if guard.diagnostics(uri.as_ref()).is_some() {
2354                            return;
2355                        }
2356                    }
2357                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2358                }
2359            })
2360            .await
2361            .expect("pump stalled behind translator lock");
2362
2363            holder.await.unwrap();
2364        }
2365
2366        /// The `Other` arm (custom/unrecognized notifications, e.g.
2367        /// rust-analyzer's `experimental/serverStatus`) must reach
2368        /// `NotificationCache::observe_indexing_signal` via the lifecycle
2369        /// lane -- this is the one place in production that notification
2370        /// actually gets from the LSP transport into the indexing-readiness
2371        /// gate; every other test for the gate pre-seeds the cache by hand
2372        /// and would not have caught a pump wiring regression.
2373        #[tokio::test]
2374        async fn test_pump_routes_other_notifications_to_indexing_signal() {
2375            let cache = make_cache();
2376            let subs = make_subs();
2377            let peer_cell = make_peer_cell();
2378            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
2379            let (lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2380            let (_cancel_tx, cancel_rx) = watch::channel(false);
2381            let server_id = ServerId::from("rust");
2382
2383            tokio::spawn(diagnostics_pump(
2384                server_id.clone(),
2385                rx,
2386                lifecycle_rx,
2387                cancel_rx,
2388                true,
2389                PumpShared {
2390                    notification_cache: Arc::clone(&cache),
2391                    subs,
2392                    peer_cell,
2393                    workspace_roots: test_workspace_roots(),
2394                },
2395            ));
2396
2397            lifecycle_tx
2398                .send(LspNotification::Other {
2399                    method: std::borrow::Cow::Borrowed("experimental/serverStatus"),
2400                    params: Some(serde_json::json!({"quiescent": false})),
2401                })
2402                .await
2403                .unwrap();
2404            drop(lifecycle_tx);
2405
2406            tokio::time::timeout(std::time::Duration::from_secs(5), async {
2407                loop {
2408                    {
2409                        let guard = cache.lock().await;
2410                        if guard.indexing_state(&server_id) == IndexingState::Loading {
2411                            return;
2412                        }
2413                    }
2414                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2415                }
2416            })
2417            .await
2418            .expect(
2419                "pump did not route the Other{experimental/serverStatus} notification into \
2420                 NotificationCache::observe_indexing_signal within 5s",
2421            );
2422        }
2423
2424        /// P3: a `$/progress` `report` frame must never reach the lifecycle
2425        /// lane at all (S3) -- classified and dropped by
2426        /// `LspClient::message_loop_inner` before enqueueing, not merely
2427        /// ignored once received. This test exercises the pump side: even
2428        /// if a `report` somehow arrived on the lifecycle lane, the pump
2429        /// itself only recognizes `begin`/`end` shapes via
2430        /// `NotificationCache::observe_progress`, so a `begin` sent
2431        /// afterward must still be the one that flips the state.
2432        #[tokio::test]
2433        async fn test_pump_routes_progress_begin_to_indexing_signal() {
2434            let cache = make_cache();
2435            let subs = make_subs();
2436            let peer_cell = make_peer_cell();
2437            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
2438            let (lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2439            let (_cancel_tx, cancel_rx) = watch::channel(false);
2440            let server_id = ServerId::from("gopls");
2441
2442            tokio::spawn(diagnostics_pump(
2443                server_id.clone(),
2444                rx,
2445                lifecycle_rx,
2446                cancel_rx,
2447                true,
2448                PumpShared {
2449                    notification_cache: Arc::clone(&cache),
2450                    subs,
2451                    peer_cell,
2452                    workspace_roots: test_workspace_roots(),
2453                },
2454            ));
2455
2456            lifecycle_tx
2457                .send(LspNotification::Progress(lsp_types::ProgressParams {
2458                    token: lsp_types::ProgressToken::String("indexing".to_string()),
2459                    value: serde_json::json!({"kind": "begin", "title": "Loading"}),
2460                }))
2461                .await
2462                .unwrap();
2463            drop(lifecycle_tx);
2464
2465            tokio::time::timeout(std::time::Duration::from_secs(5), async {
2466                loop {
2467                    {
2468                        let guard = cache.lock().await;
2469                        if guard.indexing_state(&server_id) == IndexingState::Loading {
2470                            return;
2471                        }
2472                    }
2473                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2474                }
2475            })
2476            .await
2477            .expect(
2478                "pump did not route the Progress(begin) notification into \
2479                 NotificationCache::observe_progress within 5s",
2480            );
2481        }
2482
2483        /// P3: saturating the diagnostics lane to capacity must not stall the
2484        /// lifecycle lane -- a `begin`/`end` frame arriving while the
2485        /// notification lane is backed up must still reach the readiness
2486        /// gate promptly.
2487        #[tokio::test]
2488        async fn test_lifecycle_lane_unaffected_by_saturated_notification_lane() {
2489            let cache = make_cache();
2490            let subs = make_subs();
2491            let peer_cell = make_peer_cell();
2492            let (tx, rx) = mpsc::channel(2);
2493            let (lifecycle_tx, lifecycle_rx) = mpsc::channel(8);
2494            let (_cancel_tx, cancel_rx) = watch::channel(false);
2495            let server_id = ServerId::from("gopls");
2496
2497            // Fill the notification lane to capacity before the pump drains it, forcing a backlog.
2498            for _ in 0..2 {
2499                tx.send(LspNotification::PublishDiagnostics(
2500                    PublishDiagnosticsParams {
2501                        uri: test_uri("saturate.rs"),
2502                        diagnostics: vec![],
2503                        version: None,
2504                    },
2505                ))
2506                .await
2507                .unwrap();
2508            }
2509
2510            tokio::spawn(diagnostics_pump(
2511                server_id.clone(),
2512                rx,
2513                lifecycle_rx,
2514                cancel_rx,
2515                true,
2516                PumpShared {
2517                    notification_cache: Arc::clone(&cache),
2518                    subs,
2519                    peer_cell,
2520                    workspace_roots: test_workspace_roots(),
2521                },
2522            ));
2523
2524            lifecycle_tx
2525                .send(LspNotification::Other {
2526                    method: std::borrow::Cow::Borrowed("experimental/serverStatus"),
2527                    params: Some(serde_json::json!({"quiescent": false})),
2528                })
2529                .await
2530                .unwrap();
2531            drop(tx);
2532            drop(lifecycle_tx);
2533
2534            tokio::time::timeout(std::time::Duration::from_secs(5), async {
2535                loop {
2536                    {
2537                        let guard = cache.lock().await;
2538                        if guard.indexing_state(&server_id) == IndexingState::Loading {
2539                            return;
2540                        }
2541                    }
2542                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2543                }
2544            })
2545            .await
2546            .expect("lifecycle lane must still be served while the notification lane is backed up");
2547        }
2548    }
2549}