Skip to main content

mcpls_core/lsp/
lifecycle.rs

1//! LSP server lifecycle management.
2//!
3//! This module handles the complete lifecycle of an LSP server:
4//! 1. Spawn server process
5//! 2. Initialize → initialized handshake
6//! 3. Capability negotiation
7//! 4. Active request handling
8//! 5. Graceful shutdown sequence
9
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::Arc;
13
14use lsp_types::{
15    ClientCapabilities, ClientInfo, DidChangeConfigurationNotification,
16    DidChangeConfigurationParams, ExitNotification, GeneralClientCapabilities, InitializeParams,
17    InitializeRequest, InitializeResult, InitializedNotification, InitializedParams,
18    PositionEncodingKind, Request, ServerCapabilities, ShutdownRequest, StaleRequestSupportOptions,
19    SymbolKind, WorkspaceFolder,
20};
21use tokio::process::Command;
22use tokio::sync::mpsc;
23use tokio::time::{Duration, Instant, timeout, timeout_at};
24use tracing::{debug, info, warn};
25
26use crate::bridge::{WorkspaceRoots, try_path_to_uri};
27use crate::config::{LspServerConfig, LspSettings, PositionEncodings, ServerCommand};
28use crate::error::{
29    BackgroundTask, Error, InitFailureHint, InitPhase, Result, ServerSpawnFailure, StartupFailure,
30    StdioStream, UntrustedRefusal,
31};
32use crate::lsp::client::{LspClient, SHUTDOWN_TIMEOUT};
33#[cfg(unix)]
34use crate::lsp::process::Binding;
35use crate::lsp::process::{MarkOutcome, ServerProcess};
36use crate::lsp::stderr::{EofWait, StderrCapture};
37use crate::lsp::transport::LspTransport;
38use crate::lsp::types::LspNotification;
39use crate::lsp::{
40    CONTENT_MODIFIED_RETRY_METHODS, ManagedEnvVar, NotificationInbox, NotificationSink, ParentEnv,
41    PublishReader, ServerMessage, process_env, tsserver_pin,
42};
43use crate::redaction::Redactions;
44
45/// Environment variables passed through to a spawned LSP server even though
46/// its environment is otherwise cleared.
47///
48/// `PATH` lets the server resolve its own toolchain (e.g. rustup shims, venv
49/// binaries); `HOME`/`USERPROFILE` and `TMPDIR`/`TEMP`/`TMP` let it find user
50/// config/cache and scratch directories.
51///
52/// This list is not exhaustive: session-specific values that cannot be
53/// hardcoded into a static [`LspServerConfig::env`] table (e.g.
54/// `SSH_AUTH_SOCK`, which changes every login session) have no way through
55/// today. See [`LspServerConfig::env`] for the config-level override/addition
56/// mechanism this list feeds into.
57const ENV_PASSTHROUGH: &[&str] = &[
58    ManagedEnvVar::Path.name(),
59    ManagedEnvVar::Home.name(),
60    ManagedEnvVar::UserProfile.name(),
61    "TMPDIR",
62    "TEMP",
63    "TMP",
64];
65
66/// Upper bound [`LspServer::shutdown`] waits for the child process to exit on
67/// its own after sending the LSP `exit` notification, before falling back to
68/// killing its process tree. Only the leader is awaited here.
69const CHILD_EXIT_GRACE: Duration = Duration::from_secs(3);
70
71/// Least time the final process-tree sweep may take even when the shutdown
72/// deadline is already spent, so a whole shutdown stays within
73/// `SHUTDOWN_TIMEOUT` plus this.
74const MIN_SWEEP_BUDGET: Duration = Duration::from_secs(3);
75
76/// How long [`LspServer::terminate`] lets the leader process exit on its own
77/// after the `shutdown` handshake, before the process tree is killed.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ExitGrace {
80    /// Up to the rest of the handshake budget, at most [`CHILD_EXIT_GRACE`]
81    /// (normal shutdown).
82    WithinBudget,
83    /// A server that answered `shutdown` may take this long to exit, since a
84    /// healthy server can be slow to flush; one that did not answer is killed
85    /// at once.
86    IfAnswered(Duration),
87}
88
89/// How long a failed `initialize` waits for the child to be reaped before
90/// concluding it is still running and keeping the original error.
91const EARLY_EXIT_PROBE: Duration = Duration::from_millis(500);
92
93/// Capacity of the diagnostics/log/showMessage notification channel (P3).
94///
95/// Raised from the pre-#422 value of 64: P1 makes mcpls advertise
96/// `window.workDoneProgress`, so a server may now attach progress reporting
97/// to ordinary request capabilities too, raising overall notification
98/// volume generally even though progress itself moved to its own
99/// [`LIFECYCLE_CHANNEL_CAPACITY`] lane.
100const NOTIFICATION_CHANNEL_CAPACITY: usize = 256;
101
102/// Capacity of the lifecycle channel (P3): `$/progress` `begin`/`end`
103/// frames plus `Other` (rust-analyzer's `experimental/serverStatus`).
104///
105/// O(phases), not O(reports): a `$/progress` `report` frame is filtered out
106/// before it ever reaches this channel (see
107/// `lsp::client::LspClient::message_loop_inner`), so this only needs to hold
108/// a handful of `begin`/`end` transitions even for a server with several
109/// concurrent operations, not a `report`-per-package stream like gopls can
110/// produce.
111const LIFECYCLE_CHANNEL_CAPACITY: usize = 128;
112
113/// Every symbol kind defined by LSP 3.17 and understood by mcpls, with the
114/// lowercase name a `workspace/symbol` `kind_filter` accepts for it.
115///
116/// Single source of truth for both the `initialize` request's `value_set`
117/// and the `kind_filter` validation in [`crate::bridge::translator::symbols`].
118pub const SUPPORTED_SYMBOL_KINDS: [(SymbolKind, &str); 26] = [
119    (SymbolKind::File, "file"),
120    (SymbolKind::Module, "module"),
121    (SymbolKind::Namespace, "namespace"),
122    (SymbolKind::Package, "package"),
123    (SymbolKind::Class, "class"),
124    (SymbolKind::Method, "method"),
125    (SymbolKind::Property, "property"),
126    (SymbolKind::Field, "field"),
127    (SymbolKind::Constructor, "constructor"),
128    (SymbolKind::Enum, "enum"),
129    (SymbolKind::Interface, "interface"),
130    (SymbolKind::Function, "function"),
131    (SymbolKind::Variable, "variable"),
132    (SymbolKind::Constant, "constant"),
133    (SymbolKind::String, "string"),
134    (SymbolKind::Number, "number"),
135    (SymbolKind::Boolean, "boolean"),
136    (SymbolKind::Array, "array"),
137    (SymbolKind::Object, "object"),
138    (SymbolKind::Key, "key"),
139    (SymbolKind::Null, "null"),
140    (SymbolKind::EnumMember, "enummember"),
141    (SymbolKind::Struct, "struct"),
142    (SymbolKind::Event, "event"),
143    (SymbolKind::Operator, "operator"),
144    (SymbolKind::TypeParameter, "typeparameter"),
145];
146
147/// The kinds of [`SUPPORTED_SYMBOL_KINDS`] as the `initialize` request lists them.
148fn advertised_symbol_kinds() -> Vec<SymbolKind> {
149    SUPPORTED_SYMBOL_KINDS
150        .iter()
151        .map(|&(kind, _)| kind)
152        .collect()
153}
154
155/// Windows-only additions to [`ENV_PASSTHROUGH`].
156///
157/// `SystemRoot`/`SystemDrive`/`windir` are required by the Windows process
158/// loader itself; `APPDATA`/`LOCALAPPDATA` are read by the Node-based default
159/// servers (pyright, typescript-language-server) for global config and
160/// cache; the rest are conventionally expected by Windows child processes.
161/// `NoDefaultCurrentDirectoryInExePath` passes through so a user who set it to
162/// keep the current directory out of executable lookups is not overridden by
163/// the environment clearing.
164#[cfg(windows)]
165const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
166    ManagedEnvVar::NoDefaultCurrentDirectoryInExePath.name(),
167    "SystemRoot",
168    "SystemDrive",
169    "windir",
170    "APPDATA",
171    "LOCALAPPDATA",
172    "ProgramData",
173    "ProgramFiles",
174    "COMSPEC",
175    "PATHEXT",
176    "NUMBER_OF_PROCESSORS",
177    "USERNAME",
178];
179
180/// State of an LSP server connection.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum ServerState {
183    /// Server has not been initialized.
184    Uninitialized,
185    /// Server is currently initializing.
186    Initializing,
187    /// Server is ready to handle requests.
188    Ready,
189    /// Server is shutting down.
190    ShuttingDown,
191    /// Server has been shut down.
192    Shutdown,
193}
194
195impl ServerState {
196    /// Check if the server is ready to handle requests.
197    #[must_use]
198    pub const fn is_ready(&self) -> bool {
199        matches!(self, Self::Ready)
200    }
201
202    /// Check if the server can accept new requests.
203    #[must_use]
204    pub const fn can_accept_requests(&self) -> bool {
205        matches!(self, Self::Ready)
206    }
207}
208
209/// Where a spawned server's working directory is.
210///
211/// A closed type so a spawn never silently depends on the working directory
212/// mcpls itself happens to have.
213///
214/// # Examples
215///
216/// ```
217/// use std::path::PathBuf;
218///
219/// use mcpls_core::lsp::ChildWorkingDir;
220///
221/// assert_eq!(ChildWorkingDir::default(), ChildWorkingDir::Inherit);
222/// let fixed = ChildWorkingDir::Fixed(PathBuf::from("project"));
223/// assert_ne!(fixed, ChildWorkingDir::Inherit);
224/// ```
225#[derive(Debug, Clone, PartialEq, Eq, Default)]
226pub enum ChildWorkingDir {
227    /// The server inherits mcpls's working directory.
228    #[default]
229    Inherit,
230    /// The server starts in this directory.
231    Fixed(PathBuf),
232}
233
234/// A tsserver pin mcpls chose, as opposed to one the user configured.
235///
236/// Remembered so a respawn can tell a pin that went stale (the install moved)
237/// from the user's own `tsserver.path`, which is never second-guessed.
238#[derive(Debug, Clone)]
239struct AutoPin {
240    tsserver: PathBuf,
241    /// What untrusted mode vetted the pin against, if it is on.
242    vetting: Option<tsserver_pin::UntrustedVetting>,
243}
244
245/// Configuration for LSP server initialization.
246///
247/// Built by [`Self::new`]; the fields are private so the effective
248/// `initialization_options` live in exactly one place, the
249/// [`LspServerConfig`] this was built from.
250#[derive(Debug, Clone)]
251pub struct ServerInitConfig {
252    server_config: LspServerConfig,
253    workspace_roots: WorkspaceRoots,
254    position_encodings: PositionEncodings,
255    redactions: Arc<Redactions>,
256    child_working_dir: ChildWorkingDir,
257    auto_pin: Option<AutoPin>,
258}
259
260impl ServerInitConfig {
261    /// Builds the config a server is spawned from.
262    ///
263    /// `server_config` must already carry the *effective* initialization
264    /// options (for example with the tsserver pin applied): they are sent as
265    /// they are and are the only copy.
266    ///
267    /// `position_encodings` is the preference order from
268    /// [`crate::config::WorkspaceConfig::position_encodings`], sent as
269    /// `capabilities.general.positionEncodings` during [`LspServer::spawn`]'s
270    /// `initialize` handshake. `redactions` holds the secrets of every
271    /// configured server, hidden in this server's output;
272    /// [`LspServer::spawn`] adds the secrets of this server's own
273    /// configuration and current environment, so an empty set still hides
274    /// them, and `serve` fills it with the secrets of every configured server
275    /// so a server that echoes another's secret has it hidden too.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use std::sync::Arc;
281    ///
282    /// use mcpls_core::bridge::WorkspaceRoots;
283    /// use mcpls_core::config::{LspServerConfig, PositionEncodings};
284    /// use mcpls_core::lsp::{ChildWorkingDir, ServerInitConfig};
285    ///
286    /// let config = ServerInitConfig::new(
287    ///     LspServerConfig::rust_analyzer(),
288    ///     WorkspaceRoots::default(),
289    ///     PositionEncodings::DEFAULT,
290    ///     Arc::default(),
291    /// );
292    /// assert_eq!(config.child_working_dir(), &ChildWorkingDir::Inherit);
293    /// ```
294    #[must_use]
295    pub const fn new(
296        server_config: LspServerConfig,
297        workspace_roots: WorkspaceRoots,
298        position_encodings: PositionEncodings,
299        redactions: Arc<Redactions>,
300    ) -> Self {
301        Self {
302            server_config,
303            workspace_roots,
304            position_encodings,
305            redactions,
306            child_working_dir: ChildWorkingDir::Inherit,
307            auto_pin: None,
308        }
309    }
310
311    /// This config with `tsserver` recorded as the pin mcpls chose, vetted
312    /// as `vetting` says in untrusted-workspace mode.
313    #[must_use]
314    pub(crate) fn with_auto_pin(
315        mut self,
316        tsserver: PathBuf,
317        vetting: Option<tsserver_pin::UntrustedVetting>,
318    ) -> Self {
319        self.auto_pin = Some(AutoPin { tsserver, vetting });
320        self
321    }
322
323    /// This config with the child started in `dir`.
324    ///
325    /// # Examples
326    ///
327    /// ```
328    /// use std::path::PathBuf;
329    /// use std::sync::Arc;
330    ///
331    /// use mcpls_core::bridge::WorkspaceRoots;
332    /// use mcpls_core::config::{LspServerConfig, PositionEncodings};
333    /// use mcpls_core::lsp::{ChildWorkingDir, ServerInitConfig};
334    ///
335    /// let dir = ChildWorkingDir::Fixed(PathBuf::from("project"));
336    /// let config = ServerInitConfig::new(
337    ///     LspServerConfig::rust_analyzer(),
338    ///     WorkspaceRoots::default(),
339    ///     PositionEncodings::DEFAULT,
340    ///     Arc::default(),
341    /// )
342    /// .with_child_working_dir(dir.clone());
343    /// assert_eq!(config.child_working_dir(), &dir);
344    /// ```
345    #[must_use]
346    pub fn with_child_working_dir(mut self, dir: ChildWorkingDir) -> Self {
347        self.child_working_dir = dir;
348        self
349    }
350
351    /// The server's configuration, carrying the effective initialization options.
352    #[must_use]
353    pub const fn server_config(&self) -> &LspServerConfig {
354        &self.server_config
355    }
356
357    /// The workspace roots sent as `workspaceFolders`.
358    #[must_use]
359    pub const fn workspace_roots(&self) -> &WorkspaceRoots {
360        &self.workspace_roots
361    }
362
363    /// The position encoding preference order offered to the server.
364    #[must_use]
365    pub const fn position_encodings(&self) -> &PositionEncodings {
366        &self.position_encodings
367    }
368
369    /// The secrets hidden in this server's output.
370    #[must_use]
371    pub const fn redactions(&self) -> &Arc<Redactions> {
372        &self.redactions
373    }
374
375    /// Where the child process starts.
376    #[must_use]
377    pub const fn child_working_dir(&self) -> &ChildWorkingDir {
378        &self.child_working_dir
379    }
380
381    /// Mutable access to the server config, for tests that adjust a fixture.
382    #[cfg(all(test, unix))]
383    pub(crate) const fn server_config_mut(&mut self) -> &mut LspServerConfig {
384        &mut self.server_config
385    }
386
387    /// The tsserver this config pins through `initialization_options.tsserver.path`.
388    #[must_use]
389    pub fn pinned_tsserver(&self) -> Option<PathBuf> {
390        tsserver_pin::configured_tsserver_path(self.server_config.initialization_options.as_ref())
391    }
392
393    /// The config a respawn of this server spawns from.
394    ///
395    /// Called by every respawn right before it spawns; the filesystem work
396    /// runs on the blocking pool and only for an auto-pinned server. A tsserver pin mcpls
397    /// chose whose canonical path changed (the install was upgraded, moved or
398    /// retargeted through a symlink) is resolved again and, in untrusted mode,
399    /// checked against the boundary it was first vetted against. A pin the
400    /// user configured is kept as is.
401    ///
402    /// # Errors
403    ///
404    /// [`Error::ServerFailedToStart`] with an untrusted-mode refusal when the
405    /// tsserver now resolves inside the workspace, or the server is now
406    /// launched in a way no tsserver can be pinned for (the same two checks
407    /// startup makes).
408    pub(crate) async fn for_respawn(&self) -> Result<Self> {
409        if self.auto_pin.is_none() {
410            return Ok(self.clone());
411        }
412        let config = self.clone();
413        crate::on_blocking_pool(move || config.plan_respawn())
414            .await
415            .map_err(|source| Error::TaskFailed {
416                task: crate::error::BackgroundTask::ServerPlanning,
417                source,
418            })?
419    }
420
421    /// The blocking half of [`Self::for_respawn`]: canonicalizes the pin and
422    /// re-plans the tsserver, so it runs on the blocking pool.
423    pub(crate) fn plan_respawn(&self) -> Result<Self> {
424        let Some(pin) = self
425            .auto_pin
426            .as_ref()
427            .filter(|pin| dunce::canonicalize(&pin.tsserver).ok().as_ref() != Some(&pin.tsserver))
428        else {
429            return Ok(self.clone());
430        };
431        let mut unpinned = self.server_config.clone();
432        unpinned.initialization_options = None;
433        let plan = tsserver_pin::plan_typescript(unpinned, process_env);
434        if let Some(vetting) = &pin.vetting {
435            plan.vet_untrusted(vetting)
436                .map_err(|refusal| self.refusal(refusal))?;
437        }
438        let (server_config, tsserver) = plan.apply(&self.workspace_roots);
439        let auto_pin = tsserver.map(|tsserver| AutoPin {
440            tsserver,
441            vetting: pin.vetting.clone(),
442        });
443        Ok(Self {
444            server_config,
445            auto_pin,
446            ..self.clone()
447        })
448    }
449
450    fn refusal(&self, refusal: UntrustedRefusal) -> Error {
451        Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
452            server_id: self.server_config.id(),
453            language_id: self.server_config.language_id.clone(),
454            command: self.server_config.command.server_command().clone(),
455            reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
456        }))
457    }
458}
459
460/// The terminal outcome of starting one configured server.
461///
462/// A closed type so a settled server is either running or has a recorded
463/// failure, never both and never neither.
464#[derive(Debug)]
465pub enum ServerStartOutcome {
466    /// The server initialized and is ready to register.
467    Started(Box<LspServer>),
468    /// The server failed to start.
469    Failed(ServerSpawnFailure),
470}
471
472/// Managed LSP server instance with capabilities and encoding.
473pub struct LspServer {
474    client: LspClient,
475    capabilities: ServerCapabilities,
476    position_encoding: PositionEncodingKind,
477    /// Receiver for the log and `showMessage` frames of the LSP server
478    /// ([`ServerMessage`]); diagnostics arrive on [`Self::publish_rx`].
479    /// Taken for a pump with [`Self::take_notification_inbox`].
480    notification_rx: mpsc::Receiver<ServerMessage>,
481    /// Receiver of the diagnostics mailbox: `textDocument/publishDiagnostics`
482    /// is coalesced per file and a publish it cannot hold is reported as lost
483    /// instead of being dropped (see [`PublishReader`]). Taken for a pump with
484    /// [`Self::take_notification_inbox`].
485    publish_rx: PublishReader,
486    /// Receiver for the lifecycle lane (P3): `$/progress` `begin`/`end`
487    /// frames and unrecognized notifications (which carry e.g.
488    /// rust-analyzer's `experimental/serverStatus`), kept separate from
489    /// [`Self::notification_rx`] so a high-volume diagnostics publisher can
490    /// never starve out a workspace-readiness signal, or vice versa.
491    ///
492    /// Extract this before registering the server -- see
493    /// [`Self::take_lifecycle_rx`].
494    lifecycle_rx: mpsc::Receiver<LspNotification>,
495    /// Child process handle. Kept alive for process lifetime management and
496    /// queried by [`Self::has_exited`] to detect a crash. [`LspServer::shutdown`]
497    /// waits for it to exit after sending `exit`; otherwise, or if that wait
498    /// times out, dropping it terminates the process via SIGKILL
499    /// (`kill_on_drop`). The process is also bound to the mcpls process
500    /// lifetime (see [`ServerProcess`]), so it dies with mcpls on any exit.
501    ///
502    /// `None` only for test fixtures that never spawn a real server process
503    /// (see `crate::test_lsp`, `Self::new_for_test_with_encoding`) --
504    /// [`Self::spawn`] always populates this with `Some`.
505    child: Option<ServerProcess>,
506    /// Config this server was spawned from; the single source of its routing
507    /// identity, respawn config and indexing policy.
508    init_config: ServerInitConfig,
509}
510
511impl std::fmt::Debug for LspServer {
512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513        f.debug_struct("LspServer")
514            .field("client", &self.client)
515            .field("capabilities", &self.capabilities)
516            .field("position_encoding", &self.position_encoding)
517            .field("notification_rx", &"<channel>")
518            .field("publish_rx", &"<mailbox>")
519            .field("lifecycle_rx", &"<channel>")
520            .field("child", &"<process>")
521            .field("id", &self.init_config.server_config.id())
522            .finish()
523    }
524}
525
526/// Resolve one environment variable as the spawned server would see it:
527/// the server config's `env` override wins, otherwise the parent value.
528///
529/// `parent_env` is injected so callers and tests need not touch the real
530/// process environment.
531pub fn child_env_var(
532    config: &LspServerConfig,
533    key: &str,
534    parent_env: impl ParentEnv,
535) -> Option<std::ffi::OsString> {
536    config
537        .env
538        .get(key)
539        .map(std::ffi::OsString::from)
540        .or_else(|| parent_env(key))
541}
542
543impl LspServer {
544    /// The config this server was spawned from.
545    pub(crate) const fn init_config(&self) -> &ServerInitConfig {
546        &self.init_config
547    }
548
549    /// Replace the config a respawn of this server would use.
550    #[cfg(test)]
551    #[cfg(unix)]
552    pub(crate) fn set_init_config(&mut self, config: ServerInitConfig) {
553        self.init_config = config;
554    }
555
556    /// Take the notification receiver out of this server, replacing it with a dummy channel.
557    fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<ServerMessage> {
558        let (_, dummy) = tokio::sync::mpsc::channel(1);
559        std::mem::replace(&mut self.notification_rx, dummy)
560    }
561
562    /// Take everything the notification lane delivers: the log/showMessage
563    /// receiver and the diagnostics mailbox, ready for a pump.
564    pub fn take_notification_inbox(&mut self) -> NotificationInbox {
565        NotificationInbox::new(self.take_notification_rx(), self.take_publish_rx())
566    }
567
568    /// Take the diagnostics mailbox out of this server, replacing it with a
569    /// closed one.
570    fn take_publish_rx(&mut self) -> PublishReader {
571        std::mem::replace(&mut self.publish_rx, PublishReader::closed())
572    }
573
574    /// Take the lifecycle receiver out of this server, replacing it with a
575    /// dummy channel -- the lifecycle-lane counterpart to
576    /// [`Self::take_notification_inbox`]. Extract this before registering the
577    /// server for a background pump task to drain.
578    pub fn take_lifecycle_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
579        let (_, dummy) = tokio::sync::mpsc::channel(1);
580        std::mem::replace(&mut self.lifecycle_rx, dummy)
581    }
582
583    /// Spawn and initialize LSP server.
584    ///
585    /// This performs the complete initialization sequence:
586    /// 1. Spawns the LSP server as a child process
587    /// 2. Sends initialize request with client capabilities
588    /// 3. Receives server capabilities from initialize response
589    /// 4. Sends initialized notification
590    /// 5. Sends `workspace/didChangeConfiguration` with the configured
591    ///    settings (null when unset), which servers that wait for a
592    ///    configuration push (pyright) need before they answer requests
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if:
597    /// - Server process fails to spawn
598    /// - Initialize request fails or times out
599    /// - Server returns error during initialization
600    /// - The `initialized` or `workspace/didChangeConfiguration` notification
601    ///   cannot be written ([`Error::LspInitFailed`])
602    pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
603        let own = Redactions::for_server(config.server_config(), current_environment());
604        let redactions = Arc::new(Redactions::union([config.redactions().as_ref(), &own]));
605        Self::log_spawn(config.server_config(), &redactions);
606
607        let command = Self::build_command(
608            config.server_config(),
609            config.child_working_dir(),
610            process_env,
611        );
612
613        // Log allowlist presence and an override count only — never the
614        // configured keys themselves, since `config.server_config.env` may
615        // hold secret-bearing names (e.g. `AWS_SECRET_ACCESS_KEY`) whose
616        // mere presence in a debug log would be its own disclosure.
617        let passthrough_present = {
618            let base = ENV_PASSTHROUGH
619                .iter()
620                .filter(|key| std::env::var_os(key).is_some())
621                .count();
622            #[cfg(windows)]
623            let windows = ENV_PASSTHROUGH_WINDOWS
624                .iter()
625                .filter(|key| std::env::var_os(key).is_some())
626                .count();
627            #[cfg(not(windows))]
628            let windows = 0;
629            base.saturating_add(windows)
630        };
631        debug!(
632            "Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
633             {} configured override(s) applied",
634            config.server_config.env.len()
635        );
636
637        let mut child = ServerProcess::spawn(command)
638            .map_err(|e| spawn_error(config.server_config.command.server_command().clone(), e))?;
639
640        let stdin = child
641            .take_stdin()
642            .ok_or(Error::StdioCapture(StdioStream::Stdin))?;
643        let stdout = child
644            .take_stdout()
645            .ok_or(Error::StdioCapture(StdioStream::Stdout))?;
646
647        let stderr = child
648            .take_stderr()
649            .ok_or(Error::StdioCapture(StdioStream::Stderr))?;
650        let stderr_capture = StderrCapture::start(stderr);
651
652        let transport = LspTransport::with_redactions(stdin, stdout, Arc::clone(&redactions));
653        let (notification_tx, notification_rx) = mpsc::channel(NOTIFICATION_CHANNEL_CAPACITY);
654        let (notification_sink, publish_rx) = NotificationSink::new(notification_tx);
655        let (lifecycle_tx, lifecycle_rx) = mpsc::channel(LIFECYCLE_CHANNEL_CAPACITY);
656        let client = LspClient::from_transport_with_notifications(
657            config.server_config.clone(),
658            transport,
659            notification_sink,
660            lifecycle_tx,
661            Arc::clone(&redactions),
662        );
663        #[cfg(unix)]
664        let process_id = initialize_process_id(child.binding());
665        #[cfg(windows)]
666        let process_id = Some(mcpls_process_id());
667        let (capabilities, position_encoding) =
668            match Self::initialize_as(&client, &config, process_id).await {
669                Ok(negotiated) => negotiated,
670                Err(init_error) if is_connection_loss(&init_error) => {
671                    return Err(Self::connection_loss_error(
672                        &config,
673                        &mut child,
674                        stderr_capture,
675                        &redactions,
676                        init_error,
677                    )
678                    .await);
679                }
680                Err(Error::LspInitFailed { phase, cause, .. }) => {
681                    // The server may be about to exit after printing its reason,
682                    // so wait the (bounded) end-of-file grace whether or not it
683                    // has exited yet.
684                    let stderr = stderr_capture.finish(EofWait::Grace, &redactions).await;
685                    let hint = Self::init_failure_hint(&config).await;
686                    return Err(Error::LspInitFailed {
687                        phase,
688                        cause: Box::new(redactions.sanitize_error(*cause)),
689                        hint,
690                        stderr,
691                    });
692                }
693                Err(init_error) => return Err(redactions.sanitize_error(init_error)),
694            };
695
696        info!("LSP server initialized successfully");
697
698        Ok(Self {
699            client,
700            capabilities,
701            position_encoding,
702            notification_rx,
703            publish_rx,
704            lifecycle_rx,
705            child: Some(child),
706            init_config: config,
707        })
708    }
709
710    /// The error for an `initialize` that ended in a lost connection: the
711    /// server exited (with its exit status) or the pipe closed first.
712    async fn connection_loss_error(
713        config: &ServerInitConfig,
714        child: &mut ServerProcess,
715        stderr_capture: StderrCapture,
716        redactions: &Redactions,
717        init_error: Error,
718    ) -> Error {
719        let exit_status = early_exit_status(child).await;
720        let eof_wait = if exit_status.is_some() {
721            EofWait::Grace
722        } else {
723            EofWait::Skip
724        };
725        let stderr = stderr_capture.finish(eof_wait, redactions).await;
726        let hint = Self::init_failure_hint(config).await;
727        match exit_status {
728            Some(status) => Error::ServerExitedDuringInit {
729                command: config.server_config.command.server_command().clone(),
730                exit_code: status.code(),
731                hint,
732                stderr,
733            },
734            None => Error::LspInitFailed {
735                phase: InitPhase::Initialize,
736                cause: Box::new(redactions.sanitize_error(init_error)),
737                hint,
738                stderr,
739            },
740        }
741    }
742
743    /// The guidance for a failed `initialize` of `config`, if its cause is known.
744    ///
745    /// Runs on the failure path only. It walks `PATH` and reads package
746    /// manifests, so it runs on the blocking pool; the hint is dropped when the
747    /// pool is shutting down.
748    async fn init_failure_hint(config: &ServerInitConfig) -> Option<InitFailureHint> {
749        let config = config.clone();
750        let hint = crate::on_blocking_pool(move || {
751            tsserver_pin::init_failure_hint(
752                config.server_config(),
753                config.workspace_roots(),
754                process_env,
755            )
756        })
757        .await;
758        hint.unwrap_or_else(|error| {
759            debug!("Dropped the init failure hint: {error}");
760            None
761        })
762    }
763
764    /// Logs the command and argument count at `info`, and the argument values
765    /// (redacted) only at `debug`.
766    fn log_spawn(server_config: &LspServerConfig, redactions: &Redactions) {
767        info!(
768            "Spawning LSP server: {} ({} arg(s))",
769            server_config.command,
770            server_config.args.len()
771        );
772        debug!(
773            "LSP server args: {:?}",
774            server_config
775                .args
776                .iter()
777                .map(|arg| redactions.apply(arg))
778                .collect::<Vec<_>>()
779        );
780    }
781
782    /// Build the child `Command` for a spawned LSP server, without spawning it.
783    ///
784    /// The child's environment is cleared, then [`ENV_PASSTHROUGH`] (plus
785    /// [`ENV_PASSTHROUGH_WINDOWS`] under `cfg(windows)`) is copied in from
786    /// `parent_env` for whichever of those keys it returns `Some` for, then
787    /// `config.env` is applied last so it can override any passthrough
788    /// value. `parent_env` is injected (production passes
789    /// `std::env::var_os`) so tests can supply a fixed environment without
790    /// racing on real process-global state.
791    fn build_command(
792        config: &LspServerConfig,
793        working_dir: &ChildWorkingDir,
794        parent_env: impl ParentEnv,
795    ) -> Command {
796        let mut command = Command::new(&config.command);
797        command.args(&config.args).env_clear();
798        if let ChildWorkingDir::Fixed(dir) = working_dir {
799            command.current_dir(dir);
800        }
801
802        for key in ENV_PASSTHROUGH {
803            if let Some(value) = child_env_var(config, key, &parent_env) {
804                command.env(key, value);
805            }
806        }
807        #[cfg(windows)]
808        for key in ENV_PASSTHROUGH_WINDOWS {
809            if let Some(value) = child_env_var(config, key, &parent_env) {
810                command.env(key, value);
811            }
812        }
813
814        command
815            .envs(config.env.iter())
816            .stdin(Stdio::piped())
817            .stdout(Stdio::piped())
818            .stderr(Stdio::piped());
819
820        command
821    }
822
823    /// Folding ranges with columns (`lineFoldingOnly: false`), the three
824    /// standard kinds, and collapsed text.
825    fn folding_range_capabilities() -> lsp_types::FoldingRangeClientCapabilities {
826        lsp_types::FoldingRangeClientCapabilities {
827            dynamic_registration: Some(false),
828            line_folding_only: Some(false),
829            folding_range_kind: Some(lsp_types::ClientFoldingRangeKindOptions {
830                value_set: Some(vec![
831                    lsp_types::FoldingRangeKind::Comment,
832                    lsp_types::FoldingRangeKind::Imports,
833                    lsp_types::FoldingRangeKind::Region,
834                ]),
835            }),
836            folding_range: Some(lsp_types::ClientFoldingRangeOptions {
837                collapsed_text: Some(true),
838            }),
839            ..Default::default()
840        }
841    }
842
843    /// Build the `capabilities` mcpls advertises in the `initialize`
844    /// request, given the configured position-encoding preference order.
845    ///
846    /// Extracted from [`Self::initialize`] (S2) so a unit test can assert
847    /// directly on the returned value -- in particular that
848    /// `window.work_done_progress == Some(true)` (P1), without which no
849    /// spec-compliant LSP server may ever initiate `$/progress` at all (see
850    /// the matching `window/workDoneProgress/create` allowlist entry in
851    /// [`crate::lsp::client::LspClient::server_request_result`]). Before
852    /// this existed, deleting those capability lines would silently no-op
853    /// the whole feature with an otherwise-green test suite.
854    ///
855    /// `workspace.configuration` is advertised only when `settings` are
856    /// configured: without them mcpls has nothing to answer with.
857    fn client_capabilities(
858        position_encodings: &PositionEncodings,
859        settings: Option<&LspSettings>,
860    ) -> ClientCapabilities {
861        ClientCapabilities {
862            general: Some(GeneralClientCapabilities {
863                position_encodings: Some(
864                    position_encodings
865                        .as_slice()
866                        .iter()
867                        .map(|encoding| encoding.to_kind())
868                        .collect(),
869                ),
870                stale_request_support: Some(StaleRequestSupportOptions {
871                    // mcpls does not implement active in-flight request
872                    // cancellation.
873                    cancel: false,
874                    retry_on_content_modified: CONTENT_MODIFIED_RETRY_METHODS
875                        .iter()
876                        .map(ToString::to_string)
877                        .collect(),
878                }),
879                ..Default::default()
880            }),
881            text_document: Some(lsp_types::TextDocumentClientCapabilities {
882                document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
883                    dynamic_registration: Some(false),
884                    symbol_kind: Some(lsp_types::ClientSymbolKindOptions {
885                        value_set: Some(advertised_symbol_kinds()),
886                    }),
887                    hierarchical_document_symbol_support: Some(true),
888                    ..Default::default()
889                }),
890                hover: Some(lsp_types::HoverClientCapabilities {
891                    dynamic_registration: Some(false),
892                    content_format: Some(vec![
893                        lsp_types::MarkupKind::Markdown,
894                        lsp_types::MarkupKind::PlainText,
895                    ]),
896                }),
897                definition: Some(lsp_types::DefinitionClientCapabilities {
898                    dynamic_registration: Some(false),
899                    link_support: Some(true),
900                }),
901                declaration: Some(lsp_types::DeclarationClientCapabilities {
902                    dynamic_registration: Some(false),
903                    link_support: Some(true),
904                }),
905                references: Some(lsp_types::ReferenceClientCapabilities {
906                    dynamic_registration: Some(false),
907                }),
908                // clangd and typescript-language-server answer `prepareProvider` only
909                // when the client advertises `prepareSupport`.
910                rename: Some(lsp_types::RenameClientCapabilities {
911                    dynamic_registration: Some(false),
912                    prepare_support: Some(true),
913                    prepare_support_default_behavior: Some(
914                        lsp_types::PrepareSupportDefaultBehavior::Identifier,
915                    ),
916                    ..Default::default()
917                }),
918                folding_range: Some(Self::folding_range_capabilities()),
919                // typescript-language-server publishes nothing unless this object is present;
920                // only `versionSupport`, the one field the diagnostics cache retains.
921                publish_diagnostics: Some(lsp_types::PublishDiagnosticsClientCapabilities {
922                    version_support: Some(true),
923                    ..Default::default()
924                }),
925                selection_range: Some(lsp_types::SelectionRangeClientCapabilities {
926                    dynamic_registration: Some(false),
927                }),
928                code_action: Some(lsp_types::CodeActionClientCapabilities {
929                    dynamic_registration: Some(false),
930                    data_support: Some(true),
931                    resolve_support: Some(lsp_types::ClientCodeActionResolveOptions {
932                        properties: vec!["edit".to_string()],
933                    }),
934                    // Declare supported action kinds so the server returns
935                    // CodeAction objects (not just legacy Command objects).
936                    code_action_literal_support: Some(lsp_types::ClientCodeActionLiteralOptions {
937                        code_action_kind: lsp_types::ClientCodeActionKindOptions {
938                            value_set: std::iter::once(lsp_types::CodeActionKind::Empty)
939                                .chain(
940                                    crate::bridge::CodeActionKindFilter::ALL
941                                        .map(lsp_types::CodeActionKind::from),
942                                )
943                                .collect(),
944                        },
945                    }),
946                    ..Default::default()
947                }),
948                ..Default::default()
949            }),
950            workspace: Some(lsp_types::WorkspaceClientCapabilities {
951                workspace_folders: Some(true),
952                configuration: settings.map(|_| true),
953                ..Default::default()
954            }),
955            // Required per LSP 3.17 before a server may send $/progress at all -- see this fn's doc.
956            window: Some(lsp_types::WindowClientCapabilities {
957                work_done_progress: Some(true),
958                ..Default::default()
959            }),
960            // Required for rust-analyzer to ever emit experimental/serverStatus; other servers ignore this unrecognized key.
961            experimental: Some(serde_json::json!({ "serverStatusNotification": true })),
962            ..Default::default()
963        }
964    }
965
966    /// Perform LSP initialization handshake.
967    ///
968    /// Sends initialize request and waits for response, then sends the
969    /// initialized and `workspace/didChangeConfiguration` notifications.
970    ///
971    /// The pushed settings are the configured per-server settings. Without
972    /// any they are null rather than `{}`: some servers rebuild their
973    /// preferences from an empty settings map and so drop their
974    /// `initialization_options`, while a non-map value is ignored. Because
975    /// configured settings are never empty (see [`LspSettings`]), servers
976    /// such as jdtls may replace their `initialization_options`-derived
977    /// preferences with them.
978    #[cfg(test)]
979    async fn initialize(
980        client: &LspClient,
981        config: &ServerInitConfig,
982    ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
983        Self::initialize_as(client, config, Some(mcpls_process_id())).await
984    }
985
986    /// As [`Self::initialize`], reporting `process_id` as the parent process
987    /// the server may watch (`None` is allowed by LSP and sent while a
988    /// lifeline watchdog is bound: a server that exits when mcpls vanishes
989    /// would otherwise race the watchdog's freeze of its descendants).
990    async fn initialize_as(
991        client: &LspClient,
992        config: &ServerInitConfig,
993        process_id: Option<i32>,
994    ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
995        debug!("Sending initialize request");
996        if config.server_config().initialization_options.is_some()
997            && config.server_config().settings.is_some()
998        {
999            warn!(
1000                "server `{}` has both initialization_options and settings: servers that pull \
1001                 workspace/configuration (rust-analyzer, jdtls) may replace the options with \
1002                 the settings",
1003                config.server_config.command
1004            );
1005        }
1006
1007        let workspace_folders: Vec<WorkspaceFolder> = config
1008            .workspace_roots()
1009            .canonical()
1010            .iter()
1011            .map(|root| workspace_folder(root))
1012            .collect::<Result<Vec<_>>>()?;
1013
1014        let params = InitializeParams {
1015            process_id,
1016            #[allow(
1017                deprecated,
1018                reason = "`root_uri` is deprecated but the struct literal must still set it"
1019            )]
1020            root_uri: None,
1021            initialization_options: config.server_config().initialization_options.clone(),
1022            capabilities: Self::client_capabilities(
1023                &config.position_encodings,
1024                config.server_config.settings.as_ref(),
1025            ),
1026            client_info: Some(ClientInfo {
1027                name: "mcpls".to_string(),
1028                version: Some(env!("CARGO_PKG_VERSION").to_string()),
1029            }),
1030            workspace_folders_initialize_params: lsp_types::WorkspaceFoldersInitializeParams {
1031                workspace_folders: Some(lsp_types::WorkspaceFolders::WorkspaceFolderList(
1032                    workspace_folders,
1033                )),
1034            },
1035            ..Default::default()
1036        };
1037
1038        // Use the server's configured timeout for the initialize handshake too,
1039        // not a hardcoded 30s: large solutions (e.g. a 130-project Unity .sln via
1040        // OmniSharp) take minutes to respond to `initialize`.
1041        let result: InitializeResult = client
1042            .request_typed::<InitializeRequest>(
1043                params,
1044                config.server_config.timeout_seconds.as_duration(),
1045            )
1046            .await
1047            .map_err(|e| {
1048                if is_connection_loss(&e) {
1049                    e
1050                } else {
1051                    Error::LspInitFailed {
1052                        phase: InitPhase::Initialize,
1053                        cause: Box::new(e),
1054                        hint: None,
1055                        stderr: None,
1056                    }
1057                }
1058            })?;
1059
1060        let position_encoding = result
1061            .capabilities
1062            .position_encoding
1063            .clone()
1064            .unwrap_or(PositionEncodingKind::UTF16);
1065
1066        debug!(
1067            "Server capabilities received, encoding: {:?}",
1068            position_encoding
1069        );
1070
1071        notify_handshake::<InitializedNotification>(
1072            client,
1073            InitPhase::Initialized,
1074            InitializedParams {},
1075        )
1076        .await?;
1077        notify_handshake::<DidChangeConfigurationNotification>(
1078            client,
1079            InitPhase::DidChangeConfiguration,
1080            DidChangeConfigurationParams {
1081                settings: config
1082                    .server_config
1083                    .settings
1084                    .as_ref()
1085                    .map_or(serde_json::Value::Null, LspSettings::to_value),
1086            },
1087        )
1088        .await?;
1089
1090        Ok((result.capabilities, position_encoding))
1091    }
1092
1093    /// Get server capabilities.
1094    #[must_use]
1095    pub const fn capabilities(&self) -> &ServerCapabilities {
1096        &self.capabilities
1097    }
1098
1099    /// Get negotiated position encoding.
1100    #[must_use]
1101    pub fn position_encoding(&self) -> PositionEncodingKind {
1102        self.position_encoding.clone()
1103    }
1104
1105    /// Get client for making requests.
1106    #[must_use]
1107    pub const fn client(&self) -> &LspClient {
1108        &self.client
1109    }
1110
1111    /// Non-blocking check for whether the child process has already exited.
1112    ///
1113    /// Uses [`tokio::process::Child::try_wait`] on the leader process, which never blocks waiting
1114    /// for the process: `true` means it is gone (crashed, killed, or exited
1115    /// on its own), and any [`LspClient`] obtained from [`Self::client`] is
1116    /// now permanently disconnected -- new requests through it fail with
1117    /// [`crate::error::Error::ServerTerminated`]. Callers that want to
1118    /// recover substitute a freshly [`Self::spawn`]ed replacement.
1119    ///
1120    /// Always returns `Ok(false)` for a test fixture with no real backing
1121    /// process (`self.child` is `None`) -- there is nothing to have exited.
1122    /// [`Self::spawn`] always populates `child`, so production code never
1123    /// observes this case.
1124    ///
1125    /// # Errors
1126    ///
1127    /// Returns an error if the OS fails to report the process's status.
1128    pub fn has_exited(&mut self) -> Result<bool> {
1129        match &mut self.child {
1130            Some(child) => Ok(child.try_wait()?.is_some()),
1131            None => Ok(false),
1132        }
1133    }
1134
1135    /// Whether this server can no longer serve requests: its child process
1136    /// has exited, or its message loop has stopped (e.g. it panicked) while
1137    /// the child is still running.
1138    ///
1139    /// Unlike [`Self::has_exited`], this catches a live child whose connection
1140    /// is already dead, which nothing else would ever respawn. Test fixtures
1141    /// with no child process are never reported dead.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Returns an error if the OS fails to report the process's status.
1146    pub(crate) fn is_dead(&mut self) -> Result<bool> {
1147        if self.has_exited()? {
1148            return Ok(true);
1149        }
1150        Ok(self.child.is_some() && self.client.is_message_loop_finished())
1151    }
1152
1153    /// Shutdown server gracefully, with an overall deadline of [`SHUTDOWN_TIMEOUT`].
1154    ///
1155    /// Sends the LSP `shutdown` request, waits for the response, has the
1156    /// lifeline watchdog freeze and record descendants that escaped the
1157    /// server's process group (Unix), sends the `exit` notification, stops the
1158    /// message loop, then waits up to a grace period (never past the overall
1159    /// deadline) for the leader process to exit on its own. Whatever is left,
1160    /// including escapees that left the group through `setsid`/`setpgid` and
1161    /// shared daemons such as Gradle or Bloop, is then killed and waited for,
1162    /// up to [`crate::lsp::LIFELINE_SWEEP_BUDGET`] beyond the deadline, so
1163    /// when this returns the server's whole tree is gone. If the escapee mark
1164    /// fails the `exit` notification is skipped and the tree is killed right
1165    /// away. A test fixture with no real backing process skips the process
1166    /// steps.
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns the first error of the handshake or the message-loop stop,
1171    /// including [`Error::ShutdownTimeout`] when the deadline elapsed. The
1172    /// process tree is still torn down regardless of whether this returns
1173    /// `Ok` or `Err`.
1174    pub async fn shutdown(mut self) -> Result<()> {
1175        self.terminate(SHUTDOWN_TIMEOUT, ExitGrace::WithinBudget)
1176            .await
1177    }
1178
1179    /// Stop the server and its whole process tree, leaving `self` usable as a
1180    /// dead server (`is_dead()` is `true` for a real process).
1181    ///
1182    /// Sends the LSP `shutdown` request, waits for the response, sends the
1183    /// `exit` notification and stops the message loop, all within `budget`.
1184    /// It then lets the leader process exit on its own for as long as `grace`
1185    /// allows, which is the only path that lets the server reap its own
1186    /// children. Whatever is left in the server's process group is then killed
1187    /// (on Unix this includes shared daemons such as Gradle or Bloop started
1188    /// by the server). A test fixture with no real backing process (`child`
1189    /// is `None`) skips the process steps.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns the first error of the handshake or the message-loop stop,
1194    /// including [`Error::ShutdownTimeout`] when `budget` elapsed. The
1195    /// process tree is still torn down regardless of whether this returns
1196    /// `Ok` or `Err`.
1197    #[allow(
1198        clippy::arithmetic_side_effects,
1199        reason = "SHUTDOWN_TIMEOUT and CHILD_EXIT_GRACE are small constants"
1200    )]
1201    pub(crate) async fn terminate(&mut self, budget: Duration, grace: ExitGrace) -> Result<()> {
1202        debug!("Shutting down LSP server");
1203
1204        let deadline = Instant::now() + budget;
1205        let client = &mut self.client;
1206        let shutdown_request_timeout = Duration::from_secs(5).min(budget);
1207
1208        let handshake: Result<MarkOutcome> = timeout_at(deadline, async {
1209            let _: serde_json::Value = client
1210                .request(
1211                    ShutdownRequest::METHOD.as_str(),
1212                    (),
1213                    shutdown_request_timeout,
1214                )
1215                .await?;
1216            #[cfg(unix)]
1217            let mark = match self.child.as_mut() {
1218                Some(child) => child.mark_escapees().await,
1219                None => MarkOutcome::Confirmed,
1220            };
1221            #[cfg(windows)]
1222            let mark = MarkOutcome::Confirmed;
1223            if mark == MarkOutcome::Confirmed {
1224                client.notify_typed::<ExitNotification>(()).await?;
1225            }
1226            Ok(mark)
1227        })
1228        .await
1229        .unwrap_or(Err(Error::ShutdownTimeout));
1230        let mark = handshake
1231            .as_ref()
1232            .map_or(MarkOutcome::Confirmed, |mark| *mark);
1233        let handshake = handshake.map(drop);
1234        let handshake = match client.shutdown_until(deadline).await {
1235            Ok(()) => handshake,
1236            Err(e) => handshake.and(Err(e)),
1237        };
1238
1239        if let Some(child) = &mut self.child {
1240            let child_deadline = match grace {
1241                _ if mark != MarkOutcome::Confirmed => Instant::now(),
1242                ExitGrace::WithinBudget => deadline.min(Instant::now() + CHILD_EXIT_GRACE),
1243                ExitGrace::IfAnswered(grace) if handshake.is_ok() => Instant::now() + grace,
1244                ExitGrace::IfAnswered(_) => Instant::now(),
1245            };
1246            match timeout_at(child_deadline, child.wait()).await {
1247                Ok(Ok(status)) => {
1248                    debug!(
1249                        ?status,
1250                        "LSP server process exited after `exit` notification"
1251                    );
1252                }
1253                Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
1254                Err(_) => warn!(
1255                    timeout = ?CHILD_EXIT_GRACE,
1256                    "LSP server process did not exit within grace period after `exit` \
1257                     notification, killing it"
1258                ),
1259            }
1260            let sweep_budget = deadline
1261                .saturating_duration_since(Instant::now())
1262                .clamp(MIN_SWEEP_BUDGET, crate::lsp::LIFELINE_SWEEP_BUDGET);
1263            child.terminate_tree(sweep_budget).await;
1264        }
1265
1266        handshake?;
1267        info!("LSP server shut down successfully");
1268        Ok(())
1269    }
1270
1271    /// Spawns and initializes one server, turning an error or a panic into
1272    /// that server's [`ServerStartOutcome::Failed`] so it can never affect a
1273    /// sibling. Logs the outcome with the id and elapsed time.
1274    ///
1275    /// Runs on the caller's task: dropping the future drops the child
1276    /// process it owns.
1277    pub(crate) async fn start_contained(config: &ServerInitConfig) -> ServerStartOutcome {
1278        Box::pin(contain(config, Self::spawn(config.clone()))).await
1279    }
1280}
1281
1282/// Drives `start` (one server's spawn and handshake) and classifies how it
1283/// ended; see [`LspServer::start_contained`].
1284async fn contain(
1285    config: &ServerInitConfig,
1286    start: impl std::future::Future<Output = Result<LspServer>>,
1287) -> ServerStartOutcome {
1288    let server_id = config.server_config.id();
1289    let language_id = config.server_config.language_id.clone();
1290    let command = config.server_config.command.server_command().clone();
1291    let started = Instant::now();
1292
1293    let reason = match crate::util::catch_panic(start).await {
1294        Ok(Ok(server)) => {
1295            info!(
1296                "Successfully spawned LSP server: {} ({}) in {:?}",
1297                server_id,
1298                command,
1299                started.elapsed()
1300            );
1301            return ServerStartOutcome::Started(Box::new(server));
1302        }
1303        Ok(Err(e)) => {
1304            tracing::error!(
1305                "Failed to spawn LSP server: {} ({}) after {:?}: {}",
1306                server_id,
1307                command,
1308                started.elapsed(),
1309                e
1310            );
1311            StartupFailure::Spawn(Arc::new(e))
1312        }
1313        Err(panicked) => {
1314            tracing::error!(
1315                "Starting LSP server {} ({}) panicked after {:?}: {}",
1316                server_id,
1317                command,
1318                started.elapsed(),
1319                panicked.message()
1320            );
1321            StartupFailure::InitTaskPanicked
1322        }
1323    };
1324    ServerStartOutcome::Failed(ServerSpawnFailure {
1325        server_id,
1326        language_id,
1327        command,
1328        reason,
1329    })
1330}
1331
1332fn mcpls_process_id() -> i32 {
1333    i32::try_from(std::process::id()).unwrap_or(i32::MAX)
1334}
1335
1336/// The `processId` to send: none while a lifeline watchdog is bound, the real
1337/// mcpls pid otherwise.
1338#[cfg(unix)]
1339fn initialize_process_id(binding: Binding) -> Option<i32> {
1340    match binding {
1341        Binding::Bound => None,
1342        Binding::Unbound => Some(mcpls_process_id()),
1343    }
1344}
1345
1346/// Sends one handshake notification, mapping a failed write to
1347/// [`Error::LspInitFailed`] naming the notification's method.
1348async fn notify_handshake<N>(client: &LspClient, phase: InitPhase, params: N::Params) -> Result<()>
1349where
1350    N: lsp_types::Notification,
1351{
1352    client
1353        .notify_typed::<N>(params)
1354        .await
1355        .map_err(|e| Error::LspInitFailed {
1356            phase,
1357            cause: Box::new(e),
1358            hint: None,
1359            stderr: None,
1360        })
1361}
1362
1363/// Whether `error` means the connection to the server is gone, as opposed to
1364/// the server answering with a failure of its own.
1365const fn is_connection_loss(error: &Error) -> bool {
1366    matches!(
1367        error,
1368        Error::ServerTerminated
1369            | Error::TaskFailed {
1370                task: BackgroundTask::LspReceiver,
1371                ..
1372            }
1373    )
1374}
1375
1376/// Exit status of `child` if it has exited (or does so within
1377/// [`EARLY_EXIT_PROBE`]), `None` if it is still running.
1378async fn early_exit_status(child: &mut ServerProcess) -> Option<std::process::ExitStatus> {
1379    timeout(EARLY_EXIT_PROBE, child.wait()).await.ok()?.ok()
1380}
1381
1382/// Classify a spawn failure: a missing executable gets its own variant so the
1383/// message can carry PATH and install guidance.
1384fn spawn_error(command: ServerCommand, source: std::io::Error) -> Error {
1385    if source.kind() == std::io::ErrorKind::NotFound {
1386        Error::ServerNotFound { command, source }
1387    } else {
1388        Error::ServerSpawnFailed { command, source }
1389    }
1390}
1391
1392/// The process environment as `(name, value)` pairs, skipping non-UTF-8 entries.
1393pub fn current_environment() -> Vec<(String, String)> {
1394    std::env::vars_os()
1395        .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
1396        .collect()
1397}
1398
1399/// Build the `workspace/workspaceFolders` entry for one configured root.
1400///
1401/// Reserved characters have to be percent-encoded here: an unencoded `#`
1402/// would truncate the path into a URI fragment, and `[` / `]` are rejected
1403/// outright by `Uri`.
1404fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
1405    let uri = try_path_to_uri(root).ok_or_else(|| Error::PathToUri(root.to_path_buf()))?;
1406    Ok(WorkspaceFolder {
1407        uri,
1408        name: root
1409            .file_name()
1410            .and_then(|n| n.to_str())
1411            .unwrap_or("workspace")
1412            .to_string(),
1413    })
1414}
1415
1416/// Builds an `LspServer` that can be registered without a real language
1417/// server: an inert, in-memory transport (see `crate::test_lsp`) and no
1418/// backing child process (`child: None`) -- neither is ever read from or
1419/// written to.
1420///
1421/// `pub` rather than private to this module's own `tests` (`lifecycle` is a
1422/// private module, so this stays crate-scoped in practice, per the
1423/// `redundant_pub_crate` clippy lint): it constructs `LspServer` via a
1424/// struct literal, which only code inside this module can do (all its
1425/// fields are private), so this is the one place other modules'
1426/// shutdown-path tests (`bridge::translator`, `lib.rs`) can get a real,
1427/// registerable `LspServer` from.
1428#[cfg(test)]
1429pub fn fake_lsp_server() -> LspServer {
1430    fake_lsp_server_with_config(LspServerConfig::pyright())
1431}
1432
1433/// As [`fake_lsp_server`], with a caller-chosen config, which is also what
1434/// the returned server reports as its `init_config`.
1435#[cfg(test)]
1436pub fn fake_lsp_server_with_config(server_config: LspServerConfig) -> LspServer {
1437    let transport = crate::test_lsp::inert_transport();
1438    let client = LspClient::from_transport(server_config.clone(), transport);
1439    let (_, mock_notification_rx) = mpsc::channel(1);
1440    let (_, mock_lifecycle_rx) = mpsc::channel(1);
1441    LspServer {
1442        client,
1443        capabilities: lsp_types::ServerCapabilities::default(),
1444        position_encoding: PositionEncodingKind::UTF8,
1445        notification_rx: mock_notification_rx,
1446        publish_rx: PublishReader::closed(),
1447        lifecycle_rx: mock_lifecycle_rx,
1448        child: None,
1449        init_config: crate::test_lsp::init_config_for(server_config),
1450    }
1451}
1452
1453/// A server whose child process (`sleep`) is alive but whose message loop
1454/// dies at once on its inert transport -- the shape of a panicked loop.
1455#[cfg(test)]
1456#[cfg(unix)]
1457pub fn fake_lsp_server_with_dead_loop_and_live_child() -> LspServer {
1458    let child = tokio::process::Command::new("sleep")
1459        .arg("30")
1460        .kill_on_drop(true)
1461        .spawn()
1462        .unwrap_or_else(|e| panic!("failed to spawn sleep: {e}"));
1463    let mut server = fake_lsp_server();
1464    server.child = Some(ServerProcess::from_unbound(child));
1465    server
1466}
1467
1468#[cfg(test)]
1469impl LspServer {
1470    /// Construct an `LspServer` fixture carrying the given capabilities, for
1471    /// tests elsewhere in the crate that need to drive capability-gated
1472    /// dispatch paths in `Translator` without spawning a real language server.
1473    ///
1474    /// The underlying client and child process (`child: None`) are inert
1475    /// placeholders — only `capabilities()` is meaningful on the returned
1476    /// value.
1477    ///
1478    /// Uses `LspClient::new` (uninitialized, no background task) rather than
1479    /// `LspClient::from_transport`, so this does not depend on the Tokio
1480    /// message loop or a real process at all.
1481    pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
1482        Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
1483    }
1484
1485    /// As [`Self::new_for_test`], but with a caller-chosen negotiated
1486    /// encoding -- for tests exercising a non-UTF-16 conversion path (e.g.
1487    /// `EncodingCtx`-driven range conversion) without spawning a real
1488    /// process.
1489    pub(crate) fn new_for_test_with_encoding(
1490        capabilities: ServerCapabilities,
1491        position_encoding: PositionEncodingKind,
1492    ) -> Self {
1493        let client = LspClient::new(LspServerConfig::rust_analyzer());
1494        let (_, notification_rx) = mpsc::channel(1);
1495        let (_, lifecycle_rx) = mpsc::channel(1);
1496
1497        Self {
1498            client,
1499            capabilities,
1500            position_encoding,
1501            notification_rx,
1502            publish_rx: PublishReader::closed(),
1503            lifecycle_rx,
1504            child: None,
1505            init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
1506        }
1507    }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512    use std::assert_matches;
1513    use std::collections::HashMap;
1514
1515    use super::*;
1516    use crate::bridge::PositionEncoding;
1517    use crate::config::{FilePattern, LanguageId, TimeoutSecs, ToolSet};
1518    use crate::lsp::HostOs;
1519
1520    #[test]
1521    fn test_client_capabilities_offer_configured_encodings_in_order() {
1522        let encodings =
1523            PositionEncodings::new(vec![PositionEncoding::Utf32, PositionEncoding::Utf8]).unwrap();
1524        let general = LspServer::client_capabilities(&encodings, None)
1525            .general
1526            .unwrap();
1527        assert_eq!(
1528            general.position_encodings,
1529            Some(vec![
1530                PositionEncodingKind::UTF32,
1531                PositionEncodingKind::UTF8
1532            ])
1533        );
1534    }
1535
1536    /// P1/S2: without `window.work_done_progress == Some(true)`, no
1537    /// spec-compliant LSP server may ever initiate `$/progress` at all.
1538    /// Regression guard: deleting the capability lines in
1539    /// `client_capabilities` must fail this test, not silently no-op the
1540    /// whole feature with an otherwise-green suite.
1541    #[test]
1542    fn test_client_capabilities_advertises_work_done_progress() {
1543        let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None);
1544
1545        assert_eq!(
1546            capabilities.window.and_then(|w| w.work_done_progress),
1547            Some(true)
1548        );
1549    }
1550
1551    /// Without `prepareSupport`, clangd and typescript-language-server never
1552    /// advertise `prepareProvider`, which would make `prepare_rename`
1553    /// unreachable on them.
1554    #[test]
1555    fn test_client_capabilities_advertises_prepare_rename_support() {
1556        let rename = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
1557            .text_document
1558            .and_then(|t| t.rename)
1559            .unwrap();
1560
1561        assert_eq!(rename.prepare_support, Some(true));
1562        assert_eq!(
1563            rename.prepare_support_default_behavior,
1564            Some(lsp_types::PrepareSupportDefaultBehavior::Identifier)
1565        );
1566    }
1567
1568    #[test]
1569    fn test_client_capabilities_advertises_folding_range_with_columns_and_collapsed_text() {
1570        let folding = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
1571            .text_document
1572            .and_then(|t| t.folding_range)
1573            .unwrap();
1574
1575        assert_eq!(folding.line_folding_only, Some(false));
1576        assert_eq!(
1577            folding
1578                .folding_range
1579                .and_then(|options| options.collapsed_text),
1580            Some(true)
1581        );
1582    }
1583
1584    #[test]
1585    fn test_client_capabilities_advertises_selection_range() {
1586        let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
1587            .text_document
1588            .and_then(|t| t.selection_range)
1589            .unwrap();
1590
1591        assert_eq!(capabilities.dynamic_registration, Some(false));
1592    }
1593
1594    #[test]
1595    fn test_client_capabilities_advertises_publish_diagnostics_with_version_support_only() {
1596        let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
1597            .text_document
1598            .and_then(|t| t.publish_diagnostics)
1599            .unwrap();
1600
1601        assert_eq!(
1602            capabilities,
1603            lsp_types::PublishDiagnosticsClientCapabilities {
1604                version_support: Some(true),
1605                ..Default::default()
1606            }
1607        );
1608    }
1609
1610    #[test]
1611    fn test_client_capabilities_do_not_advertise_pull_diagnostics() {
1612        let text_document = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
1613            .text_document
1614            .unwrap();
1615
1616        assert!(text_document.diagnostic.is_none());
1617    }
1618
1619    #[test]
1620    fn test_server_state_ready() {
1621        assert!(ServerState::Ready.is_ready());
1622        assert!(ServerState::Ready.can_accept_requests());
1623    }
1624
1625    #[test]
1626    fn test_server_state_uninitialized() {
1627        assert!(!ServerState::Uninitialized.is_ready());
1628        assert!(!ServerState::Uninitialized.can_accept_requests());
1629    }
1630
1631    #[test]
1632    fn test_server_state_initializing() {
1633        assert!(!ServerState::Initializing.is_ready());
1634        assert!(!ServerState::Initializing.can_accept_requests());
1635    }
1636
1637    #[test]
1638    fn test_workspace_folder_encodes_fragment_char() {
1639        // An unencoded `#` parses as a fragment, silently handing the server
1640        // the parent directory as its root.
1641        #[cfg(windows)]
1642        let (root, expected) = (
1643            Path::new(r"C:\home\me\dev\#work"),
1644            "file:///C:/home/me/dev/%23work",
1645        );
1646        #[cfg(not(windows))]
1647        let (root, expected) = (
1648            Path::new("/home/me/dev/#work"),
1649            "file:///home/me/dev/%23work",
1650        );
1651
1652        let folder = workspace_folder(root).unwrap();
1653
1654        assert_eq!(folder.uri.as_ref(), expected);
1655        assert_eq!(folder.name, "#work");
1656    }
1657
1658    #[test]
1659    fn test_workspace_folder_encodes_bracket_chars() {
1660        #[cfg(windows)]
1661        let (root, expected) = (
1662            Path::new(r"C:\home\me\dev\[env]"),
1663            "file:///C:/home/me/dev/%5Benv%5D",
1664        );
1665        #[cfg(not(windows))]
1666        let (root, expected) = (
1667            Path::new("/home/me/dev/[env]"),
1668            "file:///home/me/dev/%5Benv%5D",
1669        );
1670
1671        let folder = workspace_folder(root).unwrap();
1672
1673        assert_eq!(folder.uri.as_ref(), expected);
1674        assert_eq!(folder.name, "[env]");
1675    }
1676
1677    #[test]
1678    fn test_workspace_folder_rejects_relative_root() {
1679        let err = workspace_folder(Path::new("relative/root")).unwrap_err();
1680        assert_matches!(err, Error::PathToUri(_), "got {err:?}");
1681    }
1682
1683    #[test]
1684    fn test_server_state_shutting_down() {
1685        assert!(!ServerState::ShuttingDown.is_ready());
1686        assert!(!ServerState::ShuttingDown.can_accept_requests());
1687    }
1688
1689    #[test]
1690    fn test_server_state_shutdown() {
1691        assert!(!ServerState::Shutdown.is_ready());
1692        assert!(!ServerState::Shutdown.can_accept_requests());
1693    }
1694
1695    #[test]
1696    fn test_server_state_equality() {
1697        assert_eq!(ServerState::Ready, ServerState::Ready);
1698        assert_ne!(ServerState::Ready, ServerState::Uninitialized);
1699        assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
1700    }
1701
1702    #[test]
1703    fn test_server_state_clone() {
1704        let state = ServerState::Ready;
1705        let cloned = state;
1706        assert_eq!(state, cloned);
1707    }
1708
1709    #[test]
1710    fn test_server_state_debug() {
1711        let state = ServerState::Ready;
1712        let debug_str = format!("{state:?}");
1713        assert!(debug_str.contains("Ready"));
1714    }
1715
1716    #[test]
1717    fn test_server_init_config_clone() {
1718        let config = ServerInitConfig::new(
1719            {
1720                let mut c = LspServerConfig::rust_analyzer();
1721                c.initialization_options = Some(serde_json::json!({"key": "value"}));
1722                c
1723            },
1724            WorkspaceRoots::for_test(vec![PathBuf::from("/tmp/workspace")], vec![]),
1725            PositionEncodings::DEFAULT,
1726            std::sync::Arc::default(),
1727        );
1728
1729        #[allow(clippy::redundant_clone, reason = "the test exercises `Clone` itself")]
1730        let cloned = config.clone();
1731        assert_eq!(cloned.server_config().language_id, "rust");
1732        assert_eq!(cloned.workspace_roots().canonical().len(), 1);
1733    }
1734
1735    #[test]
1736    fn test_server_init_config_debug() {
1737        let config = ServerInitConfig::new(
1738            LspServerConfig::pyright(),
1739            WorkspaceRoots::default(),
1740            PositionEncodings::DEFAULT,
1741            std::sync::Arc::default(),
1742        );
1743
1744        let debug_str = format!("{config:?}");
1745        assert!(debug_str.contains("python"));
1746        assert!(debug_str.contains("pyright"));
1747    }
1748
1749    #[test]
1750    fn test_server_init_config_with_options() {
1751        let init_opts = serde_json::json!({
1752            "settings": {
1753                "python": {
1754                    "analysis": {
1755                        "typeCheckingMode": "strict"
1756                    }
1757                }
1758            }
1759        });
1760
1761        let mut env = crate::config::ServerEnv::default();
1762        env.insert(
1763            crate::config::EnvKey::from_static("PYTHONPATH"),
1764            "/usr/lib".to_string(),
1765        );
1766
1767        let config = ServerInitConfig::new(
1768            LspServerConfig {
1769                language_id: LanguageId::from_static("python"),
1770                command: ServerCommand::from_static("pyright-langserver").into(),
1771                args: vec!["--stdio".to_string()],
1772                env,
1773                file_patterns: vec![FilePattern::from_static("**/*.py")],
1774                initialization_options: Some(init_opts),
1775                settings: None,
1776                timeout_seconds: TimeoutSecs::new(10).unwrap(),
1777                request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
1778                heuristics: None,
1779                name: None,
1780                handles: None,
1781                indexing: crate::bridge::IndexingPolicy::Auto,
1782            },
1783            WorkspaceRoots::for_test(vec![PathBuf::from("/workspace")], vec![]),
1784            PositionEncodings::DEFAULT,
1785            std::sync::Arc::default(),
1786        );
1787
1788        assert!(config.server_config().initialization_options.is_some());
1789        assert_eq!(config.workspace_roots().canonical().len(), 1);
1790    }
1791
1792    #[test]
1793    fn test_server_init_config_empty_workspace() {
1794        let config = ServerInitConfig::new(
1795            LspServerConfig::typescript(),
1796            WorkspaceRoots::default(),
1797            PositionEncodings::DEFAULT,
1798            std::sync::Arc::default(),
1799        );
1800
1801        assert_eq!(config.workspace_roots().canonical().len(), 0);
1802    }
1803
1804    #[test]
1805    fn test_server_init_config_multiple_workspaces() {
1806        let config = ServerInitConfig::new(
1807            LspServerConfig::rust_analyzer(),
1808            WorkspaceRoots::for_test(
1809                vec![
1810                    PathBuf::from("/workspace1"),
1811                    PathBuf::from("/workspace2"),
1812                    PathBuf::from("/workspace3"),
1813                ],
1814                vec![],
1815            ),
1816            PositionEncodings::DEFAULT,
1817            std::sync::Arc::default(),
1818        );
1819
1820        assert_eq!(config.workspace_roots().canonical().len(), 3);
1821    }
1822
1823    /// #249: `has_exited` must distinguish a live child from one that has
1824    /// already exited, since this is the signal the respawn path relies on
1825    /// to detect a crashed LSP server.
1826    ///
1827    /// Unix-only: spawns a real `sleep` subprocess, which is unavailable on
1828    /// the Windows CI runner.
1829    #[cfg(unix)]
1830    #[tokio::test]
1831    async fn test_has_exited_reflects_child_process_state() {
1832        use lsp_types::ServerCapabilities;
1833
1834        let mut mock_child = tokio::process::Command::new("sleep")
1835            .arg("2")
1836            .stdin(Stdio::piped())
1837            .stdout(Stdio::piped())
1838            .kill_on_drop(true)
1839            .spawn()
1840            .unwrap();
1841
1842        let mock_stdin = mock_child.stdin.take().unwrap();
1843        let mock_stdout = mock_child.stdout.take().unwrap();
1844
1845        let transport = LspTransport::new(mock_stdin, mock_stdout);
1846        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1847        let (_, mock_notification_rx) = mpsc::channel(1);
1848        let (_, mock_lifecycle_rx) = mpsc::channel(1);
1849
1850        let mut server = LspServer {
1851            client,
1852            capabilities: ServerCapabilities::default(),
1853            position_encoding: PositionEncodingKind::UTF8,
1854            notification_rx: mock_notification_rx,
1855            publish_rx: PublishReader::closed(),
1856            lifecycle_rx: mock_lifecycle_rx,
1857            child: Some(ServerProcess::from_unbound(mock_child)),
1858            init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
1859        };
1860
1861        assert!(
1862            !server.has_exited().unwrap(),
1863            "freshly spawned `sleep 2` should still be running"
1864        );
1865
1866        server.child.as_mut().unwrap().kill().await.unwrap();
1867        // `kill().await` waits for the process to actually exit, so the
1868        // very next `try_wait` reliably observes it as gone.
1869        assert!(
1870            server.has_exited().unwrap(),
1871            "killed child must report as exited"
1872        );
1873    }
1874
1875    /// A live child whose message loop has stopped must be reported dead.
1876    #[cfg(unix)]
1877    #[tokio::test]
1878    async fn test_is_dead_when_message_loop_finished_but_child_alive() {
1879        let mut server = fake_lsp_server_with_dead_loop_and_live_child();
1880
1881        tokio::time::timeout(Duration::from_secs(2), async {
1882            while !server.client.is_message_loop_finished() {
1883                tokio::time::sleep(Duration::from_millis(10)).await;
1884            }
1885        })
1886        .await
1887        .unwrap();
1888
1889        assert!(!server.has_exited().unwrap());
1890        assert!(server.is_dead().unwrap());
1891    }
1892
1893    /// A server that never answers `shutdown` fails the handshake with the
1894    /// request timeout, well inside `SHUTDOWN_TIMEOUT`, and still stops its
1895    /// message loop.
1896    #[tokio::test(start_paused = true)]
1897    async fn test_shutdown_reports_handshake_timeout_within_deadline() {
1898        let (client, _fake_server) = crate::test_lsp::fake_lsp_client();
1899        let probe = client.clone();
1900        let (_, notification_rx) = mpsc::channel(1);
1901        let (_, lifecycle_rx) = mpsc::channel(1);
1902        let server = LspServer {
1903            client,
1904            capabilities: lsp_types::ServerCapabilities::default(),
1905            position_encoding: PositionEncodingKind::UTF8,
1906            notification_rx,
1907            publish_rx: PublishReader::closed(),
1908            lifecycle_rx,
1909            child: None,
1910            init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
1911        };
1912
1913        let started = Instant::now();
1914        let result = server.shutdown().await;
1915
1916        assert_matches!(result, Err(Error::Timeout(_)), "got {result:?}");
1917        assert!(started.elapsed() <= SHUTDOWN_TIMEOUT);
1918        assert_matches!(probe.state().await, ServerState::Shutdown);
1919    }
1920
1921    #[tokio::test]
1922    async fn test_is_dead_false_for_childless_fixture() {
1923        let mut server = fake_lsp_server();
1924        tokio::time::sleep(Duration::from_millis(50)).await;
1925        assert!(!server.is_dead().unwrap());
1926    }
1927
1928    #[tokio::test]
1929    async fn test_lsp_server_getters() {
1930        use lsp_types::ServerCapabilities;
1931
1932        let transport = crate::test_lsp::inert_transport();
1933        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1934        let (_, mock_notification_rx) = mpsc::channel(1);
1935        let (_, mock_lifecycle_rx) = mpsc::channel(1);
1936
1937        let server = LspServer {
1938            client,
1939            capabilities: ServerCapabilities::default(),
1940            position_encoding: PositionEncodingKind::UTF8,
1941            notification_rx: mock_notification_rx,
1942            publish_rx: PublishReader::closed(),
1943            lifecycle_rx: mock_lifecycle_rx,
1944            child: None,
1945            init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
1946        };
1947
1948        assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
1949        assert!(server.capabilities().text_document_sync.is_none());
1950
1951        let debug_str = format!("{server:?}");
1952        assert!(debug_str.contains("LspServer"));
1953        assert!(debug_str.contains("<process>"));
1954    }
1955
1956    #[test]
1957    fn test_spawn_error_classifies_not_found() {
1958        use std::io::{Error as IoError, ErrorKind};
1959
1960        let missing = spawn_error(
1961            ServerCommand::from_static("x"),
1962            IoError::from(ErrorKind::NotFound),
1963        );
1964        assert_matches!(missing, Error::ServerNotFound { .. });
1965        let denied = spawn_error(
1966            ServerCommand::from_static("x"),
1967            IoError::from(ErrorKind::PermissionDenied),
1968        );
1969        assert_matches!(denied, Error::ServerSpawnFailed { .. });
1970    }
1971
1972    #[tokio::test]
1973    async fn test_spawn_nonexistent_command_is_server_not_found() {
1974        let mut server_config = LspServerConfig::rust_analyzer();
1975        server_config.command = ServerCommand::from_static("nonexistent-lsp-cmd-xyz").into();
1976        let config = ServerInitConfig::new(
1977            server_config,
1978            WorkspaceRoots::default(),
1979            PositionEncodings::DEFAULT,
1980            std::sync::Arc::default(),
1981        );
1982        let err = LspServer::spawn(config).await.unwrap_err();
1983        assert_matches!(err, Error::ServerNotFound { .. }, "got {err:?}");
1984    }
1985
1986    #[tokio::test]
1987    async fn test_is_connection_loss_excludes_server_replies() {
1988        let join_error = tokio::spawn(async { panic!("receiver died") })
1989            .await
1990            .unwrap_err();
1991        assert!(is_connection_loss(&Error::ServerTerminated));
1992        assert!(is_connection_loss(&Error::TaskFailed {
1993            task: BackgroundTask::LspReceiver,
1994            source: join_error,
1995        }));
1996        assert!(!is_connection_loss(&Error::StdioCapture(
1997            StdioStream::Stdin
1998        )));
1999        assert!(!is_connection_loss(&Error::LspServerError {
2000            code: -32603,
2001            message: "bad".to_string(),
2002            data: None,
2003        }));
2004    }
2005
2006    /// A server that answers `initialize` with an error and then exits keeps
2007    /// its own error instead of being reported as an early exit.
2008    #[cfg(unix)]
2009    #[tokio::test]
2010    async fn test_spawn_keeps_servers_own_initialize_error_when_it_exits_afterwards() {
2011        let dir = tempfile::TempDir::new().unwrap();
2012        let config = crate::test_lsp::sh_script_init_config(
2013            dir.path(),
2014            &crate::test_lsp::with_read_preamble(
2015                r#"body='{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"rejected by server"}}'
2016printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2017"#,
2018            ),
2019        );
2020
2021        let err = LspServer::spawn(config).await.unwrap_err();
2022
2023        assert_matches!(
2024            &err,
2025            Error::LspInitFailed { phase: InitPhase::Initialize, cause, .. }
2026                if matches!(**cause, Error::LspServerError { code: -32603, .. }),
2027            "got {err:?}"
2028        );
2029        assert!(err.to_string().contains("rejected by server"), "{err}");
2030    }
2031
2032    /// Connection lost while the child is still running: the error keeps the
2033    /// "initialize failed" context rather than a bare transport error.
2034    #[cfg(unix)]
2035    #[tokio::test]
2036    async fn test_spawn_connection_loss_with_live_child_is_lsp_init_failed() {
2037        let dir = tempfile::TempDir::new().unwrap();
2038        let config = crate::test_lsp::sh_script_init_config(dir.path(), "exec 1>&-\nsleep 5\n");
2039
2040        let err = LspServer::spawn(config).await.unwrap_err();
2041
2042        assert_matches!(
2043            &err,
2044            Error::LspInitFailed {
2045                phase: InitPhase::Initialize,
2046                ..
2047            },
2048            "got {err:?}"
2049        );
2050        assert!(
2051            err.to_string().contains("Initialize request failed"),
2052            "{err}"
2053        );
2054    }
2055
2056    #[cfg(unix)]
2057    #[tokio::test]
2058    async fn test_spawn_exiting_before_initialize_reply_is_server_exited_during_init() {
2059        let dir = tempfile::TempDir::new().unwrap();
2060        let config = crate::test_lsp::sh_script_init_config(dir.path(), "exit 1\n");
2061
2062        let err = LspServer::spawn(config).await.unwrap_err();
2063
2064        assert_matches!(
2065            err,
2066            Error::ServerExitedDuringInit {
2067                exit_code: Some(1),
2068                ..
2069            },
2070            "got {err:?}"
2071        );
2072    }
2073
2074    #[cfg(unix)]
2075    #[test]
2076    fn test_unbound_server_gets_the_real_process_id() {
2077        assert_eq!(
2078            initialize_process_id(Binding::Unbound),
2079            Some(i32::try_from(std::process::id()).unwrap())
2080        );
2081    }
2082
2083    #[cfg(unix)]
2084    #[test]
2085    fn test_bound_server_gets_no_process_id() {
2086        assert_eq!(initialize_process_id(Binding::Bound), None);
2087    }
2088
2089    /// A server guarded by a lifeline watchdog must not be handed the mcpls
2090    /// pid to watch: it could exit before the watchdog has frozen its tree.
2091    #[cfg(unix)]
2092    #[tokio::test]
2093    async fn test_spawn_sends_null_process_id_while_a_lifeline_is_bound() {
2094        let dir = tempfile::TempDir::new().unwrap();
2095        let dump = dir.path().join("request");
2096        let script = format!(
2097            "read -r header\nread -r _\nhead -c \"$(printf %s \"$header\" | tr -dc 0-9)\" > '{}'\nexit 1\n",
2098            dump.display()
2099        );
2100        let config = crate::test_lsp::sh_script_init_config(dir.path(), &script);
2101
2102        LspServer::spawn(config).await.unwrap_err();
2103
2104        let request = std::fs::read_to_string(&dump).unwrap();
2105        assert!(request.contains(r#""processId":null"#), "got {request}");
2106    }
2107
2108    /// #534: what the server printed before exiting reaches the error.
2109    #[cfg(unix)]
2110    #[tokio::test]
2111    async fn test_spawn_exit_during_init_carries_stderr() {
2112        let dir = tempfile::TempDir::new().unwrap();
2113        let config = crate::test_lsp::sh_script_init_config(
2114            dir.path(),
2115            "echo 'fatal: bad toolchain' >&2\nexit 1\n",
2116        );
2117
2118        let err = LspServer::spawn(config).await.unwrap_err();
2119
2120        let Error::ServerExitedDuringInit {
2121            stderr: Some(stderr),
2122            ..
2123        } = &err
2124        else {
2125            panic!("got {err:?}");
2126        };
2127        assert_eq!(stderr.head(), "fatal: bad toolchain");
2128        assert!(err.to_string().contains("stderr: fatal: bad toolchain"));
2129    }
2130
2131    /// A server that rejects `initialize` and prints its reason just before
2132    /// exiting must not lose that last line.
2133    #[cfg(unix)]
2134    #[tokio::test]
2135    async fn test_spawn_rejection_keeps_stderr_written_just_before_exit() {
2136        let dir = tempfile::TempDir::new().unwrap();
2137        let config = crate::test_lsp::sh_script_init_config(
2138            dir.path(),
2139            &crate::test_lsp::with_read_preamble(
2140                r#"body='{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"rejected by server"}}'
2141printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2142# Shell-only pause: an external `sleep` can take longer than the 100 ms EOF grace just to start on a loaded macOS runner.
2143i=0; while [ $i -lt 300 ]; do i=$((i+1)); done
2144echo 'fatal: bad toolchain' >&2
2145"#,
2146            ),
2147        );
2148
2149        let err = LspServer::spawn(config).await.unwrap_err();
2150
2151        let Error::LspInitFailed {
2152            stderr: Some(stderr),
2153            ..
2154        } = &err
2155        else {
2156            panic!("got {err:?}");
2157        };
2158        assert_eq!(stderr.head(), "fatal: bad toolchain");
2159    }
2160
2161    /// #534: a server that hangs after writing to stderr times out with its
2162    /// output attached.
2163    #[cfg(unix)]
2164    #[tokio::test]
2165    async fn test_spawn_initialize_timeout_carries_stderr() {
2166        let dir = tempfile::TempDir::new().unwrap();
2167        let mut config = crate::test_lsp::sh_script_init_config(
2168            dir.path(),
2169            "echo 'indexing forever' >&2\nsleep 5\n",
2170        );
2171        config.server_config.timeout_seconds = TimeoutSecs::new(1).unwrap();
2172
2173        let err = LspServer::spawn(config).await.unwrap_err();
2174
2175        let Error::LspInitFailed {
2176            stderr: Some(stderr),
2177            ..
2178        } = &err
2179        else {
2180            panic!("got {err:?}");
2181        };
2182        assert_eq!(stderr.head(), "indexing forever");
2183    }
2184
2185    /// #612: a secret of another configured server, handed in through the
2186    /// init config, is hidden from this server's output although its own
2187    /// configuration does not name it.
2188    #[cfg(unix)]
2189    #[tokio::test]
2190    async fn test_spawn_hides_another_servers_secret_from_stderr() {
2191        let dir = tempfile::TempDir::new().unwrap();
2192        let mut config = crate::test_lsp::sh_script_init_config(
2193            dir.path(),
2194            "echo \"seen=$OTHER_VALUE own=$API_TOKEN\" >&2\nexit 1\n",
2195        );
2196        config.server_config.env.insert(
2197            crate::config::EnvKey::from_static("OTHER_VALUE"),
2198            "bravo-secret-222".to_string(),
2199        );
2200        config.server_config.env.insert(
2201            crate::config::EnvKey::from_static("API_TOKEN"),
2202            "s3cr3t-value".to_string(),
2203        );
2204        config.redactions = std::sync::Arc::new(Redactions::new([(
2205            "B_TOKEN".to_owned(),
2206            "bravo-secret-222".to_owned(),
2207        )]));
2208
2209        let err = LspServer::spawn(config).await.unwrap_err();
2210
2211        let text = err.to_string();
2212        assert!(text.contains("seen=[redacted:B_TOKEN]"), "{text}");
2213        assert!(text.contains("own=[redacted:API_TOKEN]"), "{text}");
2214    }
2215
2216    /// Values configured in `env` never reach the error text.
2217    #[cfg(unix)]
2218    #[tokio::test]
2219    async fn test_spawn_stderr_redacts_configured_env_values() {
2220        let dir = tempfile::TempDir::new().unwrap();
2221        let mut config = crate::test_lsp::sh_script_init_config(
2222            dir.path(),
2223            "echo \"token=$API_TOKEN toolchain=$RUSTUP_TOOLCHAIN\" >&2\nexit 1\n",
2224        );
2225        config.server_config.env.insert(
2226            crate::config::EnvKey::from_static("RUSTUP_TOOLCHAIN"),
2227            "nightly-2024-01-01".to_string(),
2228        );
2229        config.server_config.env.insert(
2230            crate::config::EnvKey::from_static("API_TOKEN"),
2231            "s3cr3t-value".to_string(),
2232        );
2233
2234        let err = LspServer::spawn(config).await.unwrap_err();
2235
2236        let text = err.to_string();
2237        assert!(text.contains("token=[redacted:API_TOKEN]"), "{text}");
2238        assert!(text.contains("toolchain=nightly-2024-01-01"), "{text}");
2239        assert!(!text.contains("s3cr3t-value"), "{text}");
2240    }
2241
2242    #[test]
2243    fn test_log_spawn_hides_argument_values() {
2244        use tracing_subscriber::prelude::*;
2245
2246        use crate::test_lsp::CapturedLogs;
2247
2248        let mut config = LspServerConfig::rust_analyzer();
2249        config.args = vec!["--api-key=SuperSecretArg456".to_string()];
2250        let redactions = Redactions::for_server(&config, std::iter::empty());
2251        let logs = CapturedLogs::default();
2252        let info_only = tracing_subscriber::registry()
2253            .with(tracing_subscriber::filter::LevelFilter::INFO)
2254            .with(logs.clone());
2255
2256        tracing::subscriber::with_default(info_only, || {
2257            LspServer::log_spawn(&config, &redactions);
2258        });
2259
2260        let output = logs.messages().join("\n");
2261        assert!(output.contains("(1 arg(s))"), "{output}");
2262        assert!(!output.contains("SuperSecretArg456"), "{output}");
2263    }
2264
2265    /// FR-012: a panic while starting one server is that server's failure.
2266    #[tokio::test]
2267    async fn contain_attributes_a_panic_to_its_own_server() {
2268        let config = crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer());
2269
2270        let outcome = contain(&config, async { panic!("start boom") }).await;
2271
2272        assert_matches!(
2273            outcome,
2274            ServerStartOutcome::Failed(failure)
2275                if failure.server_id == config.server_config.id()
2276                    && matches!(failure.reason, StartupFailure::InitTaskPanicked)
2277        );
2278    }
2279
2280    #[tokio::test]
2281    async fn contain_reports_a_spawn_error_as_that_servers_failure() {
2282        let config = crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer());
2283
2284        let outcome = contain(&config, async { Err(Error::ServerTerminated) }).await;
2285
2286        assert_matches!(
2287            outcome,
2288            ServerStartOutcome::Failed(failure) if matches!(failure.reason, StartupFailure::Spawn(_))
2289        );
2290    }
2291
2292    /// `window/logMessage` and `window/showMessage` text echoing configured
2293    /// secrets is redacted before it reaches the notification lane (#554).
2294    #[cfg(unix)]
2295    #[tokio::test]
2296    async fn test_spawn_redacts_secrets_in_server_log_and_show_messages() {
2297        let dir = tempfile::TempDir::new().unwrap();
2298        let script = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}'
2299printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2300body="{\"jsonrpc\":\"2.0\",\"method\":\"window/logMessage\",\"params\":{\"type\":3,\"message\":\"env=$API_TOKEN arg=$1\"}}"
2301printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2302body="{\"jsonrpc\":\"2.0\",\"method\":\"window/showMessage\",\"params\":{\"type\":3,\"message\":\"env=$API_TOKEN\"}}"
2303printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2304sleep 5
2305"#;
2306        let mut config = crate::test_lsp::sh_script_init_config(
2307            dir.path(),
2308            &crate::test_lsp::with_read_preamble(script),
2309        );
2310        config
2311            .server_config
2312            .args
2313            .push("--api-key=SuperSecretArg456".to_string());
2314        config.server_config.env.insert(
2315            crate::config::EnvKey::from_static("API_TOKEN"),
2316            "SuperSecretValue123".to_string(),
2317        );
2318
2319        let mut server = LspServer::spawn(config).await.unwrap();
2320        let mut rx = server.take_notification_rx();
2321        let mut texts = Vec::new();
2322        for _ in 0..2 {
2323            let notification = tokio::time::timeout(Duration::from_secs(5), rx.recv())
2324                .await
2325                .unwrap()
2326                .unwrap();
2327            texts.push(format!("{notification:?}"));
2328        }
2329
2330        let joined = texts.join("\n");
2331        assert!(!joined.contains("SuperSecretValue123"), "{joined}");
2332        assert!(!joined.contains("SuperSecretArg456"), "{joined}");
2333        assert!(joined.contains("[redacted:API_TOKEN]"), "{joined}");
2334        assert!(joined.contains("[redacted:api-key]"), "{joined}");
2335    }
2336
2337    /// A secret echoed into a mistyped `initialize` result reaches the serde
2338    /// error text, which is redacted too (#554).
2339    #[cfg(unix)]
2340    #[tokio::test]
2341    async fn test_spawn_redacts_secrets_in_initialize_decode_error() {
2342        let dir = tempfile::TempDir::new().unwrap();
2343        let script = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":"SuperSecretValue123"}}'
2344printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2345sleep 5
2346"#;
2347        let mut config = crate::test_lsp::sh_script_init_config(
2348            dir.path(),
2349            &crate::test_lsp::with_read_preamble(script),
2350        );
2351        config.server_config.env.insert(
2352            crate::config::EnvKey::from_static("API_TOKEN"),
2353            "SuperSecretValue123".to_string(),
2354        );
2355
2356        let err = LspServer::spawn(config).await.unwrap_err();
2357
2358        let text = err.to_string();
2359        assert!(!text.contains("SuperSecretValue123"), "{text}");
2360    }
2361
2362    /// The server's own `initialize` error text is redacted (#554).
2363    #[cfg(unix)]
2364    #[tokio::test]
2365    async fn test_spawn_redacts_secrets_in_initialize_error_message() {
2366        let dir = tempfile::TempDir::new().unwrap();
2367        let script = r#"body="{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"bad token $API_TOKEN\"}}"
2368printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
2369sleep 5
2370"#;
2371        let mut config = crate::test_lsp::sh_script_init_config(
2372            dir.path(),
2373            &crate::test_lsp::with_read_preamble(script),
2374        );
2375        config.server_config.env.insert(
2376            crate::config::EnvKey::from_static("API_TOKEN"),
2377            "SuperSecretValue123".to_string(),
2378        );
2379
2380        let err = LspServer::spawn(config).await.unwrap_err();
2381
2382        let text = err.to_string();
2383        assert!(text.contains("[redacted:API_TOKEN]"), "{text}");
2384        assert!(!text.contains("SuperSecretValue123"), "{text}");
2385    }
2386
2387    /// Regression guard for the drain: a server flooding stderr both before
2388    /// and after it answers `initialize` must never block or lose its pipe
2389    /// (`EPIPE`/`SIGPIPE`) once `spawn` has returned.
2390    #[cfg(unix)]
2391    #[tokio::test]
2392    async fn test_spawn_keeps_draining_stderr_after_initialize() {
2393        let dir = tempfile::TempDir::new().unwrap();
2394        let marker = dir.path().join("flooded");
2395        let script = format!(
2396            r#"head -c 200000 /dev/zero | tr '\0' 'e' >&2
2397body='{{"jsonrpc":"2.0","id":1,"result":{{"capabilities":{{}}}}}}'
2398printf 'Content-Length: %d\r\n\r\n%s' ${{#body}} "$body"
2399sleep 0.3
2400head -c 200000 /dev/zero | tr '\0' 'e' >&2 && echo done > '{}'
2401sleep 5
2402"#,
2403            marker.display()
2404        );
2405        let config = crate::test_lsp::sh_script_init_config(
2406            dir.path(),
2407            &crate::test_lsp::with_read_preamble(&script),
2408        );
2409
2410        let mut server = LspServer::spawn(config).await.unwrap();
2411
2412        let flooded = tokio::time::timeout(Duration::from_secs(5), async {
2413            while !marker.exists() {
2414                tokio::time::sleep(Duration::from_millis(20)).await;
2415            }
2416        })
2417        .await;
2418        assert!(
2419            flooded.is_ok(),
2420            "server's post-initialize stderr flood did not complete"
2421        );
2422        assert!(!server.has_exited().unwrap());
2423    }
2424
2425    /// Wire-level regressions for the `initialize` request. The tests
2426    /// capture the real bytes `LspServer::initialize` writes over an
2427    /// in-memory duplex pipe standing in for the LSP server. Mirrors the
2428    /// `fake_lsp_client`/`FakeServer` pattern in
2429    /// `client.rs::tests::retry_behavior`.
2430    mod initialize_wire {
2431
2432        use tempfile::TempDir;
2433        use tokio::io::BufReader;
2434
2435        use super::*;
2436        use crate::test_lsp::{
2437            fake_lsp_client, read_framed_message, write_response as write_success_response,
2438        };
2439
2440        #[tokio::test]
2441        async fn test_initialize_sends_configured_position_encodings() {
2442            let (client, mut server) = fake_lsp_client();
2443
2444            let config = ServerInitConfig::new(
2445                LspServerConfig::rust_analyzer(),
2446                WorkspaceRoots::default(),
2447                PositionEncodings::new(vec![PositionEncoding::Utf32, PositionEncoding::Utf8])
2448                    .unwrap(),
2449                std::sync::Arc::default(),
2450            );
2451
2452            let init_task =
2453                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2454
2455            let mut reader = BufReader::new(&mut server.write_stdout);
2456            let request = read_framed_message(&mut reader).await;
2457
2458            assert_eq!(request["method"], "initialize");
2459            assert_eq!(
2460                request["params"]["capabilities"]["general"]["positionEncodings"],
2461                serde_json::json!(["utf-32", "utf-8"]),
2462                "initialize request must carry the configured encoding order, not the \
2463                 hardcoded [UTF8, UTF16] default"
2464            );
2465
2466            write_success_response(
2467                &mut server.read_half_stdin,
2468                &request["id"].clone(),
2469                serde_json::json!({ "capabilities": {} }),
2470            )
2471            .await;
2472
2473            // The response written above must let `initialize` complete successfully.
2474            init_task.await.unwrap().unwrap();
2475        }
2476
2477        /// The three client frames of a handshake: `initialize`, `initialized`
2478        /// and `workspace/didChangeConfiguration`.
2479        struct Handshake {
2480            initialize: serde_json::Value,
2481            initialized: serde_json::Value,
2482            configuration: serde_json::Value,
2483        }
2484
2485        async fn handshake_with_settings(settings_json: Option<&str>) -> Handshake {
2486            let (client, mut server) = fake_lsp_client();
2487            let mut server_config = LspServerConfig::rust_analyzer();
2488            server_config.settings = settings_json.map(|json| serde_json::from_str(json).unwrap());
2489            let config = crate::test_lsp::init_config_for(server_config);
2490            let init_task =
2491                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2492
2493            let mut reader = BufReader::new(&mut server.write_stdout);
2494            let initialize = read_framed_message(&mut reader).await;
2495            assert_eq!(initialize["method"], "initialize");
2496            write_success_response(
2497                &mut server.read_half_stdin,
2498                &initialize["id"].clone(),
2499                serde_json::json!({ "capabilities": {} }),
2500            )
2501            .await;
2502            let initialized = read_framed_message(&mut reader).await;
2503            let configuration = read_framed_message(&mut reader).await;
2504            init_task.await.unwrap().unwrap();
2505            Handshake {
2506                initialize,
2507                initialized,
2508                configuration,
2509            }
2510        }
2511
2512        #[tokio::test]
2513        async fn test_initialize_pushes_null_settings_right_after_initialized() {
2514            let wire = handshake_with_settings(None).await;
2515
2516            assert_eq!(wire.initialized["method"], "initialized");
2517            assert_eq!(
2518                wire.configuration["method"],
2519                "workspace/didChangeConfiguration"
2520            );
2521            assert_eq!(
2522                wire.configuration["params"],
2523                serde_json::json!({ "settings": null })
2524            );
2525        }
2526
2527        #[tokio::test]
2528        async fn test_initialize_pushes_configured_settings_and_advertises_configuration() {
2529            let wire =
2530                handshake_with_settings(Some(r#"{"python.analysis.typeCheckingMode": "strict"}"#))
2531                    .await;
2532
2533            assert_eq!(
2534                wire.initialize["params"]["capabilities"]["workspace"]["configuration"],
2535                serde_json::json!(true)
2536            );
2537            assert_eq!(
2538                wire.configuration["params"],
2539                serde_json::json!({
2540                    "settings": { "python": { "analysis": { "typeCheckingMode": "strict" } } }
2541                })
2542            );
2543        }
2544
2545        #[tokio::test]
2546        async fn test_initialize_pushes_gopls_flat_and_yaml_url_keys_on_the_wire() {
2547            let wire = handshake_with_settings(Some(
2548                r#"{"gopls":{"ui.semanticTokens":true},
2549                    "yaml.schemas":{"https://json.schemastore.org/x.json":"*.yml"}}"#,
2550            ))
2551            .await;
2552
2553            let settings =
2554                serde_json::to_string(&wire.configuration["params"]["settings"]).unwrap();
2555            assert!(
2556                settings.contains(r#""gopls":{"ui.semanticTokens":true}"#),
2557                "{settings}"
2558            );
2559            assert!(
2560                settings.contains(
2561                    r#""yaml":{"schemas":{"https://json.schemastore.org/x.json":"*.yml"}}"#
2562                ),
2563                "{settings}"
2564            );
2565        }
2566
2567        #[test]
2568        fn test_workspace_configuration_not_advertised_without_settings() {
2569            let workspace = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
2570                .workspace
2571                .unwrap();
2572            assert_eq!(workspace.configuration, None);
2573        }
2574
2575        #[tokio::test]
2576        async fn test_initialize_advertises_stale_request_support() {
2577            let (client, mut server) = fake_lsp_client();
2578
2579            let config = ServerInitConfig::new(
2580                LspServerConfig::rust_analyzer(),
2581                WorkspaceRoots::default(),
2582                PositionEncodings::DEFAULT,
2583                std::sync::Arc::default(),
2584            );
2585
2586            let init_task =
2587                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2588
2589            let mut reader = BufReader::new(&mut server.write_stdout);
2590            let request = read_framed_message(&mut reader).await;
2591            let params: InitializeParams =
2592                serde_json::from_value(request["params"].clone()).unwrap();
2593            let stale_request_support = params
2594                .capabilities
2595                .general
2596                .unwrap()
2597                .stale_request_support
2598                .unwrap();
2599
2600            assert_eq!(request["method"], "initialize");
2601            assert!(
2602                !stale_request_support.cancel,
2603                "mcpls does not implement active in-flight request cancellation"
2604            );
2605            assert_eq!(
2606                stale_request_support.retry_on_content_modified,
2607                CONTENT_MODIFIED_RETRY_METHODS
2608                    .iter()
2609                    .map(ToString::to_string)
2610                    .collect::<Vec<_>>(),
2611                "the wire-advertised capability must match the methods LspClient::request \
2612                 actually retries -32801 for, not drift from it"
2613            );
2614
2615            write_success_response(
2616                &mut server.read_half_stdin,
2617                &request["id"].clone(),
2618                serde_json::json!({ "capabilities": {} }),
2619            )
2620            .await;
2621
2622            init_task.await.unwrap().unwrap();
2623        }
2624
2625        #[tokio::test]
2626        async fn test_initialize_advertises_server_status_notification_support() {
2627            let (client, mut server) = fake_lsp_client();
2628
2629            let config = ServerInitConfig::new(
2630                LspServerConfig::rust_analyzer(),
2631                WorkspaceRoots::default(),
2632                PositionEncodings::DEFAULT,
2633                std::sync::Arc::default(),
2634            );
2635
2636            let init_task =
2637                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2638
2639            let mut reader = BufReader::new(&mut server.write_stdout);
2640            let request = read_framed_message(&mut reader).await;
2641
2642            assert_eq!(request["method"], "initialize");
2643            assert_eq!(
2644                request["params"]["capabilities"]["experimental"]["serverStatusNotification"],
2645                serde_json::json!(true),
2646                "without this, rust-analyzer never emits experimental/serverStatus and the \
2647                 indexing-readiness gate in Translator::wait_for_indexing_ready is a \
2648                 permanent no-op"
2649            );
2650
2651            write_success_response(
2652                &mut server.read_half_stdin,
2653                &request["id"].clone(),
2654                serde_json::json!({ "capabilities": {} }),
2655            )
2656            .await;
2657
2658            init_task.await.unwrap().unwrap();
2659        }
2660
2661        #[tokio::test]
2662        async fn test_initialize_advertises_hierarchical_document_symbols() {
2663            let (client, mut server) = fake_lsp_client();
2664
2665            let config = ServerInitConfig::new(
2666                LspServerConfig::rust_analyzer(),
2667                WorkspaceRoots::default(),
2668                PositionEncodings::DEFAULT,
2669                std::sync::Arc::default(),
2670            );
2671
2672            let init_task =
2673                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2674
2675            let mut reader = BufReader::new(&mut server.write_stdout);
2676            let request = read_framed_message(&mut reader).await;
2677            let params: InitializeParams =
2678                serde_json::from_value(request["params"].clone()).unwrap();
2679            let document_symbol = params
2680                .capabilities
2681                .text_document
2682                .unwrap()
2683                .document_symbol
2684                .unwrap();
2685
2686            assert_eq!(request["method"], "initialize");
2687            assert_eq!(document_symbol.dynamic_registration, Some(false));
2688            assert_eq!(
2689                document_symbol.hierarchical_document_symbol_support,
2690                Some(true)
2691            );
2692            assert_eq!(
2693                document_symbol.symbol_kind.unwrap().value_set,
2694                Some(advertised_symbol_kinds())
2695            );
2696
2697            write_success_response(
2698                &mut server.read_half_stdin,
2699                &request["id"].clone(),
2700                serde_json::json!({ "capabilities": {} }),
2701            )
2702            .await;
2703
2704            init_task.await.unwrap().unwrap();
2705        }
2706
2707        #[tokio::test]
2708        async fn test_initialize_accepts_resolved_dot_workspace_root() {
2709            let temp_dir = TempDir::new().unwrap();
2710            let base = dunce::canonicalize(temp_dir.path()).unwrap();
2711            let workspace_roots =
2712                crate::bridge::WorkspaceRoots::from_paths_with(&[PathBuf::from(".")], || {
2713                    Ok(crate::bridge::ProcessCwd::new(base.clone(), None))
2714                })
2715                .unwrap()
2716                .canonical()
2717                .to_vec();
2718            assert_eq!(workspace_roots, vec![base.clone()]);
2719
2720            let (client, mut server) = fake_lsp_client();
2721            let config = ServerInitConfig::new(
2722                LspServerConfig::rust_analyzer(),
2723                WorkspaceRoots::for_test(workspace_roots, vec![]),
2724                PositionEncodings::DEFAULT,
2725                std::sync::Arc::default(),
2726            );
2727
2728            let init_task =
2729                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
2730
2731            let mut reader = BufReader::new(&mut server.write_stdout);
2732            let request = read_framed_message(&mut reader).await;
2733            let expected_uri = try_path_to_uri(&base).unwrap();
2734            assert_eq!(
2735                request["params"]["workspaceFolders"][0]["uri"],
2736                expected_uri.as_ref()
2737            );
2738
2739            write_success_response(
2740                &mut server.read_half_stdin,
2741                &request["id"].clone(),
2742                serde_json::json!({ "capabilities": {} }),
2743            )
2744            .await;
2745
2746            init_task.await.unwrap().unwrap();
2747        }
2748    }
2749
2750    /// #691: an override spelled `Path` is the `PATH` the child sees on Windows,
2751    /// so resolution must read it too; elsewhere it is another variable.
2752    #[test]
2753    fn child_env_var_compares_names_as_the_host_does() {
2754        let parent = |_: &str| Some(std::ffi::OsString::from("/parent"));
2755        let on = |host| {
2756            let mut config = bare_server_config(HashMap::new());
2757            config.env = crate::config::ServerEnv::from_entries(
2758                [(
2759                    crate::config::EnvKey::from_static("Path"),
2760                    "/over".to_string(),
2761                )],
2762                host,
2763            )
2764            .unwrap();
2765            child_env_var(&config, "PATH", parent)
2766        };
2767
2768        assert_eq!(on(HostOs::Windows), Some(std::ffi::OsString::from("/over")));
2769        assert_eq!(on(HostOs::Other), Some(std::ffi::OsString::from("/parent")));
2770    }
2771
2772    /// Minimal [`LspServerConfig`] for `build_command` tests, where only
2773    /// `command`/`args`/`env` matter.
2774    fn bare_server_config(env: HashMap<crate::config::EnvKey, String>) -> LspServerConfig {
2775        LspServerConfig {
2776            language_id: LanguageId::from_static("test"),
2777            command: ServerCommand::from_static("irrelevant-for-build-command").into(),
2778            args: vec![],
2779            env: crate::config::ServerEnv::from_entries(env, HostOs::CURRENT).unwrap(),
2780            file_patterns: vec![],
2781            initialization_options: None,
2782            settings: None,
2783            timeout_seconds: TimeoutSecs::new(5).unwrap(),
2784            request_timeout_seconds: TimeoutSecs::new(5).unwrap(),
2785            heuristics: None,
2786            name: None,
2787            handles: None,
2788            indexing: crate::bridge::IndexingPolicy::Auto,
2789        }
2790    }
2791
2792    /// Collects the env vars a `Command` would set, resolving `env_clear`
2793    /// removals (`None` values from `get_envs`) away so the map reflects
2794    /// what the child process would actually see.
2795    fn effective_envs(command: &Command) -> HashMap<String, String> {
2796        command
2797            .as_std()
2798            .get_envs()
2799            .filter_map(|(k, v)| {
2800                v.map(|v| {
2801                    (
2802                        k.to_string_lossy().into_owned(),
2803                        v.to_string_lossy().into_owned(),
2804                    )
2805                })
2806            })
2807            .collect()
2808    }
2809
2810    /// Regression test for #236/#246: a spawned LSP server used to inherit
2811    /// mcpls's entire environment. `build_command` must only pass through
2812    /// `ENV_PASSTHROUGH` keys from `parent_env`, not arbitrary ones.
2813    #[test]
2814    fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
2815        let config = bare_server_config(HashMap::new());
2816        let command =
2817            LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| match key {
2818                "PATH" => Some("/parent/bin".into()),
2819                "MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
2820                _ => None,
2821            });
2822
2823        let envs = effective_envs(&command);
2824
2825        assert!(
2826            !envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
2827            "non-allowlisted parent env var leaked into child command: {envs:?}"
2828        );
2829
2830        // The assertion above is provably vacuous on its own:
2831        // `Command::get_envs()` only reports explicit `.env()`/`.envs()`
2832        // modifications and is blind to whether `.env_clear()` was called,
2833        // and `build_command`'s passthrough loop never even queries
2834        // `parent_env` for a key outside `ENV_PASSTHROUGH`, so it would
2835        // pass unchanged even if `.env_clear()` were deleted from
2836        // `build_command` entirely. `std::process::Command`'s `Debug` impl
2837        // does encode clearing, prefixing the formatted command with
2838        // `env -i ` on Unix once `.env_clear()` has run; assert on that to
2839        // actually guard against the clear being removed.
2840        #[cfg(unix)]
2841        assert!(
2842            format!("{:?}", command.as_std()).starts_with("env -i "),
2843            "build_command must call .env_clear() so the child doesn't inherit the full parent environment"
2844        );
2845    }
2846
2847    /// Regression test for #236/#246: allowlisted vars present in the parent
2848    /// (e.g. `PATH`) must still reach the child.
2849    #[test]
2850    fn test_build_command_passes_through_allowlisted_env_vars() {
2851        let config = bare_server_config(HashMap::new());
2852        let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| {
2853            (key == "PATH").then(|| "/parent/bin".into())
2854        });
2855
2856        let envs = effective_envs(&command);
2857
2858        assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
2859    }
2860
2861    #[test]
2862    fn test_build_command_inherits_working_dir_unless_fixed() {
2863        let config = bare_server_config(HashMap::new());
2864        let inherited = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |_| None);
2865        assert_eq!(inherited.as_std().get_current_dir(), None);
2866
2867        let dir = PathBuf::from("/fixed/dir");
2868        let fixed =
2869            LspServer::build_command(&config, &ChildWorkingDir::Fixed(dir.clone()), |_| None);
2870        assert_eq!(fixed.as_std().get_current_dir(), Some(dir.as_path()));
2871    }
2872
2873    #[test]
2874    fn test_server_init_config_defaults_to_inherited_working_dir() {
2875        let config = ServerInitConfig::new(
2876            LspServerConfig::rust_analyzer(),
2877            WorkspaceRoots::default(),
2878            PositionEncodings::DEFAULT,
2879            std::sync::Arc::default(),
2880        );
2881        assert_eq!(config.child_working_dir(), &ChildWorkingDir::Inherit);
2882        let fixed = config.with_child_working_dir(ChildWorkingDir::Fixed(PathBuf::from("/x")));
2883        assert_eq!(
2884            fixed.child_working_dir(),
2885            &ChildWorkingDir::Fixed(PathBuf::from("/x"))
2886        );
2887    }
2888
2889    #[tokio::test]
2890    async fn test_for_respawn_keeps_the_config_and_pinned_tsserver() {
2891        let mut server_config = LspServerConfig::typescript();
2892        server_config.initialization_options =
2893            Some(serde_json::json!({"tsserver": {"path": "/pin/tsserver.js"}}));
2894        let config = ServerInitConfig::new(
2895            server_config,
2896            WorkspaceRoots::default(),
2897            PositionEncodings::DEFAULT,
2898            std::sync::Arc::default(),
2899        );
2900        assert_eq!(
2901            config.pinned_tsserver(),
2902            Some(PathBuf::from("/pin/tsserver.js"))
2903        );
2904
2905        let respawn = config.for_respawn().await.unwrap();
2906        assert_eq!(
2907            respawn.server_config().initialization_options,
2908            config.server_config().initialization_options
2909        );
2910        assert_eq!(respawn.pinned_tsserver(), config.pinned_tsserver());
2911    }
2912
2913    /// An auto-pinned server is re-planned on the blocking pool and the result
2914    /// keeps the entry's identity; a pin that no longer resolves is dropped.
2915    #[tokio::test]
2916    async fn test_for_respawn_replans_an_auto_pinned_server_off_the_runtime() {
2917        let mut server_config = LspServerConfig::typescript();
2918        server_config.initialization_options =
2919            Some(serde_json::json!({"tsserver": {"path": "/mcpls-test/stale/tsserver.js"}}));
2920        let config = ServerInitConfig::new(
2921            server_config,
2922            WorkspaceRoots::default(),
2923            PositionEncodings::DEFAULT,
2924            std::sync::Arc::default(),
2925        )
2926        .with_auto_pin(PathBuf::from("/mcpls-test/stale/tsserver.js"), None);
2927
2928        let respawn = config.for_respawn().await.unwrap();
2929
2930        assert_eq!(respawn.server_config().id(), config.server_config().id());
2931        assert_ne!(respawn.pinned_tsserver(), config.pinned_tsserver());
2932    }
2933
2934    /// Regression test for #247: `LspServerConfig::env` entries must reach
2935    /// the spawned child (previously dead configuration).
2936    #[test]
2937    fn test_build_command_includes_configured_env_vars() {
2938        let mut env = HashMap::new();
2939        env.insert(
2940            crate::config::EnvKey::from_static("MCPLS_TEST_CONFIGURED"),
2941            "from-server-config".to_string(),
2942        );
2943        let config = bare_server_config(env);
2944        let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |_| None);
2945
2946        let envs = effective_envs(&command);
2947
2948        assert_eq!(
2949            envs.get("MCPLS_TEST_CONFIGURED"),
2950            Some(&"from-server-config".to_string())
2951        );
2952    }
2953
2954    /// Regression test for #247: a `LspServerConfig::env` entry must be able
2955    /// to override an allowlisted passthrough value, since `config.env` is
2956    /// applied after the passthrough loop in `build_command`.
2957    #[test]
2958    fn test_build_command_configured_env_overrides_allowlisted_var() {
2959        let mut env = HashMap::new();
2960        env.insert(
2961            crate::config::EnvKey::from_static("PATH"),
2962            "/configured/override/path".to_string(),
2963        );
2964        let config = bare_server_config(env);
2965        let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| {
2966            (key == "PATH").then(|| "/parent/bin".into())
2967        });
2968
2969        let envs = effective_envs(&command);
2970
2971        assert_eq!(
2972            envs.get("PATH"),
2973            Some(&"/configured/override/path".to_string())
2974        );
2975    }
2976
2977    /// #174 section 8 regression: the diagnostics route must be read from the
2978    /// router as re-derived after the explicit diagnostics server failed, not
2979    /// from the configured one. A narrow "diagnostics-only" server
2980    /// (`pyright-diag`) fails to start, leaving only a catch-all (`pylsp`);
2981    /// resolving `Diagnostics` against the configured router would name the
2982    /// dead `pyright-diag` and silence diagnostics for `python`.
2983    #[tokio::test]
2984    async fn test_settling_dead_diagnostics_server_hands_the_route_to_the_catch_all() {
2985        use crate::bridge::Translator;
2986        use crate::config::{ServerId, ToolKind, ToolRouter};
2987
2988        let pylsp_id = ServerId::from_static("pylsp");
2989        let configs = vec![
2990            LspServerConfig {
2991                language_id: LanguageId::from_static("python"),
2992                command: ServerCommand::from_static("pyright-langserver").into(),
2993                args: vec![],
2994                env: crate::config::ServerEnv::default(),
2995                file_patterns: vec![],
2996                initialization_options: None,
2997                settings: None,
2998                timeout_seconds: TimeoutSecs::new(30).unwrap(),
2999                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
3000                heuristics: None,
3001                name: Some(ServerId::from_static("pyright-diag")),
3002                handles: Some(ToolSet::new(vec![ToolKind::Diagnostics]).unwrap()),
3003                indexing: crate::bridge::IndexingPolicy::Auto,
3004            },
3005            LspServerConfig {
3006                language_id: LanguageId::from_static("python"),
3007                command: ServerCommand::from_static("pylsp").into(),
3008                args: vec![],
3009                env: crate::config::ServerEnv::default(),
3010                file_patterns: vec![],
3011                initialization_options: None,
3012                settings: None,
3013                timeout_seconds: TimeoutSecs::new(30).unwrap(),
3014                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
3015                heuristics: None,
3016                name: Some(ServerId::from_static("pylsp")),
3017                handles: None,
3018                indexing: crate::bridge::IndexingPolicy::Auto,
3019            },
3020        ];
3021        let router = ToolRouter::from_configs(&configs).unwrap();
3022        let translator = Translator::new().with_router(router);
3023        translator.set_expected_servers(
3024            [ServerId::from_static("pyright-diag"), pylsp_id.clone()]
3025                .into_iter()
3026                .collect(),
3027        );
3028
3029        translator.settle_failed(&ServerSpawnFailure {
3030            server_id: ServerId::from_static("pyright-diag"),
3031            language_id: LanguageId::from_static("python"),
3032            command: ServerCommand::from_static("pyright-langserver"),
3033            reason: StartupFailure::InitTaskPanicked,
3034        });
3035        translator.settle_started(fake_lsp_server_with_config(configs[1].clone()));
3036
3037        assert!(
3038            translator.is_diagnostics_route(&LanguageId::from_static("python"), &pylsp_id),
3039            "pylsp must inherit the diagnostics route once pyright-diag is known dead"
3040        );
3041    }
3042}