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, 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/// is built via [`canonicalize_workspace_roots`] -- see that function's docs.
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 from config or current directory.
316///
317/// If no workspace roots are provided in the configuration, this function
318/// will use the current working directory, canonicalized for security.
319///
320/// # Returns
321///
322/// A vector of workspace root paths. If config roots are provided, they are
323/// returned as-is. Otherwise, returns the canonicalized current directory,
324/// falling back to relative "." if canonicalization fails.
325fn resolve_workspace_roots(config_roots: &[PathBuf]) -> Vec<PathBuf> {
326 if config_roots.is_empty() {
327 match std::env::current_dir() {
328 Ok(cwd) => {
329 // current_dir() always returns an absolute path
330 match cwd.canonicalize() {
331 Ok(canonical) => {
332 info!(
333 "Using current directory as workspace root: {}",
334 canonical.display()
335 );
336 vec![canonical]
337 }
338 Err(e) => {
339 // Canonicalization can fail if directory was deleted or permissions changed
340 // but cwd itself is still absolute
341 warn!(
342 "Failed to canonicalize current directory: {e}, using non-canonical path"
343 );
344 vec![cwd]
345 }
346 }
347 }
348 Err(e) => {
349 // This is extremely rare - only happens if cwd was deleted or unlinked
350 // In this case, we have no choice but to use a relative path
351 warn!("Failed to get current directory: {e}, using fallback");
352 vec![PathBuf::from(".")]
353 }
354 }
355 } else {
356 config_roots.to_vec()
357 }
358}
359
360/// Canonicalize each workspace root, falling back to the original path for
361/// any root that fails to canonicalize (e.g. deleted after startup).
362///
363/// `resolve_workspace_roots` returns config-provided roots unmodified
364/// (relative paths, symlinks kept as-is); this normalizes them so a plain
365/// prefix comparison against an already-resolved LSP path (see
366/// `diagnostic_path_in_workspace`) works without a filesystem syscall on
367/// that hot per-notification path.
368///
369/// Uses [`dunce::canonicalize`] rather than [`Path::canonicalize`]: on
370/// Windows, the latter returns the `\\?\`-prefixed verbatim form (e.g.
371/// `\\?\C:\...`), which a URI-derived path from `Url::to_file_path` (never
372/// verbatim-prefixed) can never `starts_with`-match, silently dropping every
373/// diagnostic. `dunce::canonicalize` resolves symlinks identically but
374/// returns the ordinary `C:\...` form when the result doesn't require the
375/// verbatim syntax (i.e. essentially always, for realistic workspace paths).
376fn canonicalize_workspace_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
377 roots
378 .iter()
379 .map(|root| dunce::canonicalize(root).unwrap_or_else(|_| root.clone()))
380 .collect()
381}
382
383/// Start the MCPLS server with the given configuration over stdio.
384///
385/// This is the backward-compatible entry point. It is equivalent to calling
386/// `serve_with(config, Transport::Stdio)`.
387///
388/// # Errors
389///
390/// Returns an error if:
391/// - All LSP servers fail to initialize
392/// - MCP server setup fails
393/// - Configuration is invalid
394///
395/// # Graceful Degradation
396///
397/// - **All servers succeed**: Service runs normally
398/// - **Partial success**: Logs warnings for failures, continues with available servers
399/// - **All servers fail**: Returns `Error::AllServersFailedToInit` with details
400///
401/// # Shutdown
402///
403/// See [`serve_with`]'s "Shutdown" section — this function uses
404/// [`Transport::Stdio`], so the same `std::process::exit` requirement
405/// applies to callers.
406pub async fn serve(config: ServerConfig) -> Result<(), Error> {
407 serve_with(config, Transport::Stdio).await
408}
409
410/// Start the MCPLS server with an explicit transport.
411///
412/// Performs all shared setup (workspace discovery, LSP spawning, translator
413/// initialization, diagnostic pump tasks) and then delegates to the
414/// appropriate transport runner.
415///
416/// # Errors
417///
418/// Returns an error if:
419/// - All LSP servers fail to initialize
420/// - The MCP server or transport fails to start
421/// - Configuration is invalid, including two applicable `[[lsp_servers]]`
422/// entries whose per-tool routing is ambiguous in this workspace (shared
423/// routing identity, two catch-alls, or the same tool claimed by both) --
424/// see `config::ToolRouter::from_configs`
425///
426/// # DNS rebinding protection (HTTP transport)
427///
428/// When using `Transport::Http`, the underlying rmcp service validates the
429/// inbound `Host` header against an allowlist that defaults to loopback
430/// addresses only (`localhost`, `127.0.0.1`, `::1`). Requests with any other
431/// `Host` value are rejected with `421 Misdirected Request`.
432///
433/// If you bind to a non-loopback address (e.g. `0.0.0.0:3000`) and expose the
434/// service through a reverse proxy, the proxy must forward `Host: localhost`
435/// (or another loopback alias) to the mcpls process. Direct non-loopback
436/// access is intentionally blocked to prevent DNS-rebinding attacks.
437///
438/// # Shutdown
439///
440/// [`Transport::Stdio`] is backed by `tokio::io::stdin()`, which internally
441/// parks an uncancellable blocking-pool thread in a raw `read()` syscall
442/// that only returns on more input or EOF. If your `main` uses
443/// `#[tokio::main]` and simply returns after awaiting this function, the
444/// macro-generated runtime-shutdown wrapper blocks waiting for that thread
445/// -- hanging indefinitely on `SIGTERM`/`SIGINT` as long as the MCP
446/// client's stdin write end is still open, since that never triggers EOF.
447/// Call `std::process::exit` right after this function resolves instead of
448/// returning normally from `main`, as in the example below (see mcpls's own
449/// `mcpls-cli` binary; tracked as #308). This does not apply to
450/// [`Transport::Http`], which never touches `tokio::io::stdin()`.
451///
452/// # Examples
453///
454/// ```rust,ignore
455/// use mcpls_core::{serve_with, Transport, ServerConfig};
456///
457/// #[tokio::main]
458/// async fn main() {
459/// let config = ServerConfig::load().expect("failed to load config");
460/// let exit_code = match serve_with(config, Transport::Stdio).await {
461/// Ok(()) => 0,
462/// Err(_) => 1,
463/// };
464/// // See "Shutdown" above: process::exit avoids a runtime-shutdown hang.
465/// std::process::exit(exit_code);
466/// }
467/// ```
468pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
469 info!("Starting MCPLS server...");
470
471 // Registered before any other startup work -- including
472 // `spawn_lsp_servers_background` below, which spawns LSP child processes
473 // concurrently on another worker thread -- so a `SIGTERM`/`SIGINT`
474 // arriving during config validation, workspace-root heuristics, or LSP
475 // spawning is caught rather than hitting the OS's default disposition
476 // (immediate termination, orphaning any LSP child mid-spawn; see #270)
477 // and skipping the `shutdown()` cleanup below entirely. See
478 // `ShutdownSignal`'s docs for why this must be a single instance carried
479 // through by value rather than re-registered later.
480 let shutdown_signal = ShutdownSignal::new();
481
482 // `ServerConfig::load`/`load_from` already validate the TOML-loading
483 // path; this covers the other one -- a caller building `ServerConfig`
484 // programmatically (e.g. a library embedder) previously hit no
485 // diagnosable error here, only silent clamping at accessor level (e.g.
486 // `LspClient::request_timeout`). `serve` delegates to this function, so
487 // one call site here covers both public entry points (`serve` and
488 // `serve_with`); note this does mean a config loaded via the CLI's
489 // `load_from` -> `serve` path is validated twice (harmless -- `validate`
490 // is a pure check with no side effects beyond a `tracing::warn!` for a
491 // non-fatal duplicate-name case, which will simply log twice).
492 //
493 // Considered wrapping this in a `Validated<ServerConfig>` marker type to
494 // make "already validated" a compile-time guarantee instead of a runtime
495 // check here; rejected as unnecessary ceremony for a pre-1.0 API (#282).
496 config.validate()?;
497
498 let project_config_ignored = config.project_config_ignored;
499 let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
500 let extension_map = config.build_effective_extension_map();
501 let max_depth = Some(config.workspace.heuristics_max_depth);
502
503 let applicable_configs: Vec<ServerInitConfig> = config
504 .lsp_servers
505 .iter()
506 .filter_map(|lsp_config| {
507 let should_spawn = workspace_roots
508 .iter()
509 .any(|root| lsp_config.should_spawn(root, max_depth));
510
511 if !should_spawn {
512 info!(
513 "Skipping LSP server '{}' ({}): no project markers found",
514 lsp_config.language_id, lsp_config.command
515 );
516 return None;
517 }
518
519 Some(ServerInitConfig {
520 server_config: lsp_config.clone(),
521 workspace_roots: workspace_roots.clone(),
522 initialization_options: lsp_config.initialization_options.clone(),
523 position_encodings: config.workspace.position_encodings.clone(),
524 notification_tx: None,
525 })
526 })
527 .collect();
528
529 info!(
530 "Attempting to spawn {} applicable LSP server(s)...",
531 applicable_configs.len()
532 );
533
534 // Built over the applicable (post-heuristics) configs only: this is where
535 // #174's workspace-scoped routing rules (duplicate ServerId, conflicting
536 // `handles` claims) are enforced -- a startup error naming the
537 // conflicting `[[lsp_servers]]` entries, not a silent drop.
538 let router = ToolRouter::from_configs(applicable_configs.iter().map(|c| &c.server_config))?;
539
540 // Built here (rather than alongside `subscriptions`/`peer_cell` below) so
541 // it can be handed to the translator, which uses it to invalidate a
542 // respawned server's stale cached diagnostics -- see
543 // `Translator::with_notification_cache`. Independent of `translator`
544 // itself, which holds no outer lock: the pump only ever locks this
545 // cache, so it never contends with a request handler running an
546 // in-flight LSP round-trip.
547 let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
548
549 let mut translator = Translator::new()
550 .with_resource_limits(config.workspace.resource_limits())
551 .with_extensions(extension_map)
552 .with_router(router)
553 .with_notification_cache(Arc::clone(¬ification_cache));
554 translator.set_workspace_roots(workspace_roots.clone());
555
556 // Mark applicable servers as "expected" so a tool call that arrives while
557 // its server is still initializing gets a clear "still initializing" error
558 // (instead of "no server configured"), telling the caller to wait and retry.
559 let expected_servers: HashSet<ServerId> = applicable_configs
560 .iter()
561 .map(|c| c.server_config.id())
562 .collect();
563 translator.set_expected_servers(expected_servers);
564
565 // Shared state, built BEFORE LSP initialization so the MCP server can answer
566 // `initialize` immediately. LSP servers (which can take minutes to initialize
567 // on a large solution, e.g. a 130-project Unity .sln via OmniSharp) are spawned
568 // in a background task and registered into this shared translator once ready.
569 // Blocking the MCP handshake on LSP init makes slow servers exceed the client's
570 // initialize-request timeout (Claude Code: ~60s) -> "Request timed out".
571 // Fixed for the server's lifetime: shared as a lock-free snapshot so
572 // cache-only handlers (e.g. `get_cached_diagnostics`, `read_resource`) can
573 // validate a path without locking `translator` below.
574 //
575 // Canonicalized once here rather than left as-is: `resolve_workspace_roots`
576 // returns config-provided roots unmodified (relative paths, symlinks kept),
577 // but `diagnostic_path_in_workspace` (fed by this snapshot) does a plain
578 // prefix check with no filesystem I/O on its hot per-notification path --
579 // that only matches correctly if both sides are already in the same
580 // (canonical) form, and LSP servers report already-resolved canonical
581 // paths. Comparing an un-canonicalized root against a canonical path would
582 // silently drop every diagnostic for a workspace configured with a
583 // relative or symlinked root. Falls back to the original root if
584 // canonicalization fails (e.g. deleted between startup and this point);
585 // `validate_path_against_roots`'s own per-call canonicalize is unaffected
586 // either way, since canonicalizing an already-canonical path is a no-op.
587 let workspace_roots_snapshot: Arc<[PathBuf]> =
588 Arc::from(canonicalize_workspace_roots(&workspace_roots));
589
590 let translator = Arc::new(translator);
591 let subscriptions = Arc::new(ResourceSubscriptions::new());
592 // Peer cell is populated after the MCP transport is established (Phase B).
593 let peer_cell = Arc::new(OnceCell::new());
594
595 // Cancellation for pump tasks: send `true` to request shutdown.
596 let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
597
598 let lsp_init_handle = if applicable_configs.is_empty() {
599 warn!("No applicable LSP servers configured — starting in protocol-only mode");
600 None
601 } else {
602 info!(
603 "Spawning {} LSP server(s) in the background...",
604 applicable_configs.len()
605 );
606 Some(spawn_lsp_servers_background(
607 applicable_configs,
608 Arc::clone(&translator),
609 Arc::clone(¬ification_cache),
610 Arc::clone(&subscriptions),
611 Arc::clone(&peer_cell),
612 cancel_rx.clone(),
613 Arc::clone(&workspace_roots_snapshot),
614 ))
615 };
616
617 info!("Starting MCP server with rmcp...");
618 let mcp_server = mcp::McplsServer::new(
619 Arc::clone(&translator),
620 Arc::clone(¬ification_cache),
621 Arc::clone(&workspace_roots_snapshot),
622 Arc::clone(&subscriptions),
623 project_config_ignored,
624 );
625 info!("MCPLS server initialized successfully");
626
627 let result = match transport {
628 Transport::Stdio => {
629 info!("Listening for MCP requests on stdio...");
630 run_stdio(mcp_server, &peer_cell, shutdown_signal).await
631 }
632 #[cfg(feature = "transport-http")]
633 Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
634 };
635
636 shutdown(&cancel_tx, &translator, lsp_init_handle).await;
637
638 info!("MCPLS server shutting down");
639 result
640}
641
642/// Bounds how long [`shutdown`] waits for the background LSP init task
643/// (see [`spawn_lsp_servers_background`]) to finish after cancellation is
644/// signaled. Deliberately shorter than [`Translator`]'s own per-server
645/// shutdown timeout: by the time `shutdown_servers` returns, every
646/// registered server's notification channel has closed, so the init task's
647/// diagnostics pumps should already be draining. This bound only matters
648/// for the rarer case where the init task is still mid-`initialize` (never
649/// registered anything for `shutdown_servers` to act on).
650const LSP_INIT_TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
651
652/// Awaits the background LSP init task's `JoinHandle` with a bounded
653/// `timeout`, logging a panic at `error` level (previously dropped
654/// silently, see #196) or an unresponsive task at `warn` level instead of
655/// letting either go unnoticed.
656///
657/// `timeout` is a parameter (rather than always
658/// [`LSP_INIT_TASK_SHUTDOWN_TIMEOUT`]) so tests can exercise the timeout
659/// branch without waiting out the real bound. Awaits `handle` by `&mut`
660/// (not by value): dropping an *owned* `JoinHandle` on timeout would only
661/// detach the task — it keeps running rather than stopping, contradicting
662/// the warning logged below. Retaining ownership lets `abort()` make that
663/// message true.
664///
665/// `abort()` only *requests* cancellation; the task's locals (which may own
666/// not-yet-registered `tokio::process::Child` handles for LSP servers
667/// [`spawn_lsp_servers_background`] is still spawning via `spawn_batch`,
668/// relying entirely on `kill_on_drop` to terminate them) are only actually
669/// dropped once the runtime polls the task to completion. `mcpls-cli`'s
670/// `main` calls `std::process::exit` right after `serve_with` returns (see
671/// #308), which skips the executor's own task teardown that used to do this
672/// polling implicitly — so this function awaits the aborted handle again,
673/// bounded, to drive that drop here instead of leaving it to chance.
674/// Otherwise a `SIGTERM` arriving mid-`spawn_batch` could orphan those LSP
675/// child processes, the exact failure mode #270 was filed to prevent.
676async fn await_lsp_init_handle(mut handle: JoinHandle<()>, timeout: Duration) {
677 match tokio::time::timeout(timeout, &mut handle).await {
678 Ok(Ok(())) => {}
679 Ok(Err(err)) => error!("Background LSP initialization task failed: {err}"),
680 Err(_) => {
681 warn!("Timed out waiting for background LSP initialization task to stop");
682 handle.abort();
683 let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
684 }
685 }
686}
687
688/// Post-transport shutdown sequence, run once the transport future
689/// (`run_stdio`/`run_http`) returns — whether that's because of a
690/// `SIGTERM`/`SIGINT`, stdio EOF, or (for HTTP) its own graceful shutdown.
691///
692/// Signals background pump tasks to exit, then gracefully shuts down every
693/// LSP server registered on `translator` (see
694/// [`Translator::shutdown_servers`] for what "gracefully" bounds and falls
695/// back to). Finally, if the background LSP init task (see
696/// [`spawn_lsp_servers_background`]) is still running, awaits it via
697/// [`await_lsp_init_handle`], giving its diagnostics pump tasks a chance to
698/// finish draining before `serve_with` returns. Extracted from
699/// [`serve_with`] so this sequence is exercised directly in tests without
700/// needing a full stdio/HTTP transport round trip.
701async fn shutdown(
702 cancel_tx: &tokio::sync::watch::Sender<bool>,
703 translator: &Translator,
704 lsp_init_handle: Option<JoinHandle<()>>,
705) {
706 let _ = cancel_tx.send(true);
707
708 info!("Shutting down LSP servers...");
709 translator.shutdown_servers().await;
710
711 if let Some(handle) = lsp_init_handle {
712 await_lsp_init_handle(handle, LSP_INIT_TASK_SHUTDOWN_TIMEOUT).await;
713 }
714}
715
716/// Spawn the applicable LSP servers in a background task and register them into
717/// the shared `translator` once ready.
718///
719/// This intentionally does NOT block the caller: `serve_with` starts the MCP
720/// server immediately so its `initialize` handshake returns before slow language
721/// servers (e.g. `OmniSharp` on a large Unity solution, which can take minutes to
722/// load) finish initializing. Tool calls that arrive before a server has
723/// registered return a `ServerInitializing` error telling the caller to wait and
724/// retry. If every server fails, the "expected servers" set is cleared so those
725/// calls fall back to a plain "no server configured" error instead.
726///
727/// Returns the task's `JoinHandle` so [`shutdown`] can await it: previously
728/// this handle was dropped, silently swallowing panics from
729/// `LspServer::spawn_batch`, `register_servers`, or a diagnostics pump task
730/// (see #196).
731fn spawn_lsp_servers_background(
732 applicable_configs: Vec<ServerInitConfig>,
733 translator: Arc<Translator>,
734 notification_cache: Arc<Mutex<NotificationCache>>,
735 subscriptions: Arc<ResourceSubscriptions>,
736 peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
737 cancel_rx: tokio::sync::watch::Receiver<bool>,
738 workspace_roots: Arc<[PathBuf]>,
739) -> JoinHandle<()> {
740 tokio::spawn(async move {
741 let configs_by_id: HashMap<ServerId, ServerInitConfig> = applicable_configs
742 .iter()
743 .map(|c| (c.server_config.id(), c.clone()))
744 .collect();
745 let result = LspServer::spawn_batch(&applicable_configs).await;
746
747 if result.all_failed() {
748 error!(
749 "All {} configured LSP server(s) failed to initialize",
750 result.failure_count()
751 );
752 for failure in &result.failures {
753 error!("Server initialization failed: {}", failure);
754 }
755 // No server will register: rebind against an empty registered
756 // set so every route drops (one rule, no special case -- see
757 // `ToolRouter::rebind_to_registered`), then stop reporting
758 // "still initializing". This path returns before
759 // `register_servers` ever runs, so it needs its own rebind call;
760 // skipping it would leave every route pointed at a dead server.
761 translator.rebind_router(&HashSet::new());
762 translator.clear_expected_servers();
763 return;
764 }
765
766 if result.partial_success() {
767 warn!(
768 "Partial server initialization: {} succeeded, {} failed",
769 result.server_count(),
770 result.failure_count()
771 );
772 for failure in &result.failures {
773 error!("Server initialization failed: {}", failure);
774 }
775 }
776
777 let server_count = result.server_count();
778 let registered = register_servers(result, &translator, &configs_by_id);
779 // Background initialization has completed; stop reporting "still
780 // initializing" (especially for servers that failed to spawn on
781 // partial success, which would otherwise return ServerInitializing
782 // forever instead of NoServerForLanguage/Tool).
783 translator.clear_expected_servers();
784 info!("Proceeding with {} LSP server(s)", server_count);
785
786 // Give each diagnostics-route server a fair share of the shared
787 // diagnostics cache budget now that the full set is known -- see
788 // `NotificationCache::set_diagnostics_route_count` (#266).
789 let diagnostics_route_count = registered
790 .diagnostics_flags
791 .values()
792 .filter(|&&is_route| is_route)
793 .count();
794 notification_cache
795 .lock()
796 .await
797 .set_diagnostics_route_count(diagnostics_route_count);
798
799 // Start diagnostics pump tasks now that servers are registered.
800 let pump_shared = PumpShared {
801 notification_cache,
802 subs: subscriptions,
803 peer_cell,
804 workspace_roots,
805 };
806 let mut pumps: JoinSet<()> = JoinSet::new();
807 for (id, rx) in registered.receivers {
808 let caches_diagnostics = registered
809 .diagnostics_flags
810 .get(&id)
811 .copied()
812 .unwrap_or(false);
813 pumps.spawn(diagnostics_pump(
814 id,
815 rx,
816 cancel_rx.clone(),
817 caches_diagnostics,
818 pump_shared.clone(),
819 ));
820 }
821 while pumps.join_next().await.is_some() {}
822 })
823}
824
825#[cfg(test)]
826#[allow(clippy::unwrap_used)]
827mod tests {
828 use bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE};
829
830 use super::*;
831
832 #[test]
833 fn test_diagnostic_path_in_workspace_empty_roots_allows_any_uri() {
834 let uri: Uri = "file:///anywhere/at/all.rs".parse().unwrap();
835 assert!(diagnostic_path_in_workspace(&uri, &[]));
836 }
837
838 #[test]
839 fn test_diagnostic_path_in_workspace_accepts_uri_under_root() {
840 // `Url::to_file_path` on Windows requires the URL's first path
841 // segment to be a drive letter; a Unix-style path with no drive
842 // letter fails to convert at all (`uri_to_path` returns `None`),
843 // trivially satisfying this assertion for the wrong reason. Use a
844 // drive-letter path so the test actually exercises the prefix check
845 // on every platform.
846 #[cfg(windows)]
847 let (root, uri_str) = (
848 PathBuf::from(r"C:\workspace\project"),
849 "file:///C:/workspace/project/src/main.rs",
850 );
851 #[cfg(not(windows))]
852 let (root, uri_str) = (
853 PathBuf::from("/workspace/project"),
854 "file:///workspace/project/src/main.rs",
855 );
856 let uri: Uri = uri_str.parse().unwrap();
857 assert!(diagnostic_path_in_workspace(&uri, &[root]));
858 }
859
860 #[test]
861 fn test_diagnostic_path_in_workspace_rejects_uri_outside_roots() {
862 #[cfg(windows)]
863 let (root, uri_str) = (
864 PathBuf::from(r"C:\workspace\project"),
865 "file:///C:/etc/passwd",
866 );
867 #[cfg(not(windows))]
868 let (root, uri_str) = (PathBuf::from("/workspace/project"), "file:///etc/passwd");
869 let uri: Uri = uri_str.parse().unwrap();
870 assert!(!diagnostic_path_in_workspace(&uri, &[root]));
871 }
872
873 #[test]
874 fn test_diagnostic_path_in_workspace_rejects_non_file_uri() {
875 let root = PathBuf::from("/workspace/project");
876 let uri: Uri = "untitled:Untitled-1".parse().unwrap();
877 assert!(!diagnostic_path_in_workspace(&uri, &[root]));
878 }
879
880 /// `Path::starts_with` is a lexical, component-wise comparison that does
881 /// not resolve `.`/`..` — without an explicit check, a URI like
882 /// `file:///workspace/project/../../etc/passwd` would lexically "start
883 /// with" `/workspace/project` despite pointing outside it.
884 #[test]
885 fn test_diagnostic_path_in_workspace_rejects_parent_dir_traversal() {
886 #[cfg(windows)]
887 let (root, uri_str) = (
888 PathBuf::from(r"C:\workspace\project"),
889 "file:///C:/workspace/project/../../etc/passwd",
890 );
891 #[cfg(not(windows))]
892 let (root, uri_str) = (
893 PathBuf::from("/workspace/project"),
894 "file:///workspace/project/../../etc/passwd",
895 );
896 let uri: Uri = uri_str.parse().unwrap();
897 assert!(!diagnostic_path_in_workspace(&uri, &[root]));
898 }
899
900 #[test]
901 fn test_canonicalize_workspace_roots_falls_back_on_nonexistent_path() {
902 let missing = PathBuf::from("/definitely/does/not/exist/anywhere");
903 let result = canonicalize_workspace_roots(std::slice::from_ref(&missing));
904 assert_eq!(result, vec![missing]);
905 }
906
907 /// #234 round-3 regression: a symlinked workspace root must canonicalize
908 /// to its real path, matching what LSP servers report in diagnostics --
909 /// otherwise `diagnostic_path_in_workspace`'s uncanonicalized prefix check
910 /// would silently drop every diagnostic for that workspace.
911 #[test]
912 #[cfg(unix)]
913 fn test_canonicalize_workspace_roots_resolves_symlink() {
914 use std::os::unix::fs::symlink;
915
916 use tempfile::TempDir;
917
918 let temp_dir = TempDir::new().unwrap();
919 let base = temp_dir.path().canonicalize().unwrap();
920 let real_dir = base.join("real");
921 std::fs::create_dir(&real_dir).unwrap();
922 let link_dir = base.join("link");
923 symlink(&real_dir, &link_dir).unwrap();
924
925 let result = canonicalize_workspace_roots(&[link_dir]);
926 assert_eq!(result, vec![real_dir]);
927 }
928
929 #[test]
930 fn test_resolve_workspace_roots_empty_config() {
931 let roots = resolve_workspace_roots(&[]);
932 assert_eq!(roots.len(), 1);
933 assert!(
934 roots[0].is_absolute(),
935 "Workspace root should be absolute path"
936 );
937 }
938
939 #[test]
940 fn test_resolve_workspace_roots_with_config() {
941 let config_roots = vec![PathBuf::from("/test/root")];
942 let roots = resolve_workspace_roots(&config_roots);
943 assert_eq!(roots, config_roots);
944 }
945
946 #[test]
947 fn test_resolve_workspace_roots_multiple_paths() {
948 let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
949 let roots = resolve_workspace_roots(&config_roots);
950 assert_eq!(roots, config_roots);
951 assert_eq!(roots.len(), 2);
952 }
953
954 #[test]
955 fn test_resolve_workspace_roots_preserves_order() {
956 let config_roots = vec![
957 PathBuf::from("/workspace/alpha"),
958 PathBuf::from("/workspace/beta"),
959 PathBuf::from("/workspace/gamma"),
960 ];
961 let roots = resolve_workspace_roots(&config_roots);
962 assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
963 assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
964 assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
965 }
966
967 #[test]
968 fn test_resolve_workspace_roots_single_path() {
969 let config_roots = vec![PathBuf::from("/single/workspace")];
970 let roots = resolve_workspace_roots(&config_roots);
971 assert_eq!(roots.len(), 1);
972 assert_eq!(roots[0], PathBuf::from("/single/workspace"));
973 }
974
975 #[test]
976 fn test_resolve_workspace_roots_empty_returns_cwd() {
977 let roots = resolve_workspace_roots(&[]);
978 assert!(
979 !roots.is_empty(),
980 "Should return at least one workspace root"
981 );
982 }
983
984 #[test]
985 fn test_resolve_workspace_roots_relative_paths() {
986 let config_roots = vec![
987 PathBuf::from("relative/path1"),
988 PathBuf::from("relative/path2"),
989 ];
990 let roots = resolve_workspace_roots(&config_roots);
991 assert_eq!(roots.len(), 2);
992 assert_eq!(roots[0], PathBuf::from("relative/path1"));
993 assert_eq!(roots[1], PathBuf::from("relative/path2"));
994 }
995
996 #[test]
997 fn test_resolve_workspace_roots_mixed_paths() {
998 let config_roots = vec![
999 PathBuf::from("/absolute/path"),
1000 PathBuf::from("relative/path"),
1001 ];
1002 let roots = resolve_workspace_roots(&config_roots);
1003 assert_eq!(roots.len(), 2);
1004 assert_eq!(roots[0], PathBuf::from("/absolute/path"));
1005 assert_eq!(roots[1], PathBuf::from("relative/path"));
1006 }
1007
1008 #[test]
1009 fn test_resolve_workspace_roots_with_dot_path() {
1010 let config_roots = vec![PathBuf::from(".")];
1011 let roots = resolve_workspace_roots(&config_roots);
1012 assert_eq!(roots, config_roots);
1013 }
1014
1015 #[test]
1016 fn test_resolve_workspace_roots_with_parent_path() {
1017 let config_roots = vec![PathBuf::from("..")];
1018 let roots = resolve_workspace_roots(&config_roots);
1019 assert_eq!(roots.len(), 1);
1020 assert_eq!(roots[0], PathBuf::from(".."));
1021 }
1022
1023 #[test]
1024 fn test_resolve_workspace_roots_unicode_paths() {
1025 let config_roots = vec![
1026 PathBuf::from("/workspace/テスト"),
1027 PathBuf::from("/workspace/тест"),
1028 ];
1029 let roots = resolve_workspace_roots(&config_roots);
1030 assert_eq!(roots.len(), 2);
1031 assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
1032 assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
1033 }
1034
1035 #[test]
1036 fn test_resolve_workspace_roots_spaces_in_paths() {
1037 let config_roots = vec![
1038 PathBuf::from("/workspace/path with spaces"),
1039 PathBuf::from("/another path/workspace"),
1040 ];
1041 let roots = resolve_workspace_roots(&config_roots);
1042 assert_eq!(roots.len(), 2);
1043 assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
1044 }
1045
1046 // Tests for graceful degradation behavior
1047 mod graceful_degradation_tests {
1048 use super::*;
1049 use crate::error::ServerSpawnFailure;
1050 use crate::lsp::ServerInitResult;
1051
1052 #[test]
1053 fn test_all_servers_failed_error_handling() {
1054 let mut result = ServerInitResult::new();
1055 result.add_failure(ServerSpawnFailure {
1056 server_id: ServerId::from("rust"),
1057 language_id: "rust".to_string(),
1058 command: "rust-analyzer".to_string(),
1059 message: "not found".to_string(),
1060 });
1061 result.add_failure(ServerSpawnFailure {
1062 server_id: ServerId::from("python"),
1063 language_id: "python".to_string(),
1064 command: "pyright".to_string(),
1065 message: "not found".to_string(),
1066 });
1067
1068 assert!(result.all_failed());
1069 assert_eq!(result.failure_count(), 2);
1070 assert_eq!(result.server_count(), 0);
1071 }
1072
1073 #[test]
1074 fn test_partial_success_detection() {
1075 use std::collections::HashMap;
1076
1077 let mut result = ServerInitResult::new();
1078 // Simulate one success and one failure
1079 result.servers = HashMap::new(); // Would have a real server in production
1080 result.add_failure(ServerSpawnFailure {
1081 server_id: ServerId::from("python"),
1082 language_id: "python".to_string(),
1083 command: "pyright".to_string(),
1084 message: "not found".to_string(),
1085 });
1086
1087 // Without actual servers, we can verify the failure was recorded
1088 assert_eq!(result.failure_count(), 1);
1089 assert_eq!(result.server_count(), 0);
1090 }
1091
1092 #[test]
1093 fn test_all_servers_succeeded_detection() {
1094 use std::collections::HashMap;
1095
1096 let mut result = ServerInitResult::new();
1097 result.servers = HashMap::new(); // Would have real servers in production
1098
1099 assert_eq!(result.failure_count(), 0);
1100 assert!(!result.all_failed());
1101 assert!(!result.partial_success());
1102 }
1103
1104 #[test]
1105 fn test_all_servers_failed_to_init_error() {
1106 let failures = vec![
1107 ServerSpawnFailure {
1108 server_id: ServerId::from("rust"),
1109 language_id: "rust".to_string(),
1110 command: "rust-analyzer".to_string(),
1111 message: "command not found".to_string(),
1112 },
1113 ServerSpawnFailure {
1114 server_id: ServerId::from("python"),
1115 language_id: "python".to_string(),
1116 command: "pyright".to_string(),
1117 message: "permission denied".to_string(),
1118 },
1119 ];
1120
1121 let err = Error::AllServersFailedToInit { count: 2, failures };
1122
1123 assert!(err.to_string().contains("all LSP servers failed"));
1124 assert!(err.to_string().contains("2 configured"));
1125
1126 // Verify failures are preserved
1127 if let Error::AllServersFailedToInit { count, failures: f } = err {
1128 assert_eq!(count, 2);
1129 assert_eq!(f.len(), 2);
1130 assert_eq!(f[0].language_id, "rust");
1131 assert_eq!(f[1].language_id, "python");
1132 } else {
1133 panic!("Expected AllServersFailedToInit error");
1134 }
1135 }
1136
1137 #[test]
1138 fn test_graceful_degradation_with_empty_config() {
1139 let result = ServerInitResult::new();
1140
1141 // Empty config means no servers configured
1142 assert!(!result.all_failed());
1143 assert!(!result.partial_success());
1144 assert!(!result.has_servers());
1145 assert_eq!(result.server_count(), 0);
1146 assert_eq!(result.failure_count(), 0);
1147 }
1148
1149 #[test]
1150 fn test_server_spawn_failure_display() {
1151 let failure = ServerSpawnFailure {
1152 server_id: ServerId::from("typescript"),
1153 language_id: "typescript".to_string(),
1154 command: "tsserver".to_string(),
1155 message: "executable not found in PATH".to_string(),
1156 };
1157
1158 let display = failure.to_string();
1159 assert!(display.contains("typescript"));
1160 assert!(display.contains("tsserver"));
1161 assert!(display.contains("executable not found"));
1162 }
1163
1164 #[test]
1165 fn test_result_helpers_consistency() {
1166 let mut result = ServerInitResult::new();
1167
1168 // Initially empty
1169 assert!(!result.has_servers());
1170 assert!(!result.all_failed());
1171 assert!(!result.partial_success());
1172
1173 // Add a failure
1174 result.add_failure(ServerSpawnFailure {
1175 server_id: ServerId::from("go"),
1176 language_id: "go".to_string(),
1177 command: "gopls".to_string(),
1178 message: "error".to_string(),
1179 });
1180
1181 assert!(result.all_failed());
1182 assert!(!result.has_servers());
1183 assert!(!result.partial_success());
1184 }
1185
1186 #[tokio::test]
1187 async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
1188 use crate::config::{LspServerConfig, WorkspaceConfig};
1189
1190 // A configured server whose command cannot spawn used to make serve()
1191 // fail synchronously with NoServersAvailable / AllServersFailedToInit.
1192 // LSP initialization now runs in a background task so the MCP
1193 // `initialize` handshake is never blocked, which means the spawn
1194 // failure is handled in the background instead: serve() starts the MCP
1195 // server in degraded mode (mirroring `test_serve_starts_with_empty_config`)
1196 // rather than failing fast. Any error it surfaces must therefore be a
1197 // transport/MCP error from the closed test connection, NOT a fail-fast
1198 // server-availability error.
1199 let config = ServerConfig {
1200 workspace: WorkspaceConfig {
1201 roots: vec![PathBuf::from("/tmp/test-workspace")],
1202 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1203 language_extensions: vec![],
1204 heuristics_max_depth: 10,
1205 max_documents: DEFAULT_MAX_DOCUMENTS,
1206 max_file_size: DEFAULT_MAX_FILE_SIZE,
1207 },
1208 lsp_servers: vec![LspServerConfig {
1209 language_id: "rust".to_string(),
1210 command: "nonexistent-command-that-will-fail-12345".to_string(),
1211 args: vec![],
1212 env: std::collections::HashMap::new(),
1213 file_patterns: vec!["**/*.rs".to_string()],
1214 initialization_options: None,
1215 timeout_seconds: 10,
1216 request_timeout_seconds: 10,
1217 heuristics: None,
1218 name: None,
1219 handles: None,
1220 }],
1221 project_config_ignored: false,
1222 };
1223
1224 // serve() proceeds to run the MCP server and blocks on the stdio
1225 // transport until EOF; bound it so the test can't hang if stdin stays
1226 // open (e.g. under multi-threaded `cargo test`, where several serve()
1227 // tests share the process stdin).
1228 let outcome =
1229 tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1230
1231 match outcome {
1232 // Still serving after the deadline => it did not fail fast. Good.
1233 Err(_elapsed) => {}
1234 // Transport closed cleanly. Also fine.
1235 Ok(Ok(())) => {}
1236 // It returned an error: it must not be a fail-fast availability error.
1237 Ok(Err(err)) => assert!(
1238 !matches!(err, Error::NoServersAvailable(_))
1239 && !matches!(err, Error::AllServersFailedToInit { .. }),
1240 "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
1241 ),
1242 }
1243 }
1244
1245 #[tokio::test]
1246 async fn test_serve_starts_with_empty_config() {
1247 use crate::config::WorkspaceConfig;
1248
1249 // Server starts in protocol-only mode when no LSP servers are configured.
1250 // serve() blocks until the MCP transport closes, so it will error with a
1251 // connection/transport error — not NoServersAvailable.
1252 let config = ServerConfig {
1253 workspace: WorkspaceConfig {
1254 roots: vec![PathBuf::from("/tmp/test-workspace")],
1255 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1256 language_extensions: vec![],
1257 heuristics_max_depth: 10,
1258 max_documents: DEFAULT_MAX_DOCUMENTS,
1259 max_file_size: DEFAULT_MAX_FILE_SIZE,
1260 },
1261 lsp_servers: vec![],
1262 project_config_ignored: false,
1263 };
1264
1265 let result = serve(config).await;
1266
1267 // serve() may succeed or fail with a transport error, but must NOT
1268 // return NoServersAvailable when the config simply has no servers.
1269 if let Err(ref err) = result {
1270 assert!(
1271 !matches!(err, Error::NoServersAvailable(_)),
1272 "serve() must not return NoServersAvailable for empty lsp_servers config"
1273 );
1274 }
1275 }
1276
1277 /// #282: a `ServerConfig` built programmatically (not via `load`/
1278 /// `load_from`, which already run `validate()`) previously skipped
1279 /// validation entirely, so `serve`/`serve_with` never rejected it —
1280 /// misconfiguration only surfaced later as silent accessor-level
1281 /// clamping. `serve` delegates straight to `serve_with`, so
1282 /// exercising it here also covers `serve_with`'s own `validate()`
1283 /// call. `validate()` runs before any LSP spawn or transport setup,
1284 /// so this returns immediately without needing a timeout guard.
1285 #[tokio::test]
1286 async fn test_serve_rejects_invalid_caller_supplied_config() {
1287 use crate::config::{LspServerConfig, WorkspaceConfig};
1288
1289 let config = ServerConfig {
1290 workspace: WorkspaceConfig {
1291 roots: vec![PathBuf::from("/tmp/test-workspace")],
1292 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1293 language_extensions: vec![],
1294 heuristics_max_depth: 10,
1295 max_documents: DEFAULT_MAX_DOCUMENTS,
1296 max_file_size: DEFAULT_MAX_FILE_SIZE,
1297 },
1298 lsp_servers: vec![LspServerConfig {
1299 language_id: "rust".to_string(),
1300 command: String::new(),
1301 args: vec![],
1302 env: std::collections::HashMap::new(),
1303 file_patterns: vec!["**/*.rs".to_string()],
1304 initialization_options: None,
1305 timeout_seconds: 10,
1306 request_timeout_seconds: 10,
1307 heuristics: None,
1308 name: None,
1309 handles: None,
1310 }],
1311 project_config_ignored: false,
1312 };
1313
1314 // `validate()` runs before any spawn/transport work and should
1315 // return immediately; bound it anyway so a regression that lets
1316 // an invalid config reach the stdio transport fails fast with a
1317 // clear timeout instead of hanging nextest for the default 120s
1318 // (mirroring the guard on `test_serve_degrades_when_all_servers_fail_to_spawn`).
1319 let outcome =
1320 tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1321
1322 match outcome {
1323 Err(elapsed) => panic!(
1324 "serve() must reject the invalid config immediately, not hang until \
1325 timeout: {elapsed}"
1326 ),
1327 Ok(result) => assert!(
1328 matches!(result, Err(Error::InvalidConfig(_))),
1329 "serve() must reject a caller-supplied config with an empty `command` via \
1330 Error::InvalidConfig, matching the load_from path; got: {result:?}"
1331 ),
1332 }
1333 }
1334
1335 /// #241: `serve_with`'s post-transport shutdown sequence must drain
1336 /// registered LSP servers rather than orphaning them. Exercises
1337 /// `shutdown()` directly (the exact code `serve_with` runs after its
1338 /// transport future returns) against a `Translator` with a real,
1339 /// registered `LspServer` — `serve_with` itself can't be driven
1340 /// through this path in a portable unit test, since it only
1341 /// registers a server after a successful LSP `initialize` handshake,
1342 /// which requires a real language server binary.
1343 #[tokio::test]
1344 async fn test_shutdown_drains_registered_lsp_server() {
1345 let translator = Translator::new();
1346 translator.register_server("fake-server", crate::lsp::fake_lsp_server());
1347 assert_eq!(translator.registered_server_count(), 1);
1348
1349 let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
1350
1351 let result = tokio::time::timeout(
1352 std::time::Duration::from_secs(20),
1353 super::super::shutdown(&cancel_tx, &translator, None),
1354 )
1355 .await;
1356
1357 assert!(
1358 result.is_ok(),
1359 "shutdown must not hang against a non-responsive mock LSP server"
1360 );
1361 assert_eq!(
1362 translator.registered_server_count(),
1363 0,
1364 "shutdown must drain every registered LSP server"
1365 );
1366 assert!(
1367 *cancel_rx.borrow(),
1368 "shutdown must signal background pump tasks to exit"
1369 );
1370 }
1371
1372 /// #196: `shutdown` must await the background LSP init task's
1373 /// `JoinHandle` (rather than leaving it detached) so a panic inside
1374 /// it surfaces as an `error!` log instead of being silently dropped.
1375 #[tokio::test]
1376 async fn test_shutdown_awaits_background_init_task() {
1377 use std::sync::atomic::{AtomicBool, Ordering};
1378
1379 let translator = Translator::new();
1380 let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
1381
1382 let completed = Arc::new(AtomicBool::new(false));
1383 let completed_clone = Arc::clone(&completed);
1384 let handle = tokio::spawn(async move {
1385 completed_clone.store(true, Ordering::SeqCst);
1386 });
1387
1388 let result = tokio::time::timeout(
1389 std::time::Duration::from_secs(5),
1390 super::super::shutdown(&cancel_tx, &translator, Some(handle)),
1391 )
1392 .await;
1393
1394 assert!(result.is_ok(), "shutdown must not hang on a live handle");
1395 assert!(
1396 completed.load(Ordering::SeqCst),
1397 "shutdown must await the background init task before returning"
1398 );
1399 }
1400
1401 /// A timed-out background init task must actually be stopped
1402 /// (`JoinHandle::abort`), not merely detached: awaiting the handle
1403 /// *by value* inside `tokio::time::timeout` would drop only the
1404 /// `JoinHandle` on timeout, which detaches the task without
1405 /// cancelling it — it keeps running (and its future is never
1406 /// dropped) despite the "timed out waiting ... to stop" log.
1407 ///
1408 /// Tests `await_lsp_init_handle` directly with a millisecond-scale
1409 /// `timeout` (rather than going through `shutdown` with the real
1410 /// multi-second `LSP_INIT_TASK_SHUTDOWN_TIMEOUT`) so this stays
1411 /// fast. A `completed`-style flag set at the end of the task
1412 /// couldn't tell "aborted" from "merely detached" apart here either
1413 /// way, since the task hasn't finished its (deliberately long)
1414 /// sleep yet in both cases — so this uses a `Drop`-signaling guard
1415 /// held across the `.await` instead: `abort()` drops the task's
1416 /// future promptly (well inside the grace period below), while a
1417 /// detached-but-still-running task would only drop it once its
1418 /// sleep actually finishes.
1419 #[tokio::test]
1420 async fn test_await_lsp_init_handle_aborts_on_timeout() {
1421 use std::sync::atomic::{AtomicBool, Ordering};
1422
1423 struct DropFlag(Arc<AtomicBool>);
1424 impl Drop for DropFlag {
1425 fn drop(&mut self) {
1426 self.0.store(true, Ordering::SeqCst);
1427 }
1428 }
1429
1430 let future_dropped = Arc::new(AtomicBool::new(false));
1431 let guard = DropFlag(Arc::clone(&future_dropped));
1432 let handle = tokio::spawn(async move {
1433 let _guard = guard;
1434 // Far longer than the timeout below, so it only elapses if
1435 // the task is genuinely aborted rather than left running.
1436 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1437 });
1438
1439 super::super::await_lsp_init_handle(handle, std::time::Duration::from_millis(20)).await;
1440
1441 // Give the just-aborted task's cancellation a moment to land.
1442 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1443 assert!(
1444 future_dropped.load(Ordering::SeqCst),
1445 "timed-out background init task's future must be dropped via abort(), \
1446 not left running detached until its own sleep completes"
1447 );
1448 }
1449
1450 /// #196: a panicking background init task must not hang or crash
1451 /// `shutdown`, and the panic must actually be logged (not merely
1452 /// swallowed while `shutdown` happens not to hang for other
1453 /// reasons) — asserted via a captured `tracing` event rather than
1454 /// just checking completion.
1455 #[tokio::test]
1456 async fn test_await_lsp_init_handle_logs_panic() {
1457 use tracing_subscriber::layer::SubscriberExt as _;
1458
1459 let handle = tokio::spawn(async {
1460 panic!("simulated background LSP init panic");
1461 });
1462
1463 let captured = CapturedMessages::default();
1464 let subscriber = tracing_subscriber::registry().with(captured.clone());
1465 let guard = tracing::subscriber::set_default(subscriber);
1466
1467 super::super::await_lsp_init_handle(handle, std::time::Duration::from_secs(5)).await;
1468
1469 drop(guard);
1470
1471 let messages = captured.0.lock().unwrap().clone();
1472 assert!(
1473 messages
1474 .iter()
1475 .any(|m| m.contains("Background LSP initialization task failed")),
1476 "expected an error! log for the panicking background init task, got: {messages:?}"
1477 );
1478 }
1479
1480 /// Captures `tracing` events emitted while a closure runs. Mirrors
1481 /// `transport::tests::http_tests::CapturedMessages` — duplicated
1482 /// rather than shared since this crate has no common test-support
1483 /// module and the two live in separate, non-`pub` test submodules.
1484 #[derive(Clone, Default)]
1485 struct CapturedMessages(Arc<std::sync::Mutex<Vec<String>>>);
1486
1487 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedMessages {
1488 fn on_event(
1489 &self,
1490 event: &tracing::Event<'_>,
1491 _ctx: tracing_subscriber::layer::Context<'_, S>,
1492 ) {
1493 struct MessageVisitor(String);
1494 impl tracing::field::Visit for MessageVisitor {
1495 fn record_debug(
1496 &mut self,
1497 field: &tracing::field::Field,
1498 value: &dyn std::fmt::Debug,
1499 ) {
1500 if field.name() == "message" {
1501 self.0 = format!("{value:?}");
1502 }
1503 }
1504 }
1505 let mut visitor = MessageVisitor(String::new());
1506 event.record(&mut visitor);
1507 self.0.lock().unwrap().push(visitor.0);
1508 }
1509 }
1510 }
1511
1512 // ------------------------------------------------------------------
1513 // diagnostics_pump unit tests
1514 // ------------------------------------------------------------------
1515
1516 #[allow(clippy::unwrap_used, clippy::expect_used)]
1517 mod pump_tests {
1518 use lsp_types::{PublishDiagnosticsParams, Uri};
1519 use tokio::sync::{mpsc, watch};
1520
1521 use super::*;
1522
1523 fn make_cache() -> Arc<Mutex<NotificationCache>> {
1524 Arc::new(Mutex::new(NotificationCache::new()))
1525 }
1526
1527 fn make_subs() -> Arc<ResourceSubscriptions> {
1528 Arc::new(ResourceSubscriptions::new())
1529 }
1530
1531 type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
1532
1533 fn make_peer_cell() -> PeerCell {
1534 Arc::new(OnceCell::new())
1535 }
1536
1537 /// Empty workspace roots: `diagnostic_path_in_workspace` allows any
1538 /// URI in this mode, matching `validate_path_against_roots`, so these
1539 /// pump-mechanics tests don't need to construct real workspace paths.
1540 fn no_workspace_roots() -> Arc<[PathBuf]> {
1541 Arc::from([])
1542 }
1543
1544 /// `PublishDiagnostics` is cached even when the peer is not yet connected.
1545 #[tokio::test]
1546 async fn test_pump_caches_before_peer_set() {
1547 let cache = make_cache();
1548 let subs = make_subs();
1549 let peer_cell = make_peer_cell();
1550 let (tx, rx) = mpsc::channel(8);
1551 // Keep _cancel_tx alive: dropping it causes cancel_rx.changed() to return Err,
1552 // which makes the pump exit before processing any messages.
1553 let (_cancel_tx, cancel_rx) = watch::channel(false);
1554
1555 let c = Arc::clone(&cache);
1556 tokio::spawn(diagnostics_pump(
1557 ServerId::from("rust"),
1558 rx,
1559 cancel_rx,
1560 true,
1561 PumpShared {
1562 notification_cache: c,
1563 subs: Arc::clone(&subs),
1564 peer_cell: Arc::clone(&peer_cell),
1565 workspace_roots: no_workspace_roots(),
1566 },
1567 ));
1568
1569 let uri: Uri = "file:///test/main.rs".parse().unwrap();
1570 tx.send(LspNotification::PublishDiagnostics(
1571 PublishDiagnosticsParams {
1572 uri: uri.clone(),
1573 diagnostics: vec![],
1574 version: None,
1575 },
1576 ))
1577 .await
1578 .unwrap();
1579 drop(tx);
1580
1581 // Poll until the pump processes the message or we time out.
1582 let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1583 loop {
1584 tokio::task::yield_now().await;
1585 let found = {
1586 let guard = cache.lock().await;
1587 guard.get_diagnostics(uri.as_str()).is_some()
1588 };
1589 if found {
1590 return true;
1591 }
1592 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1593 }
1594 })
1595 .await
1596 .expect("pump did not cache diagnostics within 5 s");
1597 assert!(cached, "diagnostics should be cached before peer is set");
1598 }
1599
1600 /// #234 (S1 hardening): diagnostics for URIs outside the configured
1601 /// workspace roots must be dropped rather than cached, closing the
1602 /// vector where a misbehaving server floods the FIFO-bounded cache
1603 /// with fabricated URIs to evict every legitimate entry.
1604 #[tokio::test]
1605 async fn test_pump_drops_diagnostics_outside_workspace_roots() {
1606 let cache = make_cache();
1607 let subs = make_subs();
1608 let peer_cell = make_peer_cell();
1609 let (tx, rx) = mpsc::channel(8);
1610 let (_cancel_tx, cancel_rx) = watch::channel(false);
1611
1612 // See `test_diagnostic_path_in_workspace_accepts_uri_under_root`
1613 // for why Windows needs a drive-letter path here.
1614 #[cfg(windows)]
1615 let (workspace_root, outside_uri_str, inside_uri_str) = (
1616 PathBuf::from(r"C:\workspace"),
1617 "file:///C:/etc/passwd",
1618 "file:///C:/workspace/src/main.rs",
1619 );
1620 #[cfg(not(windows))]
1621 let (workspace_root, outside_uri_str, inside_uri_str) = (
1622 PathBuf::from("/workspace"),
1623 "file:///etc/passwd",
1624 "file:///workspace/src/main.rs",
1625 );
1626 let workspace_roots: Arc<[PathBuf]> = Arc::from([workspace_root]);
1627
1628 tokio::spawn(diagnostics_pump(
1629 ServerId::from("rust"),
1630 rx,
1631 cancel_rx,
1632 true,
1633 PumpShared {
1634 notification_cache: Arc::clone(&cache),
1635 subs: Arc::clone(&subs),
1636 peer_cell: Arc::clone(&peer_cell),
1637 workspace_roots,
1638 },
1639 ));
1640
1641 let outside_uri: Uri = outside_uri_str.parse().unwrap();
1642 let inside_uri: Uri = inside_uri_str.parse().unwrap();
1643
1644 tx.send(LspNotification::PublishDiagnostics(
1645 PublishDiagnosticsParams {
1646 uri: outside_uri.clone(),
1647 diagnostics: vec![],
1648 version: None,
1649 },
1650 ))
1651 .await
1652 .unwrap();
1653 tx.send(LspNotification::PublishDiagnostics(
1654 PublishDiagnosticsParams {
1655 uri: inside_uri.clone(),
1656 diagnostics: vec![],
1657 version: None,
1658 },
1659 ))
1660 .await
1661 .unwrap();
1662 drop(tx);
1663
1664 // Poll until the (later-sent) in-workspace sentinel is cached --
1665 // proves the pump already processed the earlier out-of-workspace
1666 // message too, since the channel preserves send order.
1667 tokio::time::timeout(std::time::Duration::from_secs(5), async {
1668 loop {
1669 {
1670 let guard = cache.lock().await;
1671 if guard.get_diagnostics(inside_uri.as_str()).is_some() {
1672 return;
1673 }
1674 }
1675 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1676 }
1677 })
1678 .await
1679 .expect("pump did not cache in-workspace diagnostics within 5 s");
1680
1681 let found_outside = cache
1682 .lock()
1683 .await
1684 .get_diagnostics(outside_uri.as_str())
1685 .is_some();
1686 assert!(
1687 !found_outside,
1688 "diagnostics for a URI outside workspace roots must not be cached"
1689 );
1690 }
1691
1692 /// Pump exits cleanly when the cancel watch sends `true`.
1693 #[tokio::test]
1694 async fn test_pump_exits_on_cancel() {
1695 let cache = make_cache();
1696 let subs = make_subs();
1697 let peer_cell = make_peer_cell();
1698 let (_tx, rx) = mpsc::channel::<LspNotification>(8);
1699 let (cancel_tx, cancel_rx) = watch::channel(false);
1700
1701 let handle = tokio::spawn(diagnostics_pump(
1702 ServerId::from("rust"),
1703 rx,
1704 cancel_rx,
1705 true,
1706 PumpShared {
1707 notification_cache: cache,
1708 subs,
1709 peer_cell,
1710 workspace_roots: no_workspace_roots(),
1711 },
1712 ));
1713
1714 cancel_tx.send(true).unwrap();
1715 // Pump must finish within a short time after cancellation.
1716 tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1717 .await
1718 .expect("pump did not exit within timeout")
1719 .unwrap();
1720 }
1721
1722 /// Pump exits when the cancel sender is dropped (Err branch).
1723 #[tokio::test]
1724 async fn test_pump_exits_when_cancel_sender_dropped() {
1725 let cache = make_cache();
1726 let subs = make_subs();
1727 let peer_cell = make_peer_cell();
1728 let (_tx, rx) = mpsc::channel::<LspNotification>(8);
1729 let (cancel_tx, cancel_rx) = watch::channel(false);
1730
1731 let handle = tokio::spawn(diagnostics_pump(
1732 ServerId::from("rust"),
1733 rx,
1734 cancel_rx,
1735 true,
1736 PumpShared {
1737 notification_cache: cache,
1738 subs,
1739 peer_cell,
1740 workspace_roots: no_workspace_roots(),
1741 },
1742 ));
1743
1744 drop(cancel_tx); // triggers Err in cancel_rx.changed()
1745 tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1746 .await
1747 .expect("pump did not exit within timeout")
1748 .unwrap();
1749 }
1750
1751 /// Regression test for #104: the pump must cache a notification promptly
1752 /// even while another task holds the translator lock for far longer than
1753 /// any acceptable pump latency. Before the `NotificationCache` split, the
1754 /// pump locked `Arc<Mutex<Translator>>` to cache diagnostics, so it would
1755 /// have stalled here until the holder released the lock.
1756 #[tokio::test]
1757 async fn test_pump_makes_progress_while_translator_lock_held() {
1758 let translator = Arc::new(Mutex::new(Translator::new()));
1759 let cache = make_cache();
1760 let subs = make_subs();
1761 let peer_cell = make_peer_cell();
1762 let (tx, rx) = mpsc::channel(8);
1763 let (_cancel_tx, cancel_rx) = watch::channel(false);
1764
1765 // Simulate a slow in-flight MCP request (e.g. `pull_diagnostics`)
1766 // holding the translator lock across an LSP round-trip.
1767 let lock_acquired = Arc::new(tokio::sync::Notify::new());
1768 let notify = Arc::clone(&lock_acquired);
1769 let holder = tokio::spawn(async move {
1770 let _guard = translator.lock().await;
1771 notify.notify_one();
1772 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1773 });
1774 lock_acquired.notified().await;
1775
1776 tokio::spawn(diagnostics_pump(
1777 ServerId::from("rust"),
1778 rx,
1779 cancel_rx,
1780 true,
1781 PumpShared {
1782 notification_cache: Arc::clone(&cache),
1783 subs,
1784 peer_cell,
1785 workspace_roots: no_workspace_roots(),
1786 },
1787 ));
1788
1789 let uri: Uri = "file:///test/locked.rs".parse().unwrap();
1790 tx.send(LspNotification::PublishDiagnostics(
1791 PublishDiagnosticsParams {
1792 uri: uri.clone(),
1793 diagnostics: vec![],
1794 version: None,
1795 },
1796 ))
1797 .await
1798 .unwrap();
1799 drop(tx);
1800
1801 // Well within the 2 s translator lock hold: a translator-locking
1802 // pump would still be blocked at this point.
1803 tokio::time::timeout(std::time::Duration::from_millis(500), async {
1804 loop {
1805 {
1806 let guard = cache.lock().await;
1807 if guard.get_diagnostics(uri.as_str()).is_some() {
1808 return;
1809 }
1810 }
1811 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1812 }
1813 })
1814 .await
1815 .expect("pump stalled behind translator lock");
1816
1817 holder.await.unwrap();
1818 }
1819 }
1820}