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