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(®istered);
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(¬ification_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(¬ification_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(¬ification_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}