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