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