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