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