Skip to main content

aft/lsp/
manager.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::time::{Duration, Instant};
4
5use crossbeam_channel::{bounded, unbounded, Receiver, RecvTimeoutError, Sender, TrySendError};
6use lsp_types::notification::{
7    DidChangeTextDocument, DidChangeWatchedFiles, DidCloseTextDocument, DidOpenTextDocument,
8};
9use lsp_types::{
10    DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams,
11    DidOpenTextDocumentParams, FileChangeType, FileEvent, TextDocumentContentChangeEvent,
12    TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier,
13};
14
15use crate::alert_state::AcceptedDiagnosticSnapshot;
16use crate::config::Config;
17use crate::lsp::child_registry::LspChildRegistry;
18use crate::lsp::client::{LspClient, LspEvent, ReaderExitReap, ServerState};
19use crate::lsp::diagnostics::{
20    from_lsp_diagnostics, DiagnosticEntry, DiagnosticsStore, StoredDiagnostic,
21};
22use crate::lsp::document::DocumentStore;
23use crate::lsp::position::{uri_for_path, uri_to_path};
24use crate::lsp::pull_params::{
25    AftDocumentDiagnosticParams, AftDocumentDiagnosticRequest, AftWorkspaceDiagnosticParams,
26    AftWorkspaceDiagnosticRequest,
27};
28use crate::lsp::registry::{resolve_server_binary, servers_for_file, ServerDef, ServerKind};
29use crate::lsp::roots::ServerKey;
30use crate::lsp::LspError;
31use crate::slog_error;
32use crate::slog_info;
33
34const STDERR_REASON_BYTES: usize = 2 * 1024;
35/// The total grace period for draining every LSP client during process shutdown.
36const LSP_SHUTDOWN_ALL_BUDGET: Duration = Duration::from_secs(5);
37
38fn server_key_for_definition(
39    def: &ServerDef,
40    file_path: &Path,
41    config: &Config,
42) -> Option<ServerKey> {
43    def.workspace_root_for_file_with_project_root(file_path, config.project_root.as_deref())
44        .map(|root| ServerKey {
45            kind: def.kind.clone(),
46            root,
47        })
48}
49
50fn server_key_sort(left: &ServerKey, right: &ServerKey) -> std::cmp::Ordering {
51    left.kind
52        .id_str()
53        .cmp(right.kind.id_str())
54        .then(left.root.cmp(&right.root))
55}
56
57/// Outcome of attempting to ensure a server is running for a single matching
58/// `ServerDef`. Returned per matching server so the caller can report exactly
59/// what happened to the user instead of collapsing all failures into "no
60/// server".
61#[derive(Debug, Clone)]
62pub enum ServerAttemptResult {
63    /// Server is running and ready to serve requests for this file.
64    Ok { server_key: ServerKey },
65    /// No workspace root was found by walking up from the file looking for
66    /// any of the server's configured root markers.
67    NoRootMarker { looked_for: Vec<String> },
68    /// The server's binary could not be found on PATH (or override was
69    /// missing/invalid).
70    BinaryNotInstalled { binary: String },
71    /// Binary was found but spawning or initializing the server failed.
72    SpawnFailed { binary: String, reason: String },
73}
74
75/// One server's attempt to handle a file.
76#[derive(Debug, Clone)]
77pub struct ServerAttempt {
78    /// Stable server identifier (kind ID, e.g. "pyright", "rust-analyzer").
79    pub server_id: String,
80    /// Server display name from the registry.
81    pub server_name: String,
82    pub result: ServerAttemptResult,
83}
84
85/// Aggregate outcome of `ensure_server_for_file_detailed`. Distinguishes:
86/// - "No server registered for this file's extension" (`attempts.is_empty()`)
87/// - "Servers registered but none could start" (`successful.is_empty()` but
88///   `!attempts.is_empty()`)
89/// - "At least one server is ready" (`!successful.is_empty()`)
90#[derive(Debug, Clone, Default)]
91pub struct EnsureServerOutcomes {
92    /// Server keys that are now running and ready to serve requests.
93    pub successful: Vec<ServerKey>,
94    /// Per-server attempt records. Empty if no server is registered for the
95    /// file's extension.
96    pub attempts: Vec<ServerAttempt>,
97}
98
99/// Side-effect-free result of resolving the server kinds applicable to an
100/// inspection root. The public record set is deliberately only `ServerKey`s;
101/// the definitions retained internally are used later by the explicit start
102/// step and never cause a process to be opened during resolution.
103#[derive(Clone, Debug)]
104pub struct ApplicableServerSnapshot {
105    pub server_keys: Vec<ServerKey>,
106    candidates: Vec<ApplicableServerCandidate>,
107    producer_failures: Vec<ApplicableServerFailure>,
108}
109
110#[derive(Clone, Debug)]
111struct ApplicableServerCandidate {
112    key: ServerKey,
113    definition: ServerDef,
114    source_file: PathBuf,
115}
116
117#[derive(Clone, Debug)]
118pub enum ApplicabilityResolutionError {
119    RootUnreadable { root: PathBuf, reason: String },
120    RequestDeadline { root: PathBuf },
121}
122
123#[derive(Clone, Debug)]
124pub struct ApplicableServerFailure {
125    pub server_key: ServerKey,
126    pub result: ServerAttemptResult,
127}
128
129impl ApplicableServerFailure {
130    pub fn reason(&self) -> String {
131        self.result.failure_reason()
132    }
133}
134
135#[derive(Clone, Debug, Default)]
136pub struct ApplicableServerStartOutcomes {
137    pub successful: Vec<ServerKey>,
138    pub failures: Vec<ApplicableServerFailure>,
139    /// The first producer whose startup could not complete before the inspect
140    /// request's shared work deadline.
141    pub deadline_exceeded: Option<ServerKey>,
142}
143
144impl ServerAttemptResult {
145    pub fn failure_reason(&self) -> String {
146        match self {
147            Self::BinaryNotInstalled { binary } => format!("{binary} is unavailable"),
148            Self::SpawnFailed { reason, .. } => reason.clone(),
149            Self::NoRootMarker { looked_for } => {
150                format!(
151                    "no workspace root marker found (looked for {})",
152                    looked_for.join(", ")
153                )
154            }
155            Self::Ok { .. } => "server started successfully".to_string(),
156        }
157    }
158}
159
160impl EnsureServerOutcomes {
161    /// True if no server in the registry matched this file's extension.
162    pub fn no_server_registered(&self) -> bool {
163        self.attempts.is_empty()
164    }
165
166    /// True when servers matched the file's extension but none actually apply
167    /// to this project — i.e. nothing started and every attempt failed the root
168    /// marker check (e.g. oxlint registered for `.ts` with no `.oxlintrc.json`).
169    /// Distinct from `no_server_registered` (extension unsupported) and from a
170    /// real outage (binary missing / spawn failed): a missing root marker is a
171    /// filesystem fact that never changes mid-scan, so such a file will never
172    /// produce diagnostics and must not be reported as "pending".
173    pub fn only_inapplicable_root_markers(&self) -> bool {
174        self.successful.is_empty()
175            && !self.attempts.is_empty()
176            && self
177                .attempts
178                .iter()
179                .all(|attempt| matches!(attempt.result, ServerAttemptResult::NoRootMarker { .. }))
180    }
181}
182
183/// Outcome of a post-edit diagnostics wait. Reports the per-server status
184/// alongside the fresh diagnostics, so the response layer can build an
185/// honest tri-state payload (`success: true` + `complete: bool` + named
186/// gap fields per `crates/aft/src/protocol.rs`).
187///
188/// `diagnostics` only contains entries from servers that proved freshness.
189/// `accepted_snapshots` preserves that same result per producer and document
190/// version so alert consumers never infer a partition from flattened output.
191/// Pre-edit cached entries and unversioned reports are NEVER included.
192#[derive(Debug, Clone, Default)]
193pub struct PostEditWaitOutcome {
194    /// Complete, per-producer accepted snapshots. This includes a snapshot
195    /// with an empty diagnostics list when a live server authoritatively found
196    /// the edited document clean; pending, exited, warming, and unversioned
197    /// producers do not appear here.
198    pub accepted_snapshots: Vec<AcceptedDiagnosticSnapshot>,
199    /// Authoritative diagnostics flattened only for the legacy response body.
200    /// Consumers that preserve producer partitions must use
201    /// `accepted_snapshots` instead.
202    pub diagnostics: Vec<StoredDiagnostic>,
203    /// Servers we expected to publish but didn't before the deadline.
204    /// Reported to the agent via `pending_lsp_servers` so they understand
205    /// the result is partial.
206    pub pending_servers: Vec<ServerKey>,
207    /// Servers whose process exited between notification and deadline.
208    /// Reported separately so the agent knows the gap is unrecoverable
209    /// without a server restart, not "wait longer."
210    pub exited_servers: Vec<ServerKey>,
211}
212
213/// Pre-edit freshness snapshot for one server/file pair.
214#[derive(Debug, Clone, Copy, Default)]
215pub struct PreEditSnapshot {
216    pub epoch: u64,
217    pub document_version_at_capture: Option<i32>,
218}
219
220#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
221pub struct StaleDiagnosticsMark {
222    pub had_entries: bool,
223    pub changed: bool,
224}
225
226pub fn post_edit_entry_is_fresh(
227    entry: &DiagnosticEntry,
228    target_version: i32,
229    pre: PreEditSnapshot,
230) -> bool {
231    if entry.stale || entry.epoch <= pre.epoch {
232        return false;
233    }
234
235    match entry.version {
236        Some(version) => version >= target_version,
237        // Unversioned publishDiagnostics payloads cannot prove which document
238        // state they describe. Epoch advancement only proves arrival order; an
239        // old analysis result can still arrive after our pre-snapshot. Treat as
240        // pending/partial rather than fresh.
241        None => false,
242    }
243}
244
245impl PostEditWaitOutcome {
246    /// True if every expected server reported a fresh result. False means
247    /// the agent should treat the diagnostics as a partial picture.
248    pub fn complete(&self) -> bool {
249        self.pending_servers.is_empty() && self.exited_servers.is_empty()
250    }
251}
252
253/// Per-server outcome of a `textDocument/diagnostic` (per-file pull) request.
254#[derive(Debug, Clone)]
255pub enum PullFileOutcome {
256    /// Server returned a full report; diagnostics stored.
257    Full { diagnostic_count: usize },
258    /// Server returned `kind: "unchanged"` — cached diagnostics still valid.
259    Unchanged,
260    /// Server returned a partial-result token; we don't subscribe to streamed
261    /// progress so the response is treated as a soft empty until the next pull.
262    PartialNotSupported,
263    /// Server doesn't advertise pull capability — caller should fall back to
264    /// push diagnostics for this server.
265    PullNotSupported,
266    /// The pull request failed (timeout, server error, etc.).
267    RequestFailed { reason: String },
268}
269
270/// Result of ensuring a document is open in every matching server.
271#[derive(Debug, Clone, Default)]
272pub struct EnsureFileOpenResult {
273    pub server_keys: Vec<ServerKey>,
274    /// Servers that received `textDocument/didOpen` during this call.
275    pub newly_opened: Vec<ServerKey>,
276}
277
278impl EnsureFileOpenResult {
279    pub fn is_empty(&self) -> bool {
280        self.server_keys.is_empty()
281    }
282}
283
284/// Result of `pull_file_diagnostics` for one matching server.
285#[derive(Debug, Clone)]
286pub struct PullFileResult {
287    pub server_key: ServerKey,
288    pub outcome: PullFileOutcome,
289}
290
291/// Result of `pull_workspace_diagnostics` for a single server.
292#[derive(Debug, Clone)]
293pub struct PullWorkspaceResult {
294    pub server_key: ServerKey,
295    /// Files for which a Full report was received and cached. Files that came
296    /// back as `Unchanged` are NOT listed here because their cached entry was
297    /// already authoritative.
298    pub files_reported: Vec<PathBuf>,
299    /// True if the server returned a full response within the timeout.
300    pub complete: bool,
301    /// True if we cancelled (request timed out before the server responded).
302    pub cancelled: bool,
303    /// True if the server advertised workspace pull support. When false, the
304    /// other fields are empty and the caller should fall back to file-mode
305    /// pull or to push semantics.
306    pub supports_workspace: bool,
307}
308
309pub struct DrainedLspEvents {
310    pub events: Vec<LspEvent>,
311    pub diagnostics_changed: bool,
312    /// Complete snapshots accepted from document-version-verified publishes
313    /// emitted by already-live, quiescent producers. Runtime consumers must
314    /// retain each producer's snapshot until they construct an
315    /// `AcceptedObservation` that records those producer-specific results;
316    /// flattening the snapshots would erase producer ownership.
317    pub accepted_snapshots: Vec<AcceptedDiagnosticSnapshot>,
318    pub has_more: bool,
319}
320
321/// State carried by an `AppContext` post-edit wait after it releases the
322/// manager mutex. The raw receiver clone competes for each event exactly once;
323/// the dedicated wake receiver covers the case where another drain path wins
324/// that race and updates the manager before this waiter sees the raw event.
325pub(crate) struct PostEditDiagnosticsWait {
326    lookup_path: PathBuf,
327    expected_versions: Vec<(ServerKey, i32)>,
328    pre_snapshot: HashMap<ServerKey, PreEditSnapshot>,
329    event_rx: Receiver<LspEvent>,
330    wake_rx: Receiver<()>,
331    waiter_id: u64,
332    deadline: std::time::Instant,
333    fresh: HashMap<ServerKey, Vec<StoredDiagnostic>>,
334    exited: Vec<ServerKey>,
335}
336
337impl PostEditDiagnosticsWait {
338    pub(crate) fn deadline_reached(&self) -> bool {
339        std::time::Instant::now() >= self.deadline
340    }
341
342    pub(crate) fn next_event(&self) -> Option<LspEvent> {
343        let remaining = self
344            .deadline
345            .saturating_duration_since(std::time::Instant::now());
346        if remaining.is_zero() {
347            return None;
348        }
349
350        crossbeam_channel::select! {
351            recv(self.event_rx) -> event => event.ok(),
352            recv(self.wake_rx) -> _ => None,
353            default(remaining) => None,
354        }
355    }
356}
357
358impl IntoIterator for DrainedLspEvents {
359    type Item = LspEvent;
360    type IntoIter = std::vec::IntoIter<LspEvent>;
361
362    fn into_iter(self) -> Self::IntoIter {
363        self.events.into_iter()
364    }
365}
366
367/// Result of one bounded all-client shutdown. The same counts are emitted in
368/// the `lsp shutdown_all` summary log.
369#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
370pub struct LspShutdownAllOutcome {
371    pub graceful: usize,
372    pub forced: usize,
373    pub elapsed: Duration,
374}
375
376pub struct LspManager {
377    /// Active server instances, keyed by (ServerKind, workspace_root).
378    clients: HashMap<ServerKey, LspClient>,
379    /// Binary names for active server instances. Kept separate from
380    /// `LspClient` so crash handling can report the installable binary name
381    /// after a post-initialize process exit.
382    server_binaries: HashMap<ServerKey, String>,
383    /// Tracks opened documents and versions per active server.
384    documents: HashMap<ServerKey, DocumentStore>,
385    /// Stored publishDiagnostics payloads across all servers.
386    diagnostics: DiagnosticsStore,
387    /// Unified event channel — all server reader threads send here.
388    event_tx: Sender<LspEvent>,
389    event_rx: Receiver<LspEvent>,
390    /// One-slot wake channels for post-edit waits that released the manager
391    /// mutex. Drains notify them after handling an event, so a waiter cannot
392    /// sleep through a diagnostics update consumed by another drain path.
393    post_edit_waiters: HashMap<u64, Sender<()>>,
394    next_post_edit_waiter_id: u64,
395    /// Optional binary path overrides used by integration tests.
396    binary_overrides: HashMap<ServerKind, PathBuf>,
397    /// Last plugin-pushed LSP search paths. `None` keeps direct `LspManager`
398    /// users compatible with the paths supplied on their `Config` argument.
399    pushed_search_paths: Option<Vec<PathBuf>>,
400    /// Extra env vars merged into every spawned LSP child. Used in tests to
401    /// drive the fake server's behavioral variants (`AFT_FAKE_LSP_PULL=1`,
402    /// `AFT_FAKE_LSP_WORKSPACE=1`, etc.). Production code does not set this.
403    extra_env: HashMap<String, String>,
404    /// Per-(kind,root) cache of spawn failures. Once a server fails to spawn
405    /// for a workspace root, we remember why and skip subsequent attempts for
406    /// the lifetime of this AFT process. Without this, every file open or
407    /// didChange retries `spawn_server` and logs a fresh ERROR — visible as
408    /// repeated `failed to spawn TypeScript Language Server: Could not find a
409    /// valid TypeScript installation` lines per edit.
410    ///
411    /// Entries are NEVER evicted automatically. The expected recovery path is
412    /// for the user to fix their environment (install the missing binary or
413    /// add a `tsconfig.json` / `package.json` with the right dependency) and
414    /// restart OpenCode/Pi, which spawns a fresh `aft` process with an empty
415    /// cache. We deliberately don't auto-retry on file events: the failure
416    /// modes we track here (binary not installed, init handshake failure)
417    /// don't fix themselves at runtime.
418    failed_spawns: HashMap<ServerKey, ServerAttemptResult>,
419    /// Server/root pairs for which we already logged that watched-file
420    /// notifications are skipped because the capability is absent.
421    watched_file_skip_logged: HashSet<ServerKey>,
422    /// The last watched-file routing decision, retained on Windows so a CI
423    /// timeout can distinguish a skipped send from a delayed fake-server reply.
424    #[cfg(windows)]
425    last_watched_file_notification_trace: String,
426    /// Tracks PIDs of spawned LSP child processes so the signal handler can
427    /// kill them on SIGTERM/SIGINT before aft exits, preventing orphans.
428    /// Defaults to empty; production wires this from `AppContext`.
429    child_registry: LspChildRegistry,
430}
431
432impl LspManager {
433    pub fn new() -> Self {
434        let (event_tx, event_rx) = unbounded();
435        Self {
436            clients: HashMap::new(),
437            server_binaries: HashMap::new(),
438            documents: HashMap::new(),
439            diagnostics: DiagnosticsStore::new(),
440            event_tx,
441            event_rx,
442            post_edit_waiters: HashMap::new(),
443            next_post_edit_waiter_id: 0,
444            binary_overrides: HashMap::new(),
445            pushed_search_paths: None,
446            extra_env: HashMap::new(),
447            failed_spawns: HashMap::new(),
448            watched_file_skip_logged: HashSet::new(),
449            #[cfg(windows)]
450            last_watched_file_notification_trace: "no watched-file notification attempted"
451                .to_string(),
452            child_registry: LspChildRegistry::new(),
453        }
454    }
455
456    /// Set the child-PID registry. Must be called before any servers spawn.
457    pub fn set_child_registry(&mut self, registry: LspChildRegistry) {
458        self.child_registry = registry;
459    }
460
461    /// For testing: set an extra environment variable that gets passed to
462    /// every spawned LSP child process. Useful for driving fake-server
463    /// behavioral variants in integration tests.
464    pub fn set_extra_env(&mut self, key: &str, value: &str) {
465        self.extra_env.insert(key.to_string(), value.to_string());
466    }
467
468    /// Count active LSP server instances.
469    pub fn server_count(&self) -> usize {
470        self.clients.len()
471    }
472
473    /// Estimate the per-server document metadata and diagnostics retained by
474    /// the manager. LSP child-process memory is outside this process RSS and is
475    /// not included.
476    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
477        let mut bytes = 0u64;
478        let mut document_count = 0u64;
479        for documents in self.documents.values() {
480            let estimate = documents.estimated_memory();
481            bytes = bytes.saturating_add(estimate.estimated_bytes.unwrap_or(0));
482            document_count = document_count
483                .saturating_add(estimate.counts.get("documents").copied().unwrap_or(0));
484        }
485        let diagnostics = self.diagnostics.estimated_memory();
486        bytes = bytes.saturating_add(diagnostics.estimated_bytes.unwrap_or(0));
487        crate::memory::MemoryEstimate::estimated(bytes)
488            .count("servers", self.clients.len())
489            .count("document_stores", self.documents.len())
490            .count_u64("documents", document_count)
491            .count_u64(
492                "diagnostic_entries",
493                diagnostics
494                    .counts
495                    .get("diagnostic_entries")
496                    .copied()
497                    .unwrap_or(0),
498            )
499            .count_u64(
500                "diagnostics",
501                diagnostics.counts.get("diagnostics").copied().unwrap_or(0),
502            )
503    }
504
505    /// Apply the configured diagnostic LRU cap (the `lsp.diagnostic_cache_size`
506    /// knob). 0 disables the cap. Called at construction so the documented
507    /// config field actually takes effect instead of always using the default.
508    pub fn set_diagnostic_capacity(&mut self, capacity: usize) {
509        self.diagnostics.set_capacity(capacity);
510    }
511
512    /// For testing: override the binary for a server kind.
513    pub fn override_binary(&mut self, kind: ServerKind, binary_path: PathBuf) {
514        self.binary_overrides.insert(kind, binary_path);
515    }
516
517    /// Replace the plugin-managed binary search paths used by future lazy starts.
518    /// Existing servers stay live; failed starts are forgotten when the paths
519    /// change so a newly installed binary is retried without restarting AFT.
520    pub fn set_search_paths(&mut self, paths: Vec<PathBuf>) -> bool {
521        if self.pushed_search_paths.as_ref() == Some(&paths) {
522            return false;
523        }
524        self.pushed_search_paths = Some(paths);
525        self.clear_failed_spawns();
526        true
527    }
528
529    /// Resolve every configured server applicable to a root without starting it.
530    pub fn resolve_applicable_servers_for_root(
531        &self,
532        project_root: &Path,
533        config: &Config,
534    ) -> Result<ApplicableServerSnapshot, ApplicabilityResolutionError> {
535        self.resolve_applicable_servers(project_root, None, config, None)
536    }
537
538    /// Resolve inspect producers within the request deadline. A scoped inspect
539    /// may start rust-analyzer only for the Cargo workspace that owns each scope;
540    /// an unrelated nested workspace must not gain a process merely because it
541    /// lives elsewhere under the configured project root.
542    pub fn resolve_applicable_servers_for_inspect(
543        &self,
544        project_root: &Path,
545        scope_roots: Option<&[PathBuf]>,
546        config: &Config,
547        deadline: std::time::Instant,
548    ) -> Result<ApplicableServerSnapshot, ApplicabilityResolutionError> {
549        self.resolve_applicable_servers(project_root, scope_roots, config, Some(deadline))
550    }
551
552    fn resolve_applicable_servers(
553        &self,
554        project_root: &Path,
555        scope_roots: Option<&[PathBuf]>,
556        config: &Config,
557        deadline: Option<std::time::Instant>,
558    ) -> Result<ApplicableServerSnapshot, ApplicabilityResolutionError> {
559        if !project_root.is_dir() {
560            return Err(ApplicabilityResolutionError::RootUnreadable {
561                root: project_root.to_path_buf(),
562                reason: "project root is not a directory".to_string(),
563            });
564        }
565
566        let mut candidates = HashMap::<ServerKey, ApplicableServerCandidate>::new();
567        let mut producer_failures = HashMap::<ServerKey, ApplicableServerFailure>::new();
568        // Prevent a disappearing child mount from making ReadDir::drop abort on ENXIO.
569        let walker = ignore::WalkBuilder::new(project_root)
570            .same_file_system(true)
571            .standard_filters(true)
572            .add_custom_ignore_filename(".aftignore")
573            .filter_entry(|entry| {
574                !matches!(
575                    entry.file_name().to_string_lossy().as_ref(),
576                    ".git" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo"
577                )
578            })
579            .build();
580
581        for entry in walker {
582            if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
583                return Err(ApplicabilityResolutionError::RequestDeadline {
584                    root: project_root.to_path_buf(),
585                });
586            }
587            let entry = entry.map_err(|error| ApplicabilityResolutionError::RootUnreadable {
588                root: project_root.to_path_buf(),
589                reason: error.to_string(),
590            })?;
591            if !entry
592                .file_type()
593                .is_some_and(|file_type| file_type.is_file())
594            {
595                continue;
596            }
597            let file = entry.path();
598            for definition in servers_for_file(file, config) {
599                let Some(key) = server_key_for_definition(&definition, file, config) else {
600                    continue;
601                };
602                if candidates.contains_key(&key) || producer_failures.contains_key(&key) {
603                    continue;
604                }
605                if let Some(result) = self.failed_spawns.get(&key) {
606                    producer_failures.insert(
607                        key.clone(),
608                        ApplicableServerFailure {
609                            server_key: key,
610                            result: result.clone(),
611                        },
612                    );
613                    continue;
614                }
615                if self.resolve_binary(&definition, &key.root, config).is_err() {
616                    producer_failures.insert(
617                        key.clone(),
618                        ApplicableServerFailure {
619                            server_key: key,
620                            result: ServerAttemptResult::BinaryNotInstalled {
621                                binary: definition.binary.clone(),
622                            },
623                        },
624                    );
625                    continue;
626                }
627                candidates.insert(
628                    key.clone(),
629                    ApplicableServerCandidate {
630                        key,
631                        definition,
632                        source_file: file.to_path_buf(),
633                    },
634                );
635            }
636        }
637
638        if let Some(scope_roots) = scope_roots.filter(|roots| !roots.is_empty()) {
639            let rust_roots = candidates
640                .keys()
641                .chain(producer_failures.keys())
642                .filter(|key| key.kind == ServerKind::Rust)
643                .map(|key| key.root.clone())
644                .collect::<HashSet<_>>();
645            let mut owners = HashSet::new();
646            for scope_root in scope_roots {
647                if let Some(owner) = rust_roots
648                    .iter()
649                    .filter(|root| scope_root.starts_with(root))
650                    .max_by_key(|root| root.components().count())
651                {
652                    owners.insert(owner.clone());
653                }
654            }
655            candidates.retain(|key, _| key.kind != ServerKind::Rust || owners.contains(&key.root));
656            producer_failures
657                .retain(|key, _| key.kind != ServerKind::Rust || owners.contains(&key.root));
658        }
659
660        let mut candidates = candidates.into_values().collect::<Vec<_>>();
661        candidates.sort_by(|left, right| server_key_sort(&left.key, &right.key));
662        let mut producer_failures = producer_failures.into_values().collect::<Vec<_>>();
663        producer_failures
664            .sort_by(|left, right| server_key_sort(&left.server_key, &right.server_key));
665        let mut server_keys = candidates
666            .iter()
667            .map(|candidate| candidate.key.clone())
668            .chain(
669                producer_failures
670                    .iter()
671                    .map(|failure| failure.server_key.clone()),
672            )
673            .collect::<Vec<_>>();
674        server_keys.sort_by(server_key_sort);
675        Ok(ApplicableServerSnapshot {
676            server_keys,
677            candidates,
678            producer_failures,
679        })
680    }
681
682    /// Start exactly the servers selected by a prior applicability snapshot.
683    ///
684    /// No document is opened here. That boundary makes a late configuration or
685    /// filesystem change apply to the next inspection rather than starting an
686    /// unrecorded server in the current call.
687    pub fn start_applicable_servers(
688        &mut self,
689        snapshot: &ApplicableServerSnapshot,
690        config: &Config,
691    ) -> ApplicableServerStartOutcomes {
692        self.start_applicable_servers_inner(snapshot, config, None)
693    }
694
695    /// Start one inspect producer without extending the request's shared deadline.
696    /// Calling this per key lets the inspect phase log begin a phase only while
697    /// request budget remains to attempt that producer.
698    pub fn start_applicable_server_until(
699        &mut self,
700        snapshot: &ApplicableServerSnapshot,
701        server: &ServerKey,
702        config: &Config,
703        deadline: std::time::Instant,
704    ) -> ApplicableServerStartOutcomes {
705        let single = ApplicableServerSnapshot {
706            server_keys: vec![server.clone()],
707            candidates: snapshot
708                .candidates
709                .iter()
710                .filter(|candidate| candidate.key == *server)
711                .cloned()
712                .collect(),
713            producer_failures: snapshot
714                .producer_failures
715                .iter()
716                .filter(|failure| failure.server_key == *server)
717                .cloned()
718                .collect(),
719        };
720        self.start_applicable_servers_inner(&single, config, Some(deadline))
721    }
722
723    fn start_applicable_servers_inner(
724        &mut self,
725        snapshot: &ApplicableServerSnapshot,
726        config: &Config,
727        deadline: Option<std::time::Instant>,
728    ) -> ApplicableServerStartOutcomes {
729        let mut outcomes = ApplicableServerStartOutcomes {
730            failures: snapshot.producer_failures.clone(),
731            ..ApplicableServerStartOutcomes::default()
732        };
733        for candidate in &snapshot.candidates {
734            let initialize_timeout = deadline
735                .map(|deadline| deadline.saturating_duration_since(std::time::Instant::now()));
736            if initialize_timeout.is_some_and(|remaining| remaining.is_zero()) {
737                outcomes.deadline_exceeded = Some(candidate.key.clone());
738                break;
739            }
740            if self.clients.contains_key(&candidate.key) {
741                outcomes.successful.push(candidate.key.clone());
742                continue;
743            }
744            match self.spawn_server_with_timeout(
745                &candidate.definition,
746                &candidate.key.root,
747                &candidate.source_file,
748                config,
749                initialize_timeout,
750            ) {
751                Ok(client) => {
752                    self.clients.insert(candidate.key.clone(), client);
753                    self.server_binaries
754                        .insert(candidate.key.clone(), candidate.definition.binary.clone());
755                    self.documents.entry(candidate.key.clone()).or_default();
756                    outcomes.successful.push(candidate.key.clone());
757                }
758                Err(error) => {
759                    if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
760                        // A request-owned timeout says nothing durable about the
761                        // producer; a later call with a fresh budget may retry it.
762                        outcomes.deadline_exceeded = Some(candidate.key.clone());
763                        break;
764                    }
765                    let result = classify_spawn_error(&candidate.definition.binary, &error);
766                    self.failed_spawns
767                        .insert(candidate.key.clone(), result.clone());
768                    outcomes.failures.push(ApplicableServerFailure {
769                        server_key: candidate.key.clone(),
770                        result,
771                    });
772                }
773            }
774        }
775        outcomes
776    }
777
778    /// Ensure a server is running for the given file. Spawns if needed.
779    /// Returns the active server keys for the file, or an empty vec if none match.
780    ///
781    /// This is the lightweight wrapper around [`ensure_server_for_file_detailed`]
782    /// that drops failure context. Prefer the detailed variant in command
783    /// handlers that need to surface honest error messages to the agent.
784    pub fn ensure_server_for_file(&mut self, file_path: &Path, config: &Config) -> Vec<ServerKey> {
785        self.ensure_server_for_file_detailed(file_path, config)
786            .successful
787    }
788
789    fn running_server_keys_for_file(&self, file_path: &Path, config: &Config) -> Vec<ServerKey> {
790        servers_for_file(file_path, config)
791            .into_iter()
792            .filter_map(|def| server_key_for_definition(&def, file_path, config))
793            .filter(|key| self.clients.contains_key(key))
794            .collect()
795    }
796
797    /// Return whether a navigation request would need to initialize an applicable
798    /// server. Missing binaries, inapplicable root markers, and cached spawn
799    /// failures stay on the synchronous path because they cannot incur a
800    /// handshake wait.
801    pub fn navigation_requires_deferred_execution(
802        &self,
803        file_path: &Path,
804        config: &Config,
805    ) -> bool {
806        let Ok(canonical_path) = canonicalize_for_lsp(file_path) else {
807            return false;
808        };
809        servers_for_file(&canonical_path, config)
810            .into_iter()
811            .filter_map(|definition| {
812                let key = server_key_for_definition(&definition, &canonical_path, config)?;
813                Some((definition, key))
814            })
815            .any(|(definition, key)| {
816                if let Some(client) = self.clients.get(&key) {
817                    return client.state() != ServerState::Ready;
818                }
819                !self.failed_spawns.contains_key(&key)
820                    && self.resolve_binary(&definition, &key.root, config).is_ok()
821            })
822    }
823
824    /// Detailed version of [`ensure_server_for_file`] that records every
825    /// matching server's outcome (`Ok` / `NoRootMarker` / `BinaryNotInstalled`
826    /// / `SpawnFailed`).
827    ///
828    /// Use this when the caller wants to honestly report _why_ a file has no
829    /// active server (e.g., to surface "bash-language-server not on PATH" to
830    /// the agent instead of silently returning `total: 0`).
831    pub fn ensure_server_for_file_detailed(
832        &mut self,
833        file_path: &Path,
834        config: &Config,
835    ) -> EnsureServerOutcomes {
836        let defs = servers_for_file(file_path, config);
837        let mut outcomes = EnsureServerOutcomes::default();
838
839        for def in defs {
840            let server_id = def.kind.id_str().to_string();
841            let server_name = def.name.to_string();
842
843            let Some(key) = server_key_for_definition(&def, file_path, config) else {
844                outcomes.attempts.push(ServerAttempt {
845                    server_id,
846                    server_name,
847                    result: ServerAttemptResult::NoRootMarker {
848                        looked_for: def.root_markers.iter().map(|s| s.to_string()).collect(),
849                    },
850                });
851                continue;
852            };
853
854            if !self.clients.contains_key(&key) {
855                // If we already tried and failed to spawn this server for this
856                // root, return the cached classification without retrying or
857                // re-logging. This prevents per-edit ERROR spam when the user's
858                // environment is missing a dependency the LSP needs (the
859                // typescript-language-server "Could not find a valid TypeScript
860                // installation" case is the canonical example).
861                if let Some(cached) = self.failed_spawns.get(&key) {
862                    outcomes.attempts.push(ServerAttempt {
863                        server_id,
864                        server_name,
865                        result: cached.clone(),
866                    });
867                    continue;
868                }
869
870                // A spurious ServerExited can drop the client without killing
871                // the child. Reap any still-tracked processes for this pair
872                // before spawning a replacement, otherwise leaked servers
873                // accumulate on a live worktree.
874                self.reap_unreferenced_children_for(&key);
875
876                match self.spawn_server(&def, &key.root, file_path, config) {
877                    Ok(client) => {
878                        self.clients.insert(key.clone(), client);
879                        self.server_binaries.insert(key.clone(), def.binary.clone());
880                        self.documents.entry(key.clone()).or_default();
881                    }
882                    Err(err) => {
883                        slog_error!("failed to spawn {}: {}", def.name, err);
884                        let result = classify_spawn_error(&def.binary, &err);
885                        // Remember the failure so subsequent file events skip
886                        // this (kind, root) pair instead of producing a fresh
887                        // spawn attempt + ERROR log per request.
888                        self.failed_spawns.insert(key.clone(), result.clone());
889                        outcomes.attempts.push(ServerAttempt {
890                            server_id,
891                            server_name,
892                            result,
893                        });
894                        continue;
895                    }
896                }
897            }
898
899            outcomes.attempts.push(ServerAttempt {
900                server_id,
901                server_name,
902                result: ServerAttemptResult::Ok {
903                    server_key: key.clone(),
904                },
905            });
906            outcomes.successful.push(key);
907        }
908
909        outcomes
910    }
911
912    /// Ensure a server is running using the default LSP registry.
913    /// Kept for integration tests that exercise built-in server helpers directly.
914    pub fn ensure_server_for_file_default(&mut self, file_path: &Path) -> Vec<ServerKey> {
915        self.ensure_server_for_file(file_path, &Config::default())
916    }
917    /// Ensure that servers are running for the file and that the document is open
918    /// in each server's DocumentStore. Reads file content from disk if not already open.
919    /// The result identifies which servers were already tracking the document and which
920    /// received `textDocument/didOpen` during this call.
921    pub fn ensure_file_open(
922        &mut self,
923        file_path: &Path,
924        config: &Config,
925    ) -> Result<EnsureFileOpenResult, LspError> {
926        let canonical_path = canonicalize_for_lsp(file_path)?;
927        let server_keys = self.ensure_server_for_file(&canonical_path, config);
928        if server_keys.is_empty() {
929            return Ok(EnsureFileOpenResult::default());
930        }
931
932        let uri = uri_for_path(&canonical_path)?;
933        let language_id = language_id_for_extension(
934            canonical_path
935                .extension()
936                .and_then(|ext| ext.to_str())
937                .unwrap_or_default(),
938        )
939        .to_string();
940        let needs_content = server_keys.iter().any(|key| {
941            !self
942                .documents
943                .get(key)
944                .is_some_and(|store| store.is_open(&canonical_path))
945        });
946        let initial_content = needs_content
947            .then(|| std::fs::read_to_string(&canonical_path).map_err(LspError::Io))
948            .transpose()?;
949        let mut newly_opened = Vec::new();
950
951        for key in &server_keys {
952            let already_open = self
953                .documents
954                .get(key)
955                .is_some_and(|store| store.is_open(&canonical_path));
956
957            if !already_open {
958                let content = initial_content
959                    .as_ref()
960                    .expect("content is loaded when any server needs didOpen");
961                let (send_result, sent) = if let Some(client) = self.clients.get_mut(key) {
962                    (
963                        client.send_notification::<DidOpenTextDocument>(
964                            DidOpenTextDocumentParams {
965                                text_document: TextDocumentItem::new(
966                                    uri.clone(),
967                                    language_id.clone(),
968                                    0,
969                                    content.clone(),
970                                ),
971                            },
972                        ),
973                        true,
974                    )
975                } else {
976                    (Ok(()), false)
977                };
978                if let Err(err) = send_result {
979                    let _ = self.close_file_for_servers(&canonical_path, &newly_opened);
980                    return Err(err);
981                }
982                if sent {
983                    log_did_open_sent(key, &canonical_path, &language_id);
984                }
985                self.documents
986                    .entry(key.clone())
987                    .or_default()
988                    .open(canonical_path.clone());
989                newly_opened.push(key.clone());
990                continue;
991            }
992
993            // Document is already open. Check disk drift — if the file has
994            // been modified outside the AFT pipeline (other tool, manual
995            // edit, sibling session) we MUST send a didChange before any
996            // pull-diagnostic / hover query, otherwise the LSP server
997            // returns results computed from stale in-memory content.
998            //
999            // Without this, ensure_file_open would skip an already-open file
1000            // without checking whether its disk content changed, leaving the
1001            // server's in-memory copy stale.
1002            let drifted = self
1003                .documents
1004                .get(key)
1005                .is_some_and(|store| store.is_stale_on_disk(&canonical_path));
1006            if drifted {
1007                let content = match std::fs::read_to_string(&canonical_path) {
1008                    Ok(content) => content,
1009                    Err(err) => {
1010                        let _ = self.close_file_for_servers(&canonical_path, &newly_opened);
1011                        return Err(LspError::Io(err));
1012                    }
1013                };
1014                let next_version = self
1015                    .documents
1016                    .get(key)
1017                    .and_then(|store| store.version(&canonical_path))
1018                    .map(|v| v + 1)
1019                    .unwrap_or(1);
1020                let send_result = if let Some(client) = self.clients.get_mut(key) {
1021                    client.send_notification::<DidChangeTextDocument>(DidChangeTextDocumentParams {
1022                        text_document: VersionedTextDocumentIdentifier::new(
1023                            uri.clone(),
1024                            next_version,
1025                        ),
1026                        content_changes: vec![TextDocumentContentChangeEvent {
1027                            range: None,
1028                            range_length: None,
1029                            text: content,
1030                        }],
1031                    })
1032                } else {
1033                    Ok(())
1034                };
1035                if let Err(err) = send_result {
1036                    let _ = self.close_file_for_servers(&canonical_path, &newly_opened);
1037                    return Err(err);
1038                }
1039                if let Some(store) = self.documents.get_mut(key) {
1040                    store.bump_version(&canonical_path);
1041                }
1042            }
1043        }
1044
1045        Ok(EnsureFileOpenResult {
1046            server_keys,
1047            newly_opened,
1048        })
1049    }
1050
1051    pub fn ensure_file_open_default(
1052        &mut self,
1053        file_path: &Path,
1054    ) -> Result<EnsureFileOpenResult, LspError> {
1055        self.ensure_file_open(file_path, &Config::default())
1056    }
1057
1058    /// Notify relevant LSP servers that a file has been written/changed.
1059    /// This is the main hook called after every file write in AFT.
1060    ///
1061    /// If the file's server isn't running yet, starts it (lazy spawn).
1062    /// If the file isn't open in LSP yet, sends didOpen. Otherwise sends didChange.
1063    pub fn notify_file_changed(
1064        &mut self,
1065        file_path: &Path,
1066        content: &str,
1067        config: &Config,
1068    ) -> Result<(), LspError> {
1069        self.notify_file_changed_versioned(file_path, content, config)
1070            .map(|_| ())
1071    }
1072
1073    /// Like `notify_file_changed`, but returns the target document version
1074    /// per server so the post-edit waiter can match `publishDiagnostics`
1075    /// against the exact version that this notification carried.
1076    ///
1077    /// Returns: `Vec<(ServerKey, target_version)>`. `target_version` is the
1078    /// `version` field on the `VersionedTextDocumentIdentifier` we just sent
1079    /// (post-bump). For freshly-opened documents (`didOpen`) the version is
1080    /// `0`. Servers that do not echo a document version cannot prove a report
1081    /// describes this edit, so post-edit observation leaves them pending rather
1082    /// than accepting a report merely because it arrived after the edit.
1083    pub fn notify_file_changed_versioned(
1084        &mut self,
1085        file_path: &Path,
1086        content: &str,
1087        config: &Config,
1088    ) -> Result<Vec<(ServerKey, i32)>, LspError> {
1089        let canonical_path = canonicalize_for_lsp(file_path)?;
1090        let server_keys = self.ensure_server_for_file(&canonical_path, config);
1091        self.notify_file_changed_for_server_keys(canonical_path, content, server_keys)
1092    }
1093
1094    /// Notify only LSP servers that are already running for this file.
1095    ///
1096    /// Post-write notifications are best-effort and must not make a mutation
1097    /// wait for cold server startup. Explicit LSP requests use
1098    /// [`Self::notify_file_changed_versioned`] and retain lazy startup.
1099    pub fn notify_file_changed_if_running(
1100        &mut self,
1101        file_path: &Path,
1102        content: &str,
1103        config: &Config,
1104    ) -> Result<(), LspError> {
1105        self.notify_file_changed_if_running_versioned(file_path, content, config)
1106            .map(|_| ())
1107    }
1108
1109    /// Notify only already-live servers and retain the document versions that
1110    /// prove a subsequent diagnostics report belongs to this post-edit wait.
1111    /// Unlike `notify_file_changed_versioned`, this path never starts a server.
1112    pub fn notify_file_changed_if_running_versioned(
1113        &mut self,
1114        file_path: &Path,
1115        content: &str,
1116        config: &Config,
1117    ) -> Result<Vec<(ServerKey, i32)>, LspError> {
1118        let canonical_path = canonicalize_for_lsp(file_path)?;
1119        let server_keys = self.running_server_keys_for_file(&canonical_path, config);
1120        self.notify_file_changed_for_server_keys(canonical_path, content, server_keys)
1121    }
1122
1123    fn notify_file_changed_for_server_keys(
1124        &mut self,
1125        canonical_path: PathBuf,
1126        content: &str,
1127        server_keys: Vec<ServerKey>,
1128    ) -> Result<Vec<(ServerKey, i32)>, LspError> {
1129        if server_keys.is_empty() {
1130            return Ok(Vec::new());
1131        }
1132
1133        let uri = uri_for_path(&canonical_path)?;
1134        let language_id = language_id_for_extension(
1135            canonical_path
1136                .extension()
1137                .and_then(|ext| ext.to_str())
1138                .unwrap_or_default(),
1139        )
1140        .to_string();
1141
1142        let mut versions: Vec<(ServerKey, i32)> = Vec::with_capacity(server_keys.len());
1143
1144        for key in server_keys {
1145            let current_version = self
1146                .documents
1147                .get(&key)
1148                .and_then(|store| store.version(&canonical_path));
1149
1150            if let Some(version) = current_version {
1151                let next_version = version + 1;
1152                if let Some(client) = self.clients.get_mut(&key) {
1153                    client.send_notification::<DidChangeTextDocument>(
1154                        DidChangeTextDocumentParams {
1155                            text_document: VersionedTextDocumentIdentifier::new(
1156                                uri.clone(),
1157                                next_version,
1158                            ),
1159                            content_changes: vec![TextDocumentContentChangeEvent {
1160                                range: None,
1161                                range_length: None,
1162                                text: content.to_string(),
1163                            }],
1164                        },
1165                    )?;
1166                }
1167                if let Some(store) = self.documents.get_mut(&key) {
1168                    store.bump_version(&canonical_path);
1169                }
1170                versions.push((key, next_version));
1171                continue;
1172            }
1173
1174            if let Some(client) = self.clients.get_mut(&key) {
1175                client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
1176                    text_document: TextDocumentItem::new(
1177                        uri.clone(),
1178                        language_id.clone(),
1179                        0,
1180                        content.to_string(),
1181                    ),
1182                })?;
1183                log_did_open_sent(&key, &canonical_path, &language_id);
1184            }
1185            self.documents
1186                .entry(key.clone())
1187                .or_default()
1188                .open(canonical_path.clone());
1189            // didOpen carries version 0 — that's the version the server
1190            // will echo on its first publishDiagnostics for this document.
1191            versions.push((key, 0));
1192        }
1193
1194        Ok(versions)
1195    }
1196
1197    pub fn notify_file_changed_default(
1198        &mut self,
1199        file_path: &Path,
1200        content: &str,
1201    ) -> Result<(), LspError> {
1202        self.notify_file_changed(file_path, content, &Config::default())
1203    }
1204
1205    /// Notify every active server whose workspace contains at least one changed
1206    /// path that watched files changed. This is intentionally workspace-scoped
1207    /// rather than extension-scoped: configuration edits such as `package.json`
1208    /// or `tsconfig.json` affect a server's project graph even though those
1209    /// files may not be documents handled by the server itself.
1210    pub fn notify_files_watched_changed(
1211        &mut self,
1212        paths: &[(PathBuf, FileChangeType)],
1213        _config: &Config,
1214    ) -> Result<(), LspError> {
1215        #[cfg(windows)]
1216        let mut trace = vec![format!(
1217            "input_paths={paths:?}; active_keys={:?}",
1218            self.clients.keys().collect::<Vec<_>>()
1219        )];
1220
1221        if paths.is_empty() {
1222            #[cfg(windows)]
1223            {
1224                trace.push("outcome=no-input-paths".to_string());
1225                self.last_watched_file_notification_trace = trace.join("\n");
1226            }
1227            return Ok(());
1228        }
1229
1230        let mut canonical_events = Vec::with_capacity(paths.len());
1231        for (path, typ) in paths {
1232            let canonical_path = resolve_for_lsp_uri(path);
1233            canonical_events.push((canonical_path, *typ));
1234        }
1235        #[cfg(windows)]
1236        trace.push(format!("resolved_events={canonical_events:?}"));
1237
1238        let keys: Vec<ServerKey> = self.clients.keys().cloned().collect();
1239        #[cfg(windows)]
1240        if keys.is_empty() {
1241            trace.push("outcome=no-active-client".to_string());
1242        }
1243        for key in keys {
1244            let mut changes = Vec::new();
1245            for (path, typ) in &canonical_events {
1246                if !path.starts_with(&key.root) {
1247                    continue;
1248                }
1249                changes.push(FileEvent::new(uri_for_path(path)?, *typ));
1250            }
1251
1252            if changes.is_empty() {
1253                #[cfg(windows)]
1254                trace.push(format!("key={key:?}; outcome=outside-root"));
1255                continue;
1256            }
1257
1258            if let Some(client) = self.clients.get_mut(&key) {
1259                // Send when the server either advertised initialize-time
1260                // watched-file support or dynamically registered a watcher.
1261                // The dynamic client capability we send during initialize only
1262                // permits runtime registration; it is tracked separately via
1263                // `has_watched_file_registration()`.
1264                let supports_static_watched_files = client.supports_watched_files();
1265                let has_dynamic_registration = client.has_watched_file_registration();
1266                if !(supports_static_watched_files || has_dynamic_registration) {
1267                    #[cfg(windows)]
1268                    trace.push(format!(
1269                        "key={key:?}; changes={changes:?}; outcome=unsupported; static={supports_static_watched_files}; dynamic={has_dynamic_registration}"
1270                    ));
1271                    if self.watched_file_skip_logged.insert(key.clone()) {
1272                        log::debug!(
1273                            "skipping didChangeWatchedFiles for {:?} (not supported or registered)",
1274                            key
1275                        );
1276                    }
1277                    continue;
1278                }
1279                #[cfg(windows)]
1280                trace.push(format!("key={key:?}; changes={changes:?}; action=send"));
1281                let send_result = client.send_notification::<DidChangeWatchedFiles>(
1282                    DidChangeWatchedFilesParams { changes },
1283                );
1284                #[cfg(windows)]
1285                trace.push(format!(
1286                    "key={key:?}; outcome={}",
1287                    if send_result.is_ok() {
1288                        "sent"
1289                    } else {
1290                        "send-error"
1291                    }
1292                ));
1293                if let Err(error) = send_result {
1294                    #[cfg(windows)]
1295                    {
1296                        self.last_watched_file_notification_trace = trace.join("\n");
1297                    }
1298                    return Err(error);
1299                }
1300            }
1301        }
1302
1303        #[cfg(windows)]
1304        {
1305            self.last_watched_file_notification_trace = trace.join("\n");
1306        }
1307        Ok(())
1308    }
1309
1310    /// Close a document in all servers that have it open.
1311    pub fn notify_file_closed(&mut self, file_path: &Path) -> Result<(), LspError> {
1312        let canonical_path = canonicalize_for_lsp(file_path)?;
1313        let keys = self
1314            .documents
1315            .iter()
1316            .filter(|(_, store)| store.is_open(&canonical_path))
1317            .map(|(key, _)| key.clone())
1318            .collect::<Vec<_>>();
1319        self.close_file_for_servers(&canonical_path, &keys)
1320    }
1321
1322    /// Close a document only in the specified servers.
1323    ///
1324    /// Scoped inspection uses this to release documents it opened without
1325    /// disturbing pre-existing editor documents in other server stores.
1326    pub(crate) fn close_file_for_servers(
1327        &mut self,
1328        file_path: &Path,
1329        server_keys: &[ServerKey],
1330    ) -> Result<(), LspError> {
1331        let canonical_path = canonicalize_for_lsp(file_path)?;
1332        let uri = uri_for_path(&canonical_path)?;
1333        let mut first_error = None;
1334
1335        for key in server_keys {
1336            let was_open = self
1337                .documents
1338                .get(key)
1339                .is_some_and(|store| store.is_open(&canonical_path));
1340            if !was_open {
1341                continue;
1342            }
1343
1344            if let Some(client) = self.clients.get_mut(key) {
1345                if let Err(err) =
1346                    client.send_notification::<DidCloseTextDocument>(DidCloseTextDocumentParams {
1347                        text_document: TextDocumentIdentifier::new(uri.clone()),
1348                    })
1349                {
1350                    if first_error.is_none() {
1351                        first_error = Some(err);
1352                    }
1353                }
1354            }
1355
1356            if let Some(store) = self.documents.get_mut(key) {
1357                store.close(&canonical_path);
1358            }
1359            self.diagnostics.clear_for_server_file(key, &canonical_path);
1360        }
1361
1362        match first_error {
1363            Some(err) => Err(err),
1364            None => Ok(()),
1365        }
1366    }
1367
1368    /// Get an active client for a file path, if one exists.
1369    pub fn client_for_file(&self, file_path: &Path, config: &Config) -> Option<&LspClient> {
1370        let key = self.server_key_for_file(file_path, config)?;
1371        self.clients.get(&key)
1372    }
1373
1374    pub fn client_for_file_default(&self, file_path: &Path) -> Option<&LspClient> {
1375        self.client_for_file(file_path, &Config::default())
1376    }
1377
1378    /// Get a mutable active client for a file path, if one exists.
1379    pub fn client_for_file_mut(
1380        &mut self,
1381        file_path: &Path,
1382        config: &Config,
1383    ) -> Option<&mut LspClient> {
1384        let key = self.server_key_for_file(file_path, config)?;
1385        self.clients.get_mut(&key)
1386    }
1387
1388    pub fn client_for_file_mut_default(&mut self, file_path: &Path) -> Option<&mut LspClient> {
1389        self.client_for_file_mut(file_path, &Config::default())
1390    }
1391
1392    /// Number of tracked server clients.
1393    pub fn active_client_count(&self) -> usize {
1394        self.clients.len()
1395    }
1396
1397    /// Drain all pending LSP events. Call from the main loop.
1398    pub fn drain_events(&mut self) -> DrainedLspEvents {
1399        self.drain_events_bounded(usize::MAX)
1400    }
1401
1402    /// Whether LSP events are waiting to be drained. Cheap channel peek for
1403    /// the maintenance scheduler's skip probe.
1404    pub fn has_pending_events(&self) -> bool {
1405        !self.event_rx.is_empty()
1406    }
1407
1408    pub fn drain_events_bounded(&mut self, max_events: usize) -> DrainedLspEvents {
1409        let mut events = Vec::new();
1410        let mut diagnostics_changed = false;
1411        let mut accepted_snapshots = Vec::new();
1412        while events.len() < max_events {
1413            let Ok(event) = self.event_rx.try_recv() else {
1414                break;
1415            };
1416            if self.handle_event(&event).is_some() {
1417                diagnostics_changed = true;
1418            }
1419            if let Some(snapshot) = self.accepted_live_publish_snapshot(&event) {
1420                accepted_snapshots.push(snapshot);
1421            }
1422            events.push(event);
1423        }
1424        let has_more = events.len() >= max_events && !self.event_rx.is_empty();
1425        DrainedLspEvents {
1426            events,
1427            diagnostics_changed,
1428            accepted_snapshots,
1429            has_more,
1430        }
1431    }
1432
1433    /// Wait for diagnostics to arrive for a specific file until a timeout expires.
1434    pub fn wait_for_diagnostics(
1435        &mut self,
1436        file_path: &Path,
1437        config: &Config,
1438        timeout: std::time::Duration,
1439    ) -> Vec<StoredDiagnostic> {
1440        let deadline = std::time::Instant::now() + timeout;
1441        self.wait_for_file_diagnostics(file_path, config, deadline)
1442    }
1443
1444    pub fn wait_for_diagnostics_default(
1445        &mut self,
1446        file_path: &Path,
1447        timeout: std::time::Duration,
1448    ) -> Vec<StoredDiagnostic> {
1449        self.wait_for_diagnostics(file_path, &Config::default(), timeout)
1450    }
1451
1452    /// Test-only accessor for the diagnostics store. Used by integration
1453    /// tests that need to inspect per-server entries (e.g., to verify that
1454    /// `ServerKey::root` is populated correctly, not the empty path that
1455    /// the legacy `publish_with_kind` path produced).
1456    #[doc(hidden)]
1457    pub fn diagnostics_store_for_test(&self) -> &DiagnosticsStore {
1458        &self.diagnostics
1459    }
1460
1461    #[doc(hidden)]
1462    pub fn diagnostics_store_mut_for_test(&mut self) -> &mut DiagnosticsStore {
1463        &mut self.diagnostics
1464    }
1465
1466    #[doc(hidden)]
1467    pub fn post_edit_outcome_for_entry_for_test(
1468        key: ServerKey,
1469        entry: &DiagnosticEntry,
1470        target_version: i32,
1471        pre: PreEditSnapshot,
1472    ) -> PostEditWaitOutcome {
1473        Self::post_edit_outcome_for_entry(key, entry, target_version, pre)
1474    }
1475
1476    fn post_edit_outcome_for_entry(
1477        key: ServerKey,
1478        entry: &DiagnosticEntry,
1479        target_version: i32,
1480        pre: PreEditSnapshot,
1481    ) -> PostEditWaitOutcome {
1482        let mut fresh = HashMap::new();
1483        if let Some(diagnostics) =
1484            Self::authoritative_post_edit_diagnostics(entry, target_version, pre)
1485        {
1486            fresh.insert(key.clone(), diagnostics);
1487        }
1488        Self::post_edit_outcome(vec![(key, target_version)], fresh, Vec::new())
1489    }
1490
1491    fn authoritative_post_edit_diagnostics(
1492        entry: &DiagnosticEntry,
1493        target_version: i32,
1494        pre: PreEditSnapshot,
1495    ) -> Option<Vec<StoredDiagnostic>> {
1496        (!entry.provisional && post_edit_entry_is_fresh(entry, target_version, pre))
1497            .then(|| entry.diagnostics.clone())
1498    }
1499
1500    #[doc(hidden)]
1501    pub fn enqueue_event_for_test(&self, event: LspEvent) {
1502        self.event_tx
1503            .send(event)
1504            .expect("LSP event receiver should remain connected");
1505    }
1506
1507    // Used only by the Unix-gated child-spawning test modules.
1508    #[cfg(all(test, unix))]
1509    pub(crate) fn event_sender_for_test(&self) -> Sender<LspEvent> {
1510        self.event_tx.clone()
1511    }
1512
1513    // Used only by the Unix-gated child-spawning test modules.
1514    #[cfg(all(test, unix))]
1515    pub(crate) fn insert_client_for_test(&mut self, client: LspClient) {
1516        let key = ServerKey {
1517            kind: client.kind(),
1518            root: client.root().to_path_buf(),
1519        };
1520        self.clients.insert(key, client);
1521    }
1522
1523    #[doc(hidden)]
1524    pub fn pending_event_count_for_test(&self) -> usize {
1525        self.event_rx.len()
1526    }
1527
1528    #[doc(hidden)]
1529    pub fn document_is_open_for_test(&self, file_path: &Path) -> bool {
1530        canonicalize_for_lsp(file_path).is_ok_and(|canonical_path| {
1531            self.documents
1532                .values()
1533                .any(|store| store.is_open(&canonical_path))
1534        })
1535    }
1536
1537    /// Error/warning counts across the entire warm diagnostics set (all files
1538    /// any server has published for this session). Powers the agent status bar;
1539    /// reads the continuously-drained store with no extra LSP round-trip.
1540    pub fn warm_error_warning_counts(&self) -> (usize, usize) {
1541        self.diagnostics.error_warning_counts()
1542    }
1543
1544    pub fn warm_error_warning_counts_with_provisional(&self) -> ((usize, usize), bool) {
1545        self.diagnostics.error_warning_counts_with_provisional()
1546    }
1547
1548    pub fn diagnostics_generation(&self) -> u64 {
1549        self.diagnostics.generation()
1550    }
1551
1552    /// Status-bar error/warning counts with a per-file `keep` predicate and
1553    /// cross-server dedup applied (see
1554    /// [`DiagnosticsStore::filtered_error_warning_counts`]). The caller supplies
1555    /// the project-root + tsconfig-membership policy via `keep`.
1556    pub fn filtered_error_warning_counts(
1557        &self,
1558        keep: impl FnMut(&std::path::Path) -> bool,
1559    ) -> (usize, usize) {
1560        self.diagnostics.filtered_error_warning_counts(keep)
1561    }
1562
1563    /// Status-bar counts plus whether any kept diagnostics came from a server
1564    /// that is still warming. The readiness flag is needed to retain the last
1565    /// authoritative E/W values while provisional reports replace old entries.
1566    pub fn filtered_error_warning_counts_with_provisional(
1567        &self,
1568        keep: impl FnMut(&std::path::Path) -> bool,
1569    ) -> ((usize, usize), bool) {
1570        self.diagnostics
1571            .filtered_error_warning_counts_with_provisional(keep)
1572    }
1573
1574    /// Active rust-analyzer instances that have not yet reported quiescence.
1575    /// Other server kinds are intentionally absent because they do not use the
1576    /// rust-analyzer readiness extension.
1577    pub fn provisional_server_keys(&self) -> Vec<ServerKey> {
1578        self.clients
1579            .iter()
1580            .filter(|(_, client)| client.diagnostics_are_provisional())
1581            .map(|(key, _)| key.clone())
1582            .collect()
1583    }
1584
1585    /// Snapshot the current per-server epoch for every entry that exists
1586    /// for `file_path`. Servers without an entry yet (never published)
1587    /// are absent from the map; for those, `pre = 0` (any first publish
1588    /// will be considered fresh under the epoch-fallback rule).
1589    pub fn snapshot_diagnostic_epochs(&self, file_path: &Path) -> HashMap<ServerKey, u64> {
1590        let lookup_path = normalize_lookup_path(file_path);
1591        self.diagnostics
1592            .entries_for_file(&lookup_path)
1593            .into_iter()
1594            .map(|(key, entry)| (key.clone(), entry.epoch))
1595            .collect()
1596    }
1597
1598    /// Snapshot the current diagnostic epoch and document version for every
1599    /// active server relevant to `file_path` before a post-edit notification.
1600    pub fn snapshot_pre_edit_state(&self, file_path: &Path) -> HashMap<ServerKey, PreEditSnapshot> {
1601        let lookup_path = normalize_lookup_path(file_path);
1602        let mut snapshots: HashMap<ServerKey, PreEditSnapshot> = self
1603            .diagnostics
1604            .entries_for_file(&lookup_path)
1605            .into_iter()
1606            .map(|(key, entry)| {
1607                (
1608                    key.clone(),
1609                    PreEditSnapshot {
1610                        epoch: entry.epoch,
1611                        document_version_at_capture: None,
1612                    },
1613                )
1614            })
1615            .collect();
1616
1617        for (key, store) in &self.documents {
1618            if let Some(version) = store.version(&lookup_path) {
1619                snapshots
1620                    .entry(key.clone())
1621                    .or_default()
1622                    .document_version_at_capture = Some(version);
1623            }
1624        }
1625
1626        snapshots
1627    }
1628
1629    /// True when the current diagnostic entry for `server_key` can be tied to
1630    /// that server's current in-memory document version for `file_path`.
1631    ///
1632    /// File-mode `lsp_diagnostics` uses this for push-only fallback after it
1633    /// has synced/opened the document. Versioned publishes are accepted when
1634    /// they match the current document version; unversioned publishes are not
1635    /// accepted as fresh because epoch/wall-clock ordering alone is racy.
1636    pub fn diagnostic_entry_is_fresh_for_document(
1637        &self,
1638        file_path: &Path,
1639        server_key: &ServerKey,
1640        pre: PreEditSnapshot,
1641    ) -> bool {
1642        let lookup_path = normalize_lookup_path(file_path);
1643        let Some(entry) = self
1644            .diagnostics
1645            .entries_for_file(&lookup_path)
1646            .into_iter()
1647            .find_map(|(key, entry)| if key == server_key { Some(entry) } else { None })
1648        else {
1649            return false;
1650        };
1651
1652        if entry.stale {
1653            return false;
1654        }
1655
1656        let target_version = self
1657            .documents
1658            .get(server_key)
1659            .and_then(|store| store.version(&lookup_path))
1660            .or(pre.document_version_at_capture)
1661            .unwrap_or(0);
1662
1663        matches!(entry.version, Some(version) if version >= target_version)
1664    }
1665
1666    /// Prepare a post-edit wait and subscribe it before the manager mutex is
1667    /// released. The subscription closes the race between the state snapshot
1668    /// and a concurrent event drain.
1669    pub(crate) fn start_post_edit_diagnostics_wait(
1670        &mut self,
1671        file_path: &Path,
1672        expected_versions: &[(ServerKey, i32)],
1673        pre_snapshot: &HashMap<ServerKey, PreEditSnapshot>,
1674        timeout: std::time::Duration,
1675    ) -> PostEditDiagnosticsWait {
1676        let lookup_path = normalize_lookup_path(file_path);
1677
1678        // Events sent after didChange may already be queued. Handle them before
1679        // parking, while freshness checks still reject pre-edit publications.
1680        let _ = self.drain_events_for_file(&lookup_path);
1681
1682        let waiter_id = self.next_post_edit_waiter_id;
1683        self.next_post_edit_waiter_id = self.next_post_edit_waiter_id.wrapping_add(1);
1684        let (wake_tx, wake_rx) = bounded(1);
1685        self.post_edit_waiters.insert(waiter_id, wake_tx);
1686
1687        PostEditDiagnosticsWait {
1688            lookup_path,
1689            expected_versions: expected_versions.to_vec(),
1690            pre_snapshot: pre_snapshot.clone(),
1691            event_rx: self.event_rx.clone(),
1692            wake_rx,
1693            waiter_id,
1694            deadline: std::time::Instant::now() + timeout,
1695            fresh: HashMap::new(),
1696            exited: Vec::new(),
1697        }
1698    }
1699
1700    pub(crate) fn poll_post_edit_diagnostics_wait(
1701        &mut self,
1702        wait: &mut PostEditDiagnosticsWait,
1703        event: Option<LspEvent>,
1704    ) -> bool {
1705        if let Some(event) = event {
1706            self.handle_event(&event);
1707        }
1708
1709        for (key, target_version) in &wait.expected_versions {
1710            if wait.fresh.contains_key(key) || wait.exited.contains(key) {
1711                continue;
1712            }
1713            if !self.clients.contains_key(key) {
1714                wait.exited.push(key.clone());
1715                continue;
1716            }
1717            if let Some(entry) = self
1718                .diagnostics
1719                .entries_for_file(&wait.lookup_path)
1720                .into_iter()
1721                .find_map(|(stored_key, entry)| (stored_key == key).then_some(entry))
1722            {
1723                let pre = wait.pre_snapshot.get(key).copied().unwrap_or_default();
1724                if let Some(diagnostics) =
1725                    Self::authoritative_post_edit_diagnostics(entry, *target_version, pre)
1726                {
1727                    wait.fresh.insert(key.clone(), diagnostics);
1728                }
1729            }
1730        }
1731
1732        wait.fresh.len() + wait.exited.len() == wait.expected_versions.len()
1733    }
1734
1735    pub(crate) fn finish_post_edit_diagnostics_wait(
1736        &mut self,
1737        wait: PostEditDiagnosticsWait,
1738    ) -> PostEditWaitOutcome {
1739        self.post_edit_waiters.remove(&wait.waiter_id);
1740        Self::post_edit_outcome(wait.expected_versions, wait.fresh, wait.exited)
1741    }
1742
1743    /// Wait for fresh per-server diagnostics matching the just-sent document
1744    /// version. Cached pre-edit entries and provisional warming reports remain
1745    /// pending; a server exit is reported separately.
1746    ///
1747    /// `AppContext` uses the prepare/poll/finish methods directly so channel
1748    /// waiting happens without its manager mutex. This convenience method keeps
1749    /// the same behavior for standalone manager callers.
1750    pub fn wait_for_post_edit_diagnostics(
1751        &mut self,
1752        file_path: &Path,
1753        // `config` is intentionally accepted (matches sibling wait APIs and
1754        // future-proofs us if freshness rules need it). Currently unused
1755        // because expected_versions/pre_snapshot fully determine behavior.
1756        _config: &Config,
1757        expected_versions: &[(ServerKey, i32)],
1758        pre_snapshot: &HashMap<ServerKey, PreEditSnapshot>,
1759        timeout: std::time::Duration,
1760    ) -> PostEditWaitOutcome {
1761        let mut wait = self.start_post_edit_diagnostics_wait(
1762            file_path,
1763            expected_versions,
1764            pre_snapshot,
1765            timeout,
1766        );
1767        let mut complete = self.poll_post_edit_diagnostics_wait(&mut wait, None);
1768
1769        while !complete && !wait.deadline_reached() {
1770            let event = wait.next_event();
1771            complete = self.poll_post_edit_diagnostics_wait(&mut wait, event);
1772        }
1773
1774        self.finish_post_edit_diagnostics_wait(wait)
1775    }
1776
1777    fn post_edit_outcome(
1778        mut expected: Vec<(ServerKey, i32)>,
1779        mut fresh: HashMap<ServerKey, Vec<StoredDiagnostic>>,
1780        exited: Vec<ServerKey>,
1781    ) -> PostEditWaitOutcome {
1782        expected.sort_by(|(left, _), (right, _)| server_key_sort(left, right));
1783
1784        let mut accepted_snapshots = Vec::new();
1785        let mut pending_servers = Vec::new();
1786        for (server_key, document_version) in expected {
1787            if let Some(diagnostics) = fresh.remove(&server_key) {
1788                accepted_snapshots.push(AcceptedDiagnosticSnapshot::new(
1789                    server_key,
1790                    document_version,
1791                    diagnostics,
1792                ));
1793            } else if !exited.contains(&server_key) {
1794                pending_servers.push(server_key);
1795            }
1796        }
1797
1798        let mut diagnostics = accepted_snapshots
1799            .iter()
1800            .flat_map(|snapshot| snapshot.diagnostics.iter().cloned())
1801            .collect::<Vec<_>>();
1802        diagnostics.sort_by(|left, right| {
1803            left.file
1804                .cmp(&right.file)
1805                .then(left.line.cmp(&right.line))
1806                .then(left.column.cmp(&right.column))
1807                .then(left.message.cmp(&right.message))
1808        });
1809
1810        PostEditWaitOutcome {
1811            accepted_snapshots,
1812            diagnostics,
1813            pending_servers,
1814            exited_servers: exited,
1815        }
1816    }
1817
1818    /// Wait for diagnostics to arrive for a specific file until a deadline.
1819    ///
1820    /// Drains already-queued events first, then blocks on the shared event
1821    /// channel only until either `publishDiagnostics` arrives for this file or
1822    /// the deadline is reached.
1823    pub fn wait_for_file_diagnostics(
1824        &mut self,
1825        file_path: &Path,
1826        config: &Config,
1827        deadline: std::time::Instant,
1828    ) -> Vec<StoredDiagnostic> {
1829        let lookup_path = normalize_lookup_path(file_path);
1830
1831        if self.server_key_for_file(&lookup_path, config).is_none() {
1832            return Vec::new();
1833        }
1834
1835        loop {
1836            if self.drain_events_for_file(&lookup_path) {
1837                break;
1838            }
1839
1840            let now = std::time::Instant::now();
1841            if now >= deadline {
1842                break;
1843            }
1844
1845            let timeout = deadline.saturating_duration_since(now);
1846            match self.event_rx.recv_timeout(timeout) {
1847                Ok(event) => {
1848                    if matches!(
1849                        self.handle_event(&event),
1850                        Some(ref published_file) if published_file.as_path() == lookup_path.as_path()
1851                    ) {
1852                        break;
1853                    }
1854                }
1855                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
1856            }
1857        }
1858
1859        self.get_diagnostics_for_file(&lookup_path)
1860            .into_iter()
1861            .cloned()
1862            .collect()
1863    }
1864
1865    /// Default timeout for `textDocument/diagnostic` (per-file pull). Servers
1866    /// usually respond in under 1s for files they've already analyzed; we
1867    /// allow up to 10s before falling back to push semantics. Currently
1868    /// surfaced via [`Self::pull_file_timeout`] for callers that want to
1869    /// override the wait via the `wait_ms` knob.
1870    pub const PULL_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1871
1872    /// Public accessor so command handlers can reuse the documented default.
1873    pub fn pull_file_timeout() -> std::time::Duration {
1874        Self::PULL_FILE_TIMEOUT
1875    }
1876
1877    /// Default timeout for `workspace/diagnostic`. The LSP spec allows the
1878    /// server to hold this open indefinitely; we cap at 10s and report
1879    /// `complete: false` to the agent rather than hanging the bridge.
1880    const PULL_WORKSPACE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1881
1882    /// Issue a `textDocument/diagnostic` (LSP 3.17 per-file pull) request to
1883    /// every server that supports pull diagnostics for the given file.
1884    ///
1885    /// Returns the per-server outcome. If a server reports `kind: "unchanged"`,
1886    /// the cached entry's diagnostics are surfaced (deterministic re-use of
1887    /// the previous response). If a server doesn't advertise pull capability,
1888    /// it's skipped here — the caller should fall back to push for those.
1889    ///
1890    /// Side effects: results are stored in `DiagnosticsStore` so directory-mode
1891    /// queries can aggregate them later.
1892    pub fn pull_file_diagnostics(
1893        &mut self,
1894        file_path: &Path,
1895        config: &Config,
1896    ) -> Result<Vec<PullFileResult>, LspError> {
1897        self.pull_file_diagnostics_inner(file_path, config, None)
1898    }
1899
1900    /// Pull diagnostics within a caller-owned wait budget. This is used after
1901    /// edits because a pull-capable server may disable push diagnostics after
1902    /// seeing the client's LSP 3.17 diagnostic capability.
1903    pub fn pull_file_diagnostics_with_timeout(
1904        &mut self,
1905        file_path: &Path,
1906        config: &Config,
1907        timeout: Duration,
1908    ) -> Result<Vec<PullFileResult>, LspError> {
1909        self.pull_file_diagnostics_inner(file_path, config, Some(Instant::now() + timeout))
1910    }
1911
1912    fn pull_file_diagnostics_inner(
1913        &mut self,
1914        file_path: &Path,
1915        config: &Config,
1916        deadline: Option<Instant>,
1917    ) -> Result<Vec<PullFileResult>, LspError> {
1918        let canonical_path = canonicalize_for_lsp(file_path)?;
1919        // Make sure servers are running and the document is open with fresh
1920        // content (handles disk-drift via DocumentStore::is_stale_on_disk).
1921        let opened = self.ensure_file_open(&canonical_path, config)?;
1922        if opened.server_keys.is_empty() {
1923            return Ok(Vec::new());
1924        }
1925
1926        let uri = uri_for_path(&canonical_path)?;
1927        let mut results = Vec::with_capacity(opened.server_keys.len());
1928
1929        for key in opened.server_keys {
1930            let supports_pull = self
1931                .clients
1932                .get(&key)
1933                .and_then(|c| c.diagnostic_capabilities())
1934                .is_some_and(|caps| caps.pull_diagnostics);
1935
1936            if !supports_pull {
1937                results.push(PullFileResult {
1938                    server_key: key.clone(),
1939                    outcome: PullFileOutcome::PullNotSupported,
1940                });
1941                continue;
1942            }
1943
1944            // Look up previous resultId for incremental requests.
1945            let previous_result_id = self
1946                .diagnostics
1947                .entries_for_file(&canonical_path)
1948                .into_iter()
1949                .find(|(k, _)| **k == key)
1950                .and_then(|(_, entry)| entry.result_id.clone());
1951
1952            let identifier = self
1953                .clients
1954                .get(&key)
1955                .and_then(|c| c.diagnostic_capabilities())
1956                .and_then(|caps| caps.identifier.clone());
1957
1958            let params = AftDocumentDiagnosticParams {
1959                text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
1960                identifier,
1961                previous_result_id,
1962                work_done_progress_params: Default::default(),
1963                partial_result_params: Default::default(),
1964            };
1965
1966            let request_timeout = deadline
1967                .map(|deadline| {
1968                    deadline
1969                        .saturating_duration_since(Instant::now())
1970                        .min(Self::PULL_FILE_TIMEOUT)
1971                })
1972                .unwrap_or(Self::PULL_FILE_TIMEOUT);
1973            let document_version = self
1974                .documents
1975                .get(&key)
1976                .and_then(|store| store.version(&canonical_path));
1977            slog_info!(
1978                "lsp_protocol server={} root={} method=textDocument/diagnostic event=sent file={} document_version={}",
1979                key.kind.id_str(),
1980                key.root.display(),
1981                canonical_path.display(),
1982                document_version
1983                    .map(|version| version.to_string())
1984                    .unwrap_or_else(|| "none".to_string())
1985            );
1986            let outcome = match self.send_pull_request(&key, params, request_timeout) {
1987                Ok(report) => {
1988                    let report_kind = match &report {
1989                        lsp_types::DocumentDiagnosticReportResult::Report(
1990                            lsp_types::DocumentDiagnosticReport::Full(_),
1991                        ) => "full",
1992                        lsp_types::DocumentDiagnosticReportResult::Report(
1993                            lsp_types::DocumentDiagnosticReport::Unchanged(_),
1994                        ) => "unchanged",
1995                        lsp_types::DocumentDiagnosticReportResult::Partial(_) => "partial",
1996                    };
1997                    slog_info!(
1998                        "lsp_protocol server={} root={} method=textDocument/diagnostic event=received file={} report_kind={}",
1999                        key.kind.id_str(),
2000                        key.root.display(),
2001                        canonical_path.display(),
2002                        report_kind
2003                    );
2004                    if matches!(
2005                        &report,
2006                        lsp_types::DocumentDiagnosticReportResult::Report(
2007                            lsp_types::DocumentDiagnosticReport::Full(_)
2008                        )
2009                    ) {
2010                        // The server may publish diagnostics for didOpen before
2011                        // returning a full pull response. Apply those older events
2012                        // first so the full report remains authoritative. An
2013                        // unchanged response must inspect only a previous pull cache.
2014                        self.drain_events();
2015                    }
2016                    self.ingest_document_report(&key, &canonical_path, document_version, report)
2017                }
2018                Err(err) => {
2019                    slog_info!(
2020                        "lsp_protocol server={} root={} method=textDocument/diagnostic event=failed file={} error={}",
2021                        key.kind.id_str(),
2022                        key.root.display(),
2023                        canonical_path.display(),
2024                        err
2025                    );
2026                    if let Some(result) = self.cache_post_initialize_exit(&key, &err) {
2027                        PullFileOutcome::RequestFailed {
2028                            reason: server_attempt_result_reason(&result),
2029                        }
2030                    } else if recoverable_pull_rejection(&err)
2031                        && self.clients.get(&key).is_some_and(|client| {
2032                            matches!(
2033                                client.state(),
2034                                ServerState::Ready | ServerState::Initializing
2035                            )
2036                        })
2037                    {
2038                        PullFileOutcome::RequestFailed {
2039                            reason: format!("pull_rejected_push_fallback: {err}"),
2040                        }
2041                    } else {
2042                        PullFileOutcome::RequestFailed {
2043                            reason: err.to_string(),
2044                        }
2045                    }
2046                }
2047            };
2048
2049            results.push(PullFileResult {
2050                server_key: key,
2051                outcome,
2052            });
2053        }
2054
2055        Ok(results)
2056    }
2057
2058    /// Issue a `workspace/diagnostic` request to a specific server. Cancels
2059    /// internally if `timeout` elapses before the server responds. Cached
2060    /// entries from the response are stored so directory-mode queries pick
2061    /// them up.
2062    pub fn pull_workspace_diagnostics(
2063        &mut self,
2064        server_key: &ServerKey,
2065        timeout: Option<std::time::Duration>,
2066    ) -> Result<PullWorkspaceResult, LspError> {
2067        let timeout = timeout.unwrap_or(Self::PULL_WORKSPACE_TIMEOUT);
2068
2069        let supports_workspace = self
2070            .clients
2071            .get(server_key)
2072            .and_then(|c| c.diagnostic_capabilities())
2073            .is_some_and(|caps| caps.workspace_diagnostics);
2074
2075        if !supports_workspace {
2076            return Ok(PullWorkspaceResult {
2077                server_key: server_key.clone(),
2078                files_reported: Vec::new(),
2079                complete: false,
2080                cancelled: false,
2081                supports_workspace: false,
2082            });
2083        }
2084
2085        let identifier = self
2086            .clients
2087            .get(server_key)
2088            .and_then(|c| c.diagnostic_capabilities())
2089            .and_then(|caps| caps.identifier.clone());
2090
2091        let params = AftWorkspaceDiagnosticParams {
2092            identifier,
2093            previous_result_ids: Vec::new(),
2094            work_done_progress_params: Default::default(),
2095            partial_result_params: Default::default(),
2096        };
2097
2098        let result = match self
2099            .clients
2100            .get_mut(server_key)
2101            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?
2102            .send_request_with_timeout::<AftWorkspaceDiagnosticRequest>(params, timeout)
2103        {
2104            Ok(result) => result,
2105            Err(LspError::Timeout(_)) => {
2106                return Ok(PullWorkspaceResult {
2107                    server_key: server_key.clone(),
2108                    files_reported: Vec::new(),
2109                    complete: false,
2110                    cancelled: true,
2111                    supports_workspace: true,
2112                });
2113            }
2114            Err(err) => {
2115                if let Some(result) = self.cache_post_initialize_exit(server_key, &err) {
2116                    return Err(LspError::ServerNotReady(server_attempt_result_reason(
2117                        &result,
2118                    )));
2119                }
2120                return Err(err);
2121            }
2122        };
2123
2124        // Extract the items list. Partial responses are not a complete
2125        // workspace view, but the partial payload can still contain useful
2126        // document reports; ingest those while surfacing complete=false.
2127        let (items, complete) = match result {
2128            lsp_types::WorkspaceDiagnosticReportResult::Report(report) => (report.items, true),
2129            lsp_types::WorkspaceDiagnosticReportResult::Partial(partial) => (partial.items, false),
2130        };
2131
2132        // Ingest each file report into the diagnostics store.
2133        let mut files_reported = Vec::with_capacity(items.len());
2134        for item in items {
2135            match item {
2136                lsp_types::WorkspaceDocumentDiagnosticReport::Full(full) => {
2137                    if let Some(file) = uri_to_path(&full.uri) {
2138                        let stored = from_lsp_diagnostics(
2139                            file.clone(),
2140                            full.full_document_diagnostic_report.items.clone(),
2141                        );
2142                        self.diagnostics.publish_with_result_id(
2143                            server_key.clone(),
2144                            file.clone(),
2145                            stored,
2146                            full.full_document_diagnostic_report.result_id.clone(),
2147                        );
2148                        files_reported.push(file);
2149                    }
2150                }
2151                lsp_types::WorkspaceDocumentDiagnosticReport::Unchanged(_unchanged) => {
2152                    // "Unchanged" means the previously cached report is still
2153                    // valid. We left it in place; nothing to do.
2154                }
2155            }
2156        }
2157
2158        Ok(PullWorkspaceResult {
2159            server_key: server_key.clone(),
2160            files_reported,
2161            complete,
2162            cancelled: false,
2163            supports_workspace: true,
2164        })
2165    }
2166
2167    fn cache_post_initialize_exit(
2168        &mut self,
2169        key: &ServerKey,
2170        err: &LspError,
2171    ) -> Option<ServerAttemptResult> {
2172        let binary = self
2173            .server_binaries
2174            .get(key)
2175            .cloned()
2176            .unwrap_or_else(|| key.kind.id_str().to_string());
2177        let (status, stderr_tail) = {
2178            let client = self.clients.get_mut(key)?;
2179            let mut status = client.child_exit_status();
2180            for _ in 0..10 {
2181                if status.is_some() {
2182                    break;
2183                }
2184                std::thread::sleep(std::time::Duration::from_millis(10));
2185                status = client.child_exit_status();
2186            }
2187            let status = status?;
2188            wait_for_stderr_tail(client);
2189            (status, client.stderr_tail())
2190        };
2191        let reason = format_post_initialize_exit_reason(&binary, status, &stderr_tail, err);
2192        let result = ServerAttemptResult::SpawnFailed { binary, reason };
2193        self.clients.remove(key);
2194        self.server_binaries.remove(key);
2195        self.documents.remove(key);
2196        self.diagnostics.clear_for_server(key);
2197        self.failed_spawns.insert(key.clone(), result.clone());
2198        Some(result)
2199    }
2200
2201    /// Issue the per-file diagnostic request and return the report.
2202    fn send_pull_request(
2203        &mut self,
2204        key: &ServerKey,
2205        params: AftDocumentDiagnosticParams,
2206        timeout: Duration,
2207    ) -> Result<lsp_types::DocumentDiagnosticReportResult, LspError> {
2208        let client = self
2209            .clients
2210            .get_mut(key)
2211            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?;
2212        // The caller caps this below the global 30s request timeout so a stalled
2213        // pull server cannot consume the entire inspect or post-edit budget.
2214        client.send_request_with_timeout::<AftDocumentDiagnosticRequest>(params, timeout)
2215    }
2216
2217    /// Store the result of a per-file pull request and return a structured
2218    /// outcome the caller can inspect.
2219    fn ingest_document_report(
2220        &mut self,
2221        key: &ServerKey,
2222        canonical_path: &Path,
2223        document_version: Option<i32>,
2224        result: lsp_types::DocumentDiagnosticReportResult,
2225    ) -> PullFileOutcome {
2226        let report = match result {
2227            lsp_types::DocumentDiagnosticReportResult::Report(report) => report,
2228            lsp_types::DocumentDiagnosticReportResult::Partial(_) => {
2229                // Partial results stream in via $/progress notifications which
2230                // we don't currently subscribe to. Treat as a soft-empty
2231                // success — the next pull will get the full version.
2232                return PullFileOutcome::PartialNotSupported;
2233            }
2234        };
2235
2236        match report {
2237            lsp_types::DocumentDiagnosticReport::Full(full) => {
2238                let result_id = full.full_document_diagnostic_report.result_id.clone();
2239                let stored = from_lsp_diagnostics(
2240                    canonical_path.to_path_buf(),
2241                    full.full_document_diagnostic_report.items.clone(),
2242                );
2243                let count = stored.len();
2244                let provisional = self
2245                    .clients
2246                    .get(key)
2247                    .is_some_and(|client| client.diagnostics_are_provisional());
2248                self.diagnostics.publish_full_with_provisional(
2249                    key.clone(),
2250                    canonical_path.to_path_buf(),
2251                    stored,
2252                    result_id,
2253                    document_version,
2254                    provisional,
2255                );
2256                PullFileOutcome::Full {
2257                    diagnostic_count: count,
2258                }
2259            }
2260            lsp_types::DocumentDiagnosticReport::Unchanged(_unchanged) => {
2261                // The server says the previous resultId is still valid for the
2262                // current document. That is only usable if we already have a
2263                // report for this exact server/file; an initial `unchanged`
2264                // response cannot prove freshness. A stale watcher entry is
2265                // acceptable here because the pull response itself proves the
2266                // cached diagnostics still describe the now-synced file.
2267                if self
2268                    .diagnostics
2269                    .has_report_for_server_file(key, canonical_path)
2270                {
2271                    if let Some(version) = document_version {
2272                        self.diagnostics.confirm_for_server_file_version(
2273                            key,
2274                            canonical_path,
2275                            version,
2276                        );
2277                    } else {
2278                        self.diagnostics
2279                            .mark_fresh_for_server_file(key, canonical_path);
2280                    }
2281                    let authoritative = self
2282                        .clients
2283                        .get(key)
2284                        .map_or(true, |client| !client.diagnostics_are_provisional());
2285                    if authoritative {
2286                        self.diagnostics
2287                            .clear_provisional_for_server_file(key, canonical_path);
2288                    }
2289                    PullFileOutcome::Unchanged
2290                } else {
2291                    PullFileOutcome::RequestFailed {
2292                        reason: "no_cache_for_unchanged".to_string(),
2293                    }
2294                }
2295            }
2296        }
2297    }
2298
2299    /// Drain every client and clear spawn/document/diagnostic state without
2300    /// waiting on child processes. The caller owns graceful shutdown so the
2301    /// manager lock is not held across a Shutdown handshake.
2302    pub fn take_all_clients(&mut self) -> Vec<(ServerKey, LspClient)> {
2303        let clients: Vec<_> = self.clients.drain().collect();
2304        self.server_binaries.clear();
2305        self.documents.clear();
2306        self.diagnostics = DiagnosticsStore::new();
2307        clients
2308    }
2309
2310    /// Shut down every server concurrently within one shared grace period.
2311    pub fn shutdown_all(&mut self) -> LspShutdownAllOutcome {
2312        let clients = self.take_all_clients();
2313        Self::shutdown_all_clients(
2314            clients,
2315            self.child_registry.clone(),
2316            LSP_SHUTDOWN_ALL_BUDGET,
2317        )
2318    }
2319
2320    fn shutdown_all_clients(
2321        clients: Vec<(ServerKey, LspClient)>,
2322        child_registry: LspChildRegistry,
2323        budget: Duration,
2324    ) -> LspShutdownAllOutcome {
2325        let started = Instant::now();
2326        let mut pending_pids = clients
2327            .iter()
2328            .map(|(_, client)| client.child_pid())
2329            .collect::<HashSet<_>>();
2330        let (result_tx, result_rx) = unbounded();
2331
2332        for (key, mut client) in clients {
2333            let pid = client.child_pid();
2334            let result_tx = result_tx.clone();
2335            std::thread::spawn(move || {
2336                let result = client.shutdown();
2337                // Do the drop before reporting completion so the registry cannot
2338                // briefly report a reaped client as still live after this method
2339                // has returned its shutdown summary.
2340                drop(client);
2341                let _ = result_tx.send((key, pid, result));
2342            });
2343        }
2344        drop(result_tx);
2345
2346        let deadline = started + budget;
2347        let mut outcome = LspShutdownAllOutcome::default();
2348        while !pending_pids.is_empty() {
2349            let remaining = deadline.saturating_duration_since(Instant::now());
2350            match result_rx.recv_timeout(remaining) {
2351                Ok((key, pid, result)) => {
2352                    if !pending_pids.remove(&pid) {
2353                        continue;
2354                    }
2355                    match result {
2356                        Ok(()) => outcome.graceful += 1,
2357                        Err(err) => {
2358                            outcome.forced += 1;
2359                            slog_error!("error shutting down {:?}: {}", key, err);
2360                        }
2361                    }
2362                }
2363                Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break,
2364            }
2365        }
2366
2367        if !pending_pids.is_empty() {
2368            let pids = pending_pids.into_iter().collect::<Vec<_>>();
2369            outcome.forced += pids.len();
2370            // The shared registry owns process-group termination, so timed-out
2371            // clients are reaped even though their graceful worker is still
2372            // blocked in its per-client Shutdown request.
2373            child_registry.reap_pids(&pids);
2374        }
2375
2376        outcome.elapsed = started.elapsed();
2377        slog_info!(
2378            "lsp shutdown_all: graceful={} forced={} elapsed_ms={}",
2379            outcome.graceful,
2380            outcome.forced,
2381            outcome.elapsed.as_millis()
2382        );
2383        outcome
2384    }
2385
2386    /// Shut down taken clients on a detached thread named `aft-lsp-idle-reap`.
2387    /// Idle reapers use this so a hung Shutdown handshake cannot stall the
2388    /// daemon loop or hold the manager mutex.
2389    pub(crate) fn spawn_idle_lsp_reap(clients: Vec<(ServerKey, LspClient)>) {
2390        if clients.is_empty() {
2391            return;
2392        }
2393        let spawn_result = std::thread::Builder::new()
2394            .name("aft-lsp-idle-reap".into())
2395            .spawn(move || {
2396                for (key, mut client) in clients {
2397                    if let Err(err) = client.shutdown_for_idle_reap() {
2398                        slog_error!("error shutting down {:?}: {}", key, err);
2399                    }
2400                }
2401            });
2402        if let Err(err) = spawn_result {
2403            slog_error!("failed to spawn idle LSP reap thread: {err}");
2404        }
2405    }
2406
2407    /// Check if any server is active.
2408    pub fn has_active_servers(&self) -> bool {
2409        self.clients
2410            .values()
2411            .any(|client| client.state() == ServerState::Ready)
2412    }
2413
2414    /// Active server keys (running clients). Used by `lsp_diagnostics`
2415    /// directory mode to know which servers to ask for workspace pull.
2416    pub fn active_server_keys(&self) -> Vec<ServerKey> {
2417        self.clients.keys().cloned().collect()
2418    }
2419
2420    /// Return the last watched-file routing decision for Windows CI timeout
2421    /// diagnostics. The trace records whether a matching client was absent,
2422    /// outside the changed path's root, unsupported, or successfully written.
2423    #[cfg(windows)]
2424    #[doc(hidden)]
2425    pub fn watched_file_notification_trace_for_test(&self) -> &str {
2426        &self.last_watched_file_notification_trace
2427    }
2428
2429    pub fn get_diagnostics_for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
2430        let normalized = normalize_lookup_path(file);
2431        self.diagnostics.for_file(&normalized)
2432    }
2433
2434    pub fn get_diagnostics_for_file_with_provisional(
2435        &self,
2436        file: &Path,
2437    ) -> Vec<(&StoredDiagnostic, bool)> {
2438        let normalized = normalize_lookup_path(file);
2439        self.diagnostics.for_file_with_provisional(&normalized)
2440    }
2441
2442    /// Drop all cached diagnostics for a file across every server. Called when a
2443    /// file is deleted/renamed away so its diagnostics don't linger in the warm
2444    /// set (no server republishes for a vanished path), inflating the
2445    /// error/warning counts in the status bar and `aft_inspect`.
2446    ///
2447    /// The store key is the canonical path from publish time, but a deleted file
2448    /// can no longer be canonicalized directly (`canonicalize` needs the file to
2449    /// exist). We therefore try several equivalent forms: the raw path, the
2450    /// canonicalize-or-fallback form, and — crucially — a reconstruction that
2451    /// canonicalizes the still-present parent directory and rejoins the file
2452    /// name, which reproduces the publish-time key even across `/var`↔
2453    /// `/private/var`-style symlink aliasing. Returns true if anything was
2454    /// removed.
2455    /// Forget all cached spawn FAILURES so the next file event retries them.
2456    /// Called on `configure`: a configure means something changed (the user may
2457    /// have just installed the missing language server, or fixed PATH / a
2458    /// version pin), so a previously-failed (kind, root) pair deserves a fresh
2459    /// attempt instead of being skipped until a full restart. Bounded: configure
2460    /// is not a per-request hot path, so this cannot cause a spawn storm.
2461    /// Returns the number of cleared entries.
2462    pub fn clear_failed_spawns(&mut self) -> usize {
2463        let n = self.failed_spawns.len();
2464        self.failed_spawns.clear();
2465        n
2466    }
2467
2468    #[cfg(test)]
2469    pub(crate) fn insert_failed_spawn_for_test(&mut self) {
2470        let key = ServerKey {
2471            kind: crate::lsp::registry::ServerKind::Rust,
2472            root: std::path::PathBuf::from("/tmp/test-root"),
2473        };
2474        self.failed_spawns.insert(
2475            key,
2476            ServerAttemptResult::SpawnFailed {
2477                binary: "rust-analyzer".to_string(),
2478                reason: "test".to_string(),
2479            },
2480        );
2481    }
2482
2483    pub fn clear_diagnostics_for_file(&mut self, file: &Path) -> bool {
2484        diagnostic_path_candidates(file)
2485            .into_iter()
2486            .fold(false, |removed, candidate| {
2487                removed | self.diagnostics.clear_for_file(&candidate)
2488            })
2489    }
2490
2491    /// Mark cached diagnostics for this file stale after a watcher-observed
2492    /// external edit. The same path aliases as deletion are checked so canonical
2493    /// publish keys are found even when the watcher reports a symlinked path.
2494    pub fn mark_diagnostics_stale_for_file(&mut self, file: &Path) -> StaleDiagnosticsMark {
2495        let mut result = StaleDiagnosticsMark::default();
2496        for candidate in diagnostic_path_candidates(file) {
2497            let (had_entries, changed) = self.diagnostics.mark_stale_for_file(&candidate);
2498            result.had_entries |= had_entries;
2499            result.changed |= changed;
2500        }
2501        result
2502    }
2503
2504    pub fn get_diagnostics_for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
2505        let normalized = normalize_lookup_path(dir);
2506        self.diagnostics.for_directory(&normalized)
2507    }
2508
2509    pub fn get_diagnostics_for_directory_with_provisional(
2510        &self,
2511        dir: &Path,
2512    ) -> Vec<(&StoredDiagnostic, bool)> {
2513        let normalized = normalize_lookup_path(dir);
2514        self.diagnostics.for_directory_with_provisional(&normalized)
2515    }
2516
2517    pub fn get_all_diagnostics(&self) -> Vec<&StoredDiagnostic> {
2518        self.diagnostics.all()
2519    }
2520
2521    pub fn get_all_diagnostics_with_provisional(&self) -> Vec<(&StoredDiagnostic, bool)> {
2522        self.diagnostics.all_with_provisional()
2523    }
2524
2525    /// True if any LSP server has a current diagnostic report, including an
2526    /// empty report that proves a checked-clean file. This lets callers avoid
2527    /// treating an empty flattened diagnostic list as trustworthy when no server
2528    /// has actually run or every report was marked stale after an external edit.
2529    pub fn has_any_diagnostic_reports(&self) -> bool {
2530        self.diagnostics.has_any_fresh_report()
2531    }
2532
2533    /// True if any server has a current report for this file, including an
2534    /// empty checked-clean report. Watcher-stale reports are excluded because
2535    /// they predate an external edit.
2536    pub fn has_diagnostic_report_for_file(&self, file: &Path) -> bool {
2537        let normalized = normalize_lookup_path(file);
2538        self.diagnostics.has_any_fresh_report_for_file(&normalized)
2539    }
2540
2541    /// True if this exact server/file pair has a current diagnostic report,
2542    /// including an empty checked-clean report. Watcher-stale reports are
2543    /// excluded because they predate an external edit.
2544    pub fn has_diagnostic_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
2545        let normalized = normalize_lookup_path(file);
2546        self.diagnostics
2547            .has_fresh_report_for_server_file(server, &normalized)
2548    }
2549
2550    /// True if any server has an authoritative report for this file: neither
2551    /// watcher-stale nor warming-provisional, including an empty checked-clean
2552    /// report. Needed for per-file diagnostic authority: a global "some server
2553    /// reported" bit cannot prove that a specific file was analyzed.
2554    pub fn has_authoritative_report_for_file(&self, file: &Path) -> bool {
2555        let normalized = normalize_lookup_path(file);
2556        self.diagnostics
2557            .has_authoritative_report_for_file(&normalized)
2558    }
2559
2560    /// True if this server instance holds any authoritative report: an entry
2561    /// that is neither watcher-stale nor warming-provisional, including an
2562    /// empty checked-clean report. Needed by the blocking quiescence wait, which
2563    /// settles per producer rather than per file.
2564    pub fn has_authoritative_report_for_server(&self, server: &ServerKey) -> bool {
2565        self.diagnostics.has_authoritative_report_for_server(server)
2566    }
2567
2568    /// True if this server instance is still warming: its reports stay
2569    /// provisional until it declares quiescence. A server the manager does not
2570    /// know (never started, or exited since) is not warming and cannot block a
2571    /// quiescence wait.
2572    pub fn server_is_warming(&self, server: &ServerKey) -> bool {
2573        self.clients
2574            .get(server)
2575            .is_some_and(|client| client.diagnostics_are_provisional())
2576    }
2577
2578    /// A producer has settled when it holds a current authoritative report or
2579    /// has stopped warming. The blocking inspect wait and the unscoped
2580    /// diagnostics freshness gate both use this predicate so a complete
2581    /// producer set cannot be judged incomplete by a second, stricter check.
2582    pub fn producer_has_settled(&self, server: &ServerKey) -> bool {
2583        self.has_authoritative_report_for_server(server) || !self.server_is_warming(server)
2584    }
2585
2586    /// True when every expected producer has settled. Empty input is vacuously
2587    /// true; callers that mean "no producer was started" must not treat that
2588    /// as a fresh diagnostics collection.
2589    pub fn producers_settled(&self, expected: &[ServerKey]) -> bool {
2590        expected
2591            .iter()
2592            .all(|server| self.producer_has_settled(server))
2593    }
2594
2595    fn drain_events_for_file(&mut self, file_path: &Path) -> bool {
2596        let mut saw_file_diagnostics = false;
2597        while let Ok(event) = self.event_rx.try_recv() {
2598            if matches!(
2599                self.handle_event(&event),
2600                Some(ref published_file) if published_file.as_path() == file_path
2601            ) {
2602                saw_file_diagnostics = true;
2603            }
2604        }
2605        saw_file_diagnostics
2606    }
2607
2608    fn accepted_live_publish_snapshot(
2609        &self,
2610        event: &LspEvent,
2611    ) -> Option<AcceptedDiagnosticSnapshot> {
2612        let LspEvent::Notification {
2613            server_kind,
2614            root,
2615            method,
2616            params: Some(params),
2617        } = event
2618        else {
2619            return None;
2620        };
2621        if method != "textDocument/publishDiagnostics" {
2622            return None;
2623        }
2624
2625        let publish_params =
2626            serde_json::from_value::<lsp_types::PublishDiagnosticsParams>(params.clone()).ok()?;
2627        let file = uri_to_path(&publish_params.uri)?;
2628        let server_key = ServerKey {
2629            kind: server_kind.clone(),
2630            root: root.clone(),
2631        };
2632        if self.live_publish_drop_reason(&server_key, &file).is_some() {
2633            return None;
2634        }
2635        let document_version = self.documents.get(&server_key)?.version(&file)?;
2636        let entry = self
2637            .diagnostics
2638            .entries_for_file(&file)
2639            .into_iter()
2640            .find_map(|(stored_key, entry)| (stored_key == &server_key).then_some(entry))?;
2641
2642        Some(AcceptedDiagnosticSnapshot::new(
2643            server_key,
2644            document_version,
2645            entry.diagnostics.clone(),
2646        ))
2647    }
2648
2649    fn live_publish_drop_reason(&self, key: &ServerKey, file: &Path) -> Option<&'static str> {
2650        let Some(client) = self.clients.get(key) else {
2651            return Some("server-not-live");
2652        };
2653        if client.state() != ServerState::Ready {
2654            return Some("server-not-ready");
2655        }
2656        if client.diagnostics_are_provisional() {
2657            return Some("producer-warming");
2658        }
2659        let Some(document_version) = self
2660            .documents
2661            .get(key)
2662            .and_then(|documents| documents.version(file))
2663        else {
2664            return Some("document-not-open");
2665        };
2666        let Some(entry) = self
2667            .diagnostics
2668            .entries_for_file(file)
2669            .into_iter()
2670            .find_map(|(stored_key, entry)| (stored_key == key).then_some(entry))
2671        else {
2672            return Some("report-not-stored");
2673        };
2674        if entry.stale {
2675            return Some("report-stale");
2676        }
2677        if entry.provisional {
2678            return Some("report-provisional");
2679        }
2680        match entry.version {
2681            None => Some("missing-version"),
2682            Some(version) if version != document_version => Some("version-mismatch"),
2683            Some(_) => None,
2684        }
2685    }
2686
2687    fn handle_event(&mut self, event: &LspEvent) -> Option<PathBuf> {
2688        let published_file = match event {
2689            LspEvent::Notification {
2690                server_kind,
2691                root,
2692                method,
2693                params: Some(params),
2694            } if method == "textDocument/publishDiagnostics" => {
2695                self.handle_publish_diagnostics(server_kind.clone(), root.clone(), params)
2696            }
2697            LspEvent::Notification {
2698                server_kind,
2699                root,
2700                method,
2701                params: Some(params),
2702            } if method == "experimental/serverStatus" => {
2703                self.handle_server_status(server_kind.clone(), root.clone(), params);
2704                None
2705            }
2706            LspEvent::ServerExited {
2707                server_kind,
2708                root,
2709                reason,
2710            } => {
2711                let key = ServerKey {
2712                    kind: server_kind.clone(),
2713                    root: root.clone(),
2714                };
2715                if let Some(mut client) = self.clients.remove(&key) {
2716                    match client.reap_after_reader_exit(reason) {
2717                        ReaderExitReap::AlreadyExited(status) => {
2718                            slog_info!(
2719                                "exited {:?} {}: exit status {status} ({reason})",
2720                                server_kind,
2721                                root.display()
2722                            );
2723                        }
2724                        ReaderExitReap::KilledWhileAlive => {
2725                            slog_info!(
2726                                "exited {:?} {}: reader ended while child alive: {reason}",
2727                                server_kind,
2728                                root.display()
2729                            );
2730                        }
2731                    }
2732                }
2733                self.server_binaries.remove(&key);
2734                self.documents.remove(&key);
2735                self.diagnostics.clear_for_server(&key);
2736                None
2737            }
2738            _ => None,
2739        };
2740        self.wake_post_edit_waiters();
2741        published_file
2742    }
2743
2744    fn wake_post_edit_waiters(&mut self) {
2745        Self::wake_waiters(&mut self.post_edit_waiters);
2746    }
2747
2748    fn wake_waiters(waiters: &mut HashMap<u64, Sender<()>>) {
2749        waiters.retain(|_, sender| match sender.try_send(()) {
2750            Ok(()) | Err(TrySendError::Full(())) => true,
2751            Err(TrySendError::Disconnected(())) => false,
2752        });
2753    }
2754
2755    fn handle_publish_diagnostics(
2756        &mut self,
2757        server: ServerKind,
2758        root: PathBuf,
2759        params: &serde_json::Value,
2760    ) -> Option<PathBuf> {
2761        let publish_params = match serde_json::from_value::<lsp_types::PublishDiagnosticsParams>(
2762            params.clone(),
2763        ) {
2764            Ok(params) => params,
2765            Err(err) => {
2766                slog_info!(
2767                    "lsp_protocol server={} root={} method=textDocument/publishDiagnostics event=dropped-because-invalid-params error={}",
2768                    server.id_str(),
2769                    root.display(),
2770                    err
2771                );
2772                return None;
2773            }
2774        };
2775        let Some(file) = uri_to_path(&publish_params.uri) else {
2776            slog_info!(
2777                "lsp_protocol server={} root={} method=textDocument/publishDiagnostics event=dropped-because-invalid-uri uri={:?}",
2778                server.id_str(),
2779                root.display(),
2780                publish_params.uri
2781            );
2782            return None;
2783        };
2784        let diagnostic_count = publish_params.diagnostics.len();
2785        let version = publish_params.version;
2786        slog_info!(
2787            "lsp_protocol server={} root={} method=textDocument/publishDiagnostics event=received file={} version={} diagnostics={}",
2788            server.id_str(),
2789            root.display(),
2790            file.display(),
2791            version
2792                .map(|version| version.to_string())
2793                .unwrap_or_else(|| "none".to_string()),
2794            diagnostic_count
2795        );
2796        let stored = from_lsp_diagnostics(file.clone(), publish_params.diagnostics);
2797        // Store with the real ServerKey and the published document version
2798        // so observation sources can accept only reports that prove which
2799        // in-memory document state they describe. The earlier
2800        // `publish_with_kind` path silently dropped both facts.
2801        let key = ServerKey { kind: server, root };
2802        let provisional = self
2803            .clients
2804            .get(&key)
2805            .is_some_and(|client| client.diagnostics_are_provisional());
2806        self.diagnostics.publish_full_with_provisional(
2807            key.clone(),
2808            file.clone(),
2809            stored,
2810            None,
2811            version,
2812            provisional,
2813        );
2814        if let Some(reason) = self.live_publish_drop_reason(&key, &file) {
2815            slog_info!(
2816                "lsp_protocol server={} root={} method=textDocument/publishDiagnostics event=dropped-because-{} file={} version={}",
2817                key.kind.id_str(),
2818                key.root.display(),
2819                reason,
2820                file.display(),
2821                version
2822                    .map(|version| version.to_string())
2823                    .unwrap_or_else(|| "none".to_string())
2824            );
2825        }
2826        Some(file)
2827    }
2828
2829    fn handle_server_status(
2830        &mut self,
2831        server: ServerKind,
2832        root: PathBuf,
2833        params: &serde_json::Value,
2834    ) {
2835        if !matches!(&server, ServerKind::Rust)
2836            || params.get("quiescent").and_then(serde_json::Value::as_bool) != Some(true)
2837        {
2838            return;
2839        }
2840
2841        let key = ServerKey { kind: server, root };
2842        let became_quiescent = self
2843            .clients
2844            .get_mut(&key)
2845            .is_some_and(|client| client.set_rust_analyzer_quiescent(true));
2846        if became_quiescent {
2847            self.diagnostics.promote_provisional_for_server(&key);
2848        }
2849    }
2850
2851    fn reap_unreferenced_children_for(&self, key: &ServerKey) {
2852        let live_pids = self
2853            .clients
2854            .values()
2855            .map(LspClient::child_pid)
2856            .collect::<HashSet<_>>();
2857        let orphans = self
2858            .child_registry
2859            .pids_for_server(&key.root, &key.kind)
2860            .into_iter()
2861            .filter(|pid| !live_pids.contains(pid))
2862            .collect::<Vec<_>>();
2863        if !orphans.is_empty() {
2864            self.child_registry.reap_pids(&orphans);
2865        }
2866    }
2867
2868    fn spawn_server(
2869        &self,
2870        def: &ServerDef,
2871        root: &Path,
2872        source_file: &Path,
2873        config: &Config,
2874    ) -> Result<LspClient, LspError> {
2875        self.spawn_server_with_timeout(def, root, source_file, config, None)
2876    }
2877
2878    fn spawn_server_with_timeout(
2879        &self,
2880        def: &ServerDef,
2881        root: &Path,
2882        source_file: &Path,
2883        config: &Config,
2884        initialize_timeout: Option<std::time::Duration>,
2885    ) -> Result<LspClient, LspError> {
2886        let initialization_options =
2887            initialization_options_for_spawn(def, source_file, root, config)?;
2888        let binary = self.resolve_binary(def, root, config)?;
2889
2890        // Merge the server-defined env with our test-injected env.
2891        // `extra_env` is empty in production; tests use it to drive fake
2892        // server variants (AFT_FAKE_LSP_PULL=1, etc.).
2893        let mut merged_env = def.env.clone();
2894        for (key, value) in &self.extra_env {
2895            merged_env.insert(key.clone(), value.clone());
2896        }
2897
2898        // A server may use a nested language workspace, but the reclaim marker
2899        // belongs to the configured session project. Only register that broader
2900        // root when it contains the server root; otherwise retain the server root
2901        // so an unrelated configuration path cannot reap this child.
2902        let reclaim_root = config
2903            .project_root
2904            .as_deref()
2905            .map(crate::inspect::job::canonicalize_normalized)
2906            .filter(|project_root| root.starts_with(project_root))
2907            .unwrap_or_else(|| root.to_path_buf());
2908
2909        let mut client = LspClient::spawn_with_reclaim_root(
2910            def.kind.clone(),
2911            root.to_path_buf(),
2912            &binary,
2913            &def.args,
2914            &merged_env,
2915            self.event_tx.clone(),
2916            self.child_registry.clone(),
2917            Some(&reclaim_root),
2918        )?;
2919        let initialize = match initialize_timeout {
2920            Some(timeout) => client.initialize_with_timeout(root, initialization_options, timeout),
2921            None => client.initialize(root, initialization_options),
2922        };
2923        if let Err(err) = initialize {
2924            wait_for_stderr_tail(&mut client);
2925            let stderr_tail = client.stderr_tail();
2926            let reason = if client.child_exited() || !stderr_tail.is_empty() {
2927                format_initialize_failure_reason(&def.binary, &stderr_tail, &err)
2928            } else {
2929                format!("server failed during initialize: {err}")
2930            };
2931            return Err(LspError::ServerNotReady(reason));
2932        }
2933        Ok(client)
2934    }
2935
2936    fn resolve_binary(
2937        &self,
2938        def: &ServerDef,
2939        root: &Path,
2940        config: &Config,
2941    ) -> Result<PathBuf, LspError> {
2942        if let Some(path) = self.binary_overrides.get(&def.kind) {
2943            if path.exists() {
2944                return Ok(path.clone());
2945            }
2946            return Err(LspError::NotFound(format!(
2947                "override binary for {:?} not found: {}",
2948                def.kind,
2949                path.display()
2950            )));
2951        }
2952
2953        if let Some(path) = env_binary_override(&def.kind) {
2954            if path.exists() {
2955                return Ok(path);
2956            }
2957            return Err(LspError::NotFound(format!(
2958                "environment override binary for {:?} not found: {}",
2959                def.kind,
2960                path.display()
2961            )));
2962        }
2963
2964        let mut pushed_config;
2965        let resolution_config = if let Some(paths) = self.pushed_search_paths.as_ref() {
2966            pushed_config = config.clone();
2967            pushed_config.lsp_paths_extra.clone_from(paths);
2968            &pushed_config
2969        } else {
2970            config
2971        };
2972        resolve_server_binary(def, Some(root), resolution_config).ok_or_else(|| {
2973            let searched = if matches!(def.kind, ServerKind::Python | ServerKind::Ty) {
2974                "the workspace virtualenv, node_modules/.bin, lsp_paths_extra, or PATH"
2975            } else {
2976                "node_modules/.bin, lsp_paths_extra, or PATH"
2977            };
2978            LspError::NotFound(format!(
2979                "language server binary '{}' not found in {searched}",
2980                def.binary,
2981            ))
2982        })
2983    }
2984
2985    fn server_key_for_file(&self, file_path: &Path, config: &Config) -> Option<ServerKey> {
2986        for def in servers_for_file(file_path, config) {
2987            let key = server_key_for_definition(&def, file_path, config)?;
2988            if self.clients.contains_key(&key) {
2989                return Some(key);
2990            }
2991        }
2992        None
2993    }
2994}
2995
2996impl Default for LspManager {
2997    fn default() -> Self {
2998        Self::new()
2999    }
3000}
3001
3002const ASTRO_TSDK_UNAVAILABLE: &str = "astro-ls requires a project TypeScript install; none found";
3003
3004fn initialization_options_for_spawn(
3005    def: &ServerDef,
3006    source_file: &Path,
3007    server_root: &Path,
3008    config: &Config,
3009) -> Result<Option<serde_json::Value>, LspError> {
3010    if def.kind != ServerKind::Astro {
3011        return Ok(def.initialization_options.clone());
3012    }
3013
3014    if def
3015        .initialization_options
3016        .as_ref()
3017        .and_then(|options| options.pointer("/typescript/tsdk"))
3018        .and_then(serde_json::Value::as_str)
3019        .is_some_and(|tsdk| !tsdk.is_empty())
3020    {
3021        return Ok(def.initialization_options.clone());
3022    }
3023
3024    // astro-ls does not discover TypeScript itself. Resolve the nearest project
3025    // installation for the triggering .astro file without walking above the
3026    // configured project, so monorepo members use their own TypeScript SDK.
3027    let project_root = config.project_root.as_deref().unwrap_or(server_root);
3028    let boundary = if source_file.starts_with(project_root) {
3029        project_root
3030    } else {
3031        server_root
3032    };
3033    let tsdk = find_project_typescript_sdk(source_file, boundary)
3034        .ok_or_else(|| LspError::ServerNotReady(ASTRO_TSDK_UNAVAILABLE.to_string()))?;
3035    let mut options = serde_json::json!({
3036        "typescript": {
3037            "tsdk": tsdk.to_string_lossy(),
3038        }
3039    });
3040    if let Some(configured) = def.initialization_options.clone() {
3041        merge_json_override(&mut options, configured);
3042    }
3043    Ok(Some(options))
3044}
3045
3046fn find_project_typescript_sdk(source_file: &Path, project_root: &Path) -> Option<PathBuf> {
3047    let mut directory = source_file.parent()?;
3048    loop {
3049        let lib = directory
3050            .join("node_modules")
3051            .join("typescript")
3052            .join("lib");
3053        if lib.join("tsserverlibrary.js").is_file() || lib.join("typescript.js").is_file() {
3054            return Some(lib);
3055        }
3056        if directory == project_root {
3057            return None;
3058        }
3059        let parent = directory.parent()?;
3060        if !parent.starts_with(project_root) {
3061            return None;
3062        }
3063        directory = parent;
3064    }
3065}
3066
3067fn merge_json_override(base: &mut serde_json::Value, override_value: serde_json::Value) {
3068    match (base, override_value) {
3069        (serde_json::Value::Object(base), serde_json::Value::Object(override_fields)) => {
3070            for (key, value) in override_fields {
3071                if let Some(existing) = base.get_mut(&key) {
3072                    merge_json_override(existing, value);
3073                } else {
3074                    base.insert(key, value);
3075                }
3076            }
3077        }
3078        (base, value) => *base = value,
3079    }
3080}
3081
3082fn wait_for_stderr_tail(client: &mut LspClient) {
3083    for _ in 0..10 {
3084        if !client.stderr_tail().is_empty() {
3085            break;
3086        }
3087        std::thread::sleep(std::time::Duration::from_millis(10));
3088    }
3089}
3090
3091fn recoverable_pull_rejection(err: &LspError) -> bool {
3092    matches!(
3093        err,
3094        LspError::ServerError {
3095            code: -32601 | -32602,
3096            ..
3097        }
3098    )
3099}
3100
3101fn server_attempt_result_reason(result: &ServerAttemptResult) -> String {
3102    match result {
3103        ServerAttemptResult::SpawnFailed { binary, reason } => {
3104            format!("spawn_failed: {binary} ({reason})")
3105        }
3106        ServerAttemptResult::BinaryNotInstalled { binary } => {
3107            format!("binary_not_installed: {binary}")
3108        }
3109        ServerAttemptResult::NoRootMarker { looked_for } => {
3110            format!("no_root_marker (looked for: {})", looked_for.join(", "))
3111        }
3112        ServerAttemptResult::Ok { .. } => "ok".to_string(),
3113    }
3114}
3115
3116fn format_stderr_tail_for_reason(stderr_tail: &str) -> String {
3117    truncate_stderr_tail_for_reason(stderr_tail)
3118        .lines()
3119        .map(|line| format!("  {line}"))
3120        .collect::<Vec<_>>()
3121        .join("\n")
3122}
3123
3124fn truncate_stderr_tail_for_reason(stderr_tail: &str) -> String {
3125    if stderr_tail.len() <= STDERR_REASON_BYTES {
3126        return stderr_tail.to_string();
3127    }
3128
3129    let ellipsis = "...";
3130    let target_len = STDERR_REASON_BYTES.saturating_sub(ellipsis.len());
3131    let mut start = stderr_tail.len() - target_len;
3132    while start < stderr_tail.len() && !stderr_tail.is_char_boundary(start) {
3133        start += 1;
3134    }
3135    format!("{ellipsis}{}", &stderr_tail[start..])
3136}
3137
3138fn format_initialize_failure_reason(binary: &str, stderr_tail: &str, err: &LspError) -> String {
3139    let mut reason = format!("server crashed during initialize: {err}");
3140    if !stderr_tail.is_empty() {
3141        reason.push_str("; stderr (last 64 lines):\n");
3142        reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
3143        reason.push_str("\n\n");
3144        reason.push_str(&failure_hint(binary, stderr_tail));
3145    }
3146    reason
3147}
3148
3149fn format_post_initialize_exit_reason(
3150    binary: &str,
3151    status: std::process::ExitStatus,
3152    stderr_tail: &str,
3153    err: &LspError,
3154) -> String {
3155    let code = status
3156        .code()
3157        .map(|c| c.to_string())
3158        .unwrap_or_else(|| "signal/unknown".to_string());
3159    let mut reason = format!("server exited after initialize (code {code}): {err}");
3160    if !stderr_tail.is_empty() {
3161        reason.push_str("; stderr (last 64 lines):\n");
3162        reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
3163        reason.push_str("\n\n");
3164        reason.push_str(&failure_hint(binary, stderr_tail));
3165    }
3166    reason
3167}
3168
3169fn failure_hint(binary: &str, stderr_tail: &str) -> String {
3170    if stderr_tail.contains("MODULE_NOT_FOUND") || stderr_tail.contains("Cannot find module") {
3171        let package_manager = infer_package_manager(stderr_tail);
3172        format!(
3173            "Your package-manager shim resolves to a missing file. Try reinstalling: {package_manager} install -g {binary} --force. Common cause: hard-link breakage from fs migration or store prune."
3174        )
3175    } else if let Some(component) = rustup_missing_component(stderr_tail) {
3176        // The binary on PATH is rustup's proxy shim, but the toolchain
3177        // component isn't installed, so rustup rejects the dispatch with
3178        // "Unknown binary '<name>' in ... toolchain". The actionable fix is to
3179        // add the component, not anything about the binary itself.
3180        format!("'{component}' is a rustup proxy but the component is not installed. Install it: rustup component add {component}")
3181    } else {
3182        format!("Hint: see stderr above for '{binary}' failure details.")
3183    }
3184}
3185
3186/// Detect the rustup "proxy shim without installed component" failure and
3187/// return the component name to add. rustup prints
3188/// `error: Unknown binary '<name>' in official toolchain '<triple>'` when a
3189/// `~/.cargo/bin/<name>` proxy is on PATH but the component was never installed
3190/// (the canonical case is `rust-analyzer`, which ships as an opt-in component).
3191fn rustup_missing_component(stderr_tail: &str) -> Option<String> {
3192    let marker = "Unknown binary '";
3193    let start = stderr_tail.find(marker)? + marker.len();
3194    let rest = &stderr_tail[start..];
3195    let end = rest.find('\'')?;
3196    let name = &rest[..end];
3197    // Only treat it as a rustup-component issue when the toolchain phrasing is
3198    // present, so an unrelated "Unknown binary" message doesn't mislead.
3199    if name.is_empty() || !stderr_tail.contains("toolchain") {
3200        return None;
3201    }
3202    Some(name.to_string())
3203}
3204
3205fn infer_package_manager(stderr_tail: &str) -> &'static str {
3206    let lower = stderr_tail.to_ascii_lowercase();
3207    if lower.contains(".pnpm/") || lower.contains(".pnpm\\") || lower.contains("/pnpm/") {
3208        "pnpm"
3209    } else if lower.contains(".yarn/")
3210        || lower.contains(".yarn\\")
3211        || lower.contains("/yarn/")
3212        || lower.contains("yarn")
3213    {
3214        "yarn"
3215    } else {
3216        "npm"
3217    }
3218}
3219
3220fn canonicalize_for_lsp(file_path: &Path) -> Result<PathBuf, LspError> {
3221    // The whole LSP subsystem must agree on ONE canonical form. Workspace
3222    // roots are normalized (verbatim prefix stripped on Windows) because
3223    // CreateProcess rejects verbatim cwds; document and watched-file paths
3224    // are compared against those roots with starts_with, so a bare
3225    // fs::canonicalize here would produce verbatim paths on Windows that
3226    // never match any client root.
3227    std::fs::canonicalize(file_path)
3228        .map(|canonical| crate::inspect::job::normalize_path(&canonical))
3229        .map_err(LspError::from)
3230}
3231
3232fn resolve_for_lsp_uri(file_path: &Path) -> PathBuf {
3233    // Same normalized form as canonicalize_for_lsp and the client roots;
3234    // see the comment there.
3235    if let Ok(path) = std::fs::canonicalize(file_path) {
3236        return crate::inspect::job::normalize_path(&path);
3237    }
3238
3239    let mut existing = file_path.to_path_buf();
3240    let mut missing = Vec::new();
3241    while !existing.exists() {
3242        let Some(name) = existing.file_name() else {
3243            break;
3244        };
3245        missing.push(name.to_owned());
3246        let Some(parent) = existing.parent() else {
3247            break;
3248        };
3249        existing = parent.to_path_buf();
3250    }
3251
3252    let mut resolved = std::fs::canonicalize(&existing)
3253        .map(|canonical| crate::inspect::job::normalize_path(&canonical))
3254        .unwrap_or(existing);
3255    for segment in missing.into_iter().rev() {
3256        resolved.push(segment);
3257    }
3258    resolved
3259}
3260
3261fn language_id_for_extension(ext: &str) -> &'static str {
3262    match ext {
3263        "ts" => "typescript",
3264        "tsx" => "typescriptreact",
3265        "js" | "mjs" | "cjs" => "javascript",
3266        "jsx" => "javascriptreact",
3267        "py" | "pyi" => "python",
3268        "rs" => "rust",
3269        "go" => "go",
3270        "html" | "htm" => "html",
3271        "md" | "markdown" | "mdx" | "mkd" | "mkdn" | "mdown" | "mdwn" | "qmd" | "rmd" => "markdown",
3272        _ => "plaintext",
3273    }
3274}
3275
3276fn log_did_open_sent(key: &ServerKey, file: &Path, language_id: &str) {
3277    slog_info!(
3278        "lsp_protocol server={} root={} method=textDocument/didOpen event=sent file={} language_id={} version=0",
3279        key.kind.id_str(),
3280        key.root.display(),
3281        file.display(),
3282        language_id
3283    );
3284}
3285
3286fn normalize_lookup_path(path: &Path) -> PathBuf {
3287    // Normalized like every other LSP-subsystem path (see canonicalize_for_lsp):
3288    // store keys and lookups must share one canonical form or Windows verbatim
3289    // spellings silently miss.
3290    std::fs::canonicalize(path)
3291        .map(|canonical| crate::inspect::job::normalize_path(&canonical))
3292        .unwrap_or_else(|_| path.to_path_buf())
3293}
3294
3295fn diagnostic_path_candidates(file: &Path) -> Vec<PathBuf> {
3296    let mut candidates = Vec::with_capacity(4);
3297    let mut add = |candidate: PathBuf| {
3298        if !candidates.iter().any(|existing| existing == &candidate) {
3299            candidates.push(candidate);
3300        }
3301    };
3302
3303    // Existing files normalize through the same URI path used when publishing
3304    // diagnostics. A deleted file cannot be canonicalized, so retain the
3305    // watcher spelling as a candidate too.
3306    add(file.to_path_buf());
3307    add(normalize_lookup_path(file));
3308
3309    // The parent survives a file deletion. Rebuild both forms used by the
3310    // diagnostics store: raw filesystem canonicalization (legacy/direct
3311    // publishers) and its non-verbatim normalized spelling (LSP URI events).
3312    if let (Some(parent), Some(name)) = (file.parent(), file.file_name()) {
3313        if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
3314            let reconstructed = canonical_parent.join(name);
3315            add(reconstructed.clone());
3316            add(crate::inspect::job::normalize_path(&reconstructed));
3317        }
3318    }
3319
3320    candidates
3321}
3322
3323/// Classify an error returned by `spawn_server` into a structured
3324/// `ServerAttemptResult`. The two interesting cases for callers are:
3325/// - `BinaryNotInstalled` — the server's binary couldn't be resolved on PATH
3326///   or via override. The agent can be told "install bash-language-server".
3327/// - `SpawnFailed` — binary was found but spawning/initializing failed
3328///   (permissions, missing runtime, server crashed during initialize, etc.).
3329fn classify_spawn_error(binary: &str, err: &LspError) -> ServerAttemptResult {
3330    match err {
3331        // resolve_binary returns NotFound for both missing override paths and
3332        // missing PATH binaries. The "override missing" case is rare in
3333        // practice (only set in tests / env vars); we report all NotFound as
3334        // BinaryNotInstalled so the user sees an actionable install hint.
3335        LspError::NotFound(_) => ServerAttemptResult::BinaryNotInstalled {
3336            binary: binary.to_string(),
3337        },
3338        other => ServerAttemptResult::SpawnFailed {
3339            binary: binary.to_string(),
3340            reason: other.to_string(),
3341        },
3342    }
3343}
3344
3345fn env_binary_override(kind: &ServerKind) -> Option<PathBuf> {
3346    env_binary_override_from(kind, |key| std::env::var_os(key))
3347}
3348
3349fn env_binary_override_from(
3350    kind: &ServerKind,
3351    lookup: impl FnOnce(&str) -> Option<std::ffi::OsString>,
3352) -> Option<PathBuf> {
3353    let id = kind.id_str();
3354    let suffix: String = id
3355        .chars()
3356        .map(|ch| {
3357            if ch.is_ascii_alphanumeric() {
3358                ch.to_ascii_uppercase()
3359            } else {
3360                '_'
3361            }
3362        })
3363        .collect();
3364    let key = format!("AFT_LSP_{suffix}_BINARY");
3365    lookup(&key)
3366        .filter(|value| !value.is_empty())
3367        .map(PathBuf::from)
3368}
3369
3370#[cfg(test)]
3371mod env_binary_override_tests {
3372    use super::*;
3373
3374    #[test]
3375    fn empty_lsp_binary_override_is_unset_without_mutating_the_process_environment() {
3376        let kind = ServerKind::TypeScript;
3377        assert_eq!(
3378            env_binary_override_from(&kind, |key| {
3379                assert_eq!(key, "AFT_LSP_TYPESCRIPT_BINARY");
3380                Some(std::ffi::OsString::new())
3381            }),
3382            None
3383        );
3384        assert_eq!(
3385            env_binary_override_from(&kind, |_| Some(std::ffi::OsString::from("/bin/lsp"))),
3386            Some(PathBuf::from("/bin/lsp"))
3387        );
3388    }
3389}
3390
3391#[cfg(test)]
3392mod language_id_tests {
3393    use super::language_id_for_extension;
3394
3395    #[test]
3396    fn markdown_extensions_use_markdown_language_id() {
3397        for extension in [
3398            "md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd",
3399        ] {
3400            assert_eq!(language_id_for_extension(extension), "markdown");
3401        }
3402    }
3403}
3404
3405#[cfg(all(test, windows))]
3406mod windows_server_key_tests {
3407    use std::fs;
3408    use std::os::windows::ffi::OsStrExt;
3409
3410    use super::{canonicalize_for_lsp, server_key_for_definition};
3411    use crate::config::{Config, UserServerDef};
3412    use crate::lsp::registry::servers_for_file;
3413
3414    #[test]
3415    fn normalized_and_verbatim_inputs_produce_identical_server_key_material() {
3416        let temp_dir = tempfile::tempdir().expect("tempdir");
3417        let root = temp_dir.path().join("workspace");
3418        let source = root.join("src").join("main.customts");
3419        fs::create_dir_all(source.parent().expect("source parent")).expect("create source dir");
3420        fs::write(root.join("custom-root.json"), "{}\n").expect("write root marker");
3421        fs::write(&source, "export const value = 1;\n").expect("write source");
3422
3423        let config = Config {
3424            project_root: Some(root),
3425            lsp_servers: vec![UserServerDef {
3426                id: "custom-ts".to_string(),
3427                extensions: vec!["customts".to_string()],
3428                binary: "custom-ts-lsp".to_string(),
3429                args: Vec::new(),
3430                root_markers: vec!["custom-root.json".to_string()],
3431                env: Default::default(),
3432                initialization_options: None,
3433                disabled: false,
3434            }],
3435            ..Config::default()
3436        };
3437
3438        let normalized_input = canonicalize_for_lsp(&source).expect("normalized source path");
3439        let bare_canonical_input = fs::canonicalize(&source).expect("canonical source path");
3440        let key_for = |path: &std::path::Path| {
3441            let def = servers_for_file(path, &config)
3442                .into_iter()
3443                .find(|def| def.kind.id_str() == "custom-ts")
3444                .expect("custom server definition");
3445            server_key_for_definition(&def, path, &config).expect("custom server root")
3446        };
3447
3448        let key_material = |key: &crate::lsp::roots::ServerKey| {
3449            let root_bytes = key
3450                .root
3451                .as_os_str()
3452                .encode_wide()
3453                .flat_map(u16::to_le_bytes)
3454                .collect::<Vec<_>>();
3455            (key.kind.id_str().to_string(), root_bytes)
3456        };
3457        let ensure_key = key_for(&normalized_input);
3458        let running_lookup_key = key_for(&bare_canonical_input);
3459
3460        assert_eq!(key_material(&ensure_key), key_material(&running_lookup_key));
3461    }
3462}
3463
3464#[cfg(test)]
3465mod failure_hint_tests {
3466    use super::{failure_hint, rustup_missing_component};
3467
3468    #[test]
3469    fn detects_rustup_proxy_without_component() {
3470        // The exact rustup stderr for a proxy shim whose component is missing.
3471        let stderr = "error: Unknown binary 'rust-analyzer' in official toolchain 'stable-aarch64-apple-darwin'.";
3472        assert_eq!(
3473            rustup_missing_component(stderr).as_deref(),
3474            Some("rust-analyzer")
3475        );
3476        let hint = failure_hint("rust-analyzer", stderr);
3477        assert!(
3478            hint.contains("rustup component add rust-analyzer"),
3479            "expected actionable rustup hint, got: {hint}"
3480        );
3481    }
3482
3483    #[test]
3484    fn ignores_unknown_binary_without_toolchain_phrasing() {
3485        // "Unknown binary" without the rustup toolchain phrasing must not be
3486        // misattributed to a rustup component issue.
3487        let stderr = "fatal: Unknown binary 'foo' was requested by the linker.";
3488        assert_eq!(rustup_missing_component(stderr), None);
3489        assert!(failure_hint("foo", stderr).starts_with("Hint: see stderr"));
3490    }
3491
3492    #[test]
3493    fn npm_module_not_found_still_wins() {
3494        // The existing package-manager-shim case is unaffected.
3495        let stderr = "Error: Cannot find module '/x/typescript-language-server/lib/cli.mjs'";
3496        let hint = failure_hint("typescript-language-server", stderr);
3497        assert!(hint.contains("install -g"), "got: {hint}");
3498    }
3499}
3500
3501#[cfg(test)]
3502mod diagnostic_capacity_tests {
3503    use std::fs;
3504
3505    use super::LspManager;
3506    use crate::config::Config;
3507
3508    // The lsp.diagnostic_cache_size config knob must actually take effect:
3509    // set_diagnostic_capacity (called at AppContext construction with the config
3510    // value) propagates the cap to the underlying DiagnosticsStore. Before this
3511    // wiring the field was parsed but never applied (always the hardcoded 5000).
3512    #[test]
3513    fn set_diagnostic_capacity_propagates_to_store() {
3514        let mut manager = LspManager::new();
3515        manager.set_diagnostic_capacity(7);
3516        assert_eq!(manager.diagnostics_store_for_test().capacity_for_test(), 7);
3517        manager.set_diagnostic_capacity(0); // 0 = unbounded
3518        assert_eq!(manager.diagnostics_store_for_test().capacity_for_test(), 0);
3519    }
3520
3521    // configure clears cached spawn failures so a just-installed server retries
3522    // without a full restart.
3523    #[test]
3524    fn clear_failed_spawns_empties_the_cache() {
3525        let mut manager = LspManager::new();
3526        assert_eq!(manager.clear_failed_spawns(), 0);
3527        manager.insert_failed_spawn_for_test();
3528        assert_eq!(manager.clear_failed_spawns(), 1);
3529        assert_eq!(manager.clear_failed_spawns(), 0);
3530    }
3531
3532    #[test]
3533    fn pushed_search_paths_make_new_binary_visible_to_stale_config() {
3534        let root = tempfile::tempdir().unwrap();
3535        let bin_dir = tempfile::tempdir().unwrap();
3536        let binary_name = "aft-test-pushed-lsp";
3537        let binary = bin_dir.path().join(binary_name);
3538        fs::write(&binary, "#!/bin/sh\nexit 0\n").unwrap();
3539        #[cfg(unix)]
3540        {
3541            use std::os::unix::fs::PermissionsExt;
3542            fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap();
3543        }
3544
3545        let config = Config {
3546            project_root: Some(root.path().to_path_buf()),
3547            lsp_servers: vec![crate::config::UserServerDef {
3548                id: "pushed-search-path-test".to_string(),
3549                extensions: vec!["pushedpath".to_string()],
3550                binary: binary_name.to_string(),
3551                args: Vec::new(),
3552                root_markers: Vec::new(),
3553                env: Default::default(),
3554                initialization_options: None,
3555                disabled: false,
3556            }],
3557            ..Config::default()
3558        };
3559        let file = root.path().join("sample.pushedpath");
3560        fs::write(&file, "test\n").unwrap();
3561        let definition = crate::lsp::registry::servers_for_file(&file, &config)
3562            .into_iter()
3563            .find(|definition| definition.kind.id_str() == "pushed-search-path-test")
3564            .unwrap();
3565        let mut manager = LspManager::new();
3566
3567        assert!(manager
3568            .resolve_binary(&definition, root.path(), &config)
3569            .is_err());
3570        assert!(manager.set_search_paths(vec![bin_dir.path().to_path_buf()]));
3571        assert_eq!(
3572            manager
3573                .resolve_binary(&definition, root.path(), &config)
3574                .unwrap(),
3575            binary
3576        );
3577    }
3578
3579    #[test]
3580    fn post_write_notification_does_not_start_a_cold_server() {
3581        let dir = tempfile::tempdir().unwrap();
3582        let file = dir.path().join("main.ts");
3583        fs::write(dir.path().join("package.json"), "{}").unwrap();
3584        fs::write(&file, "export const value = 1;\n").unwrap();
3585
3586        let mut manager = LspManager::new();
3587        manager
3588            .notify_file_changed_if_running(&file, "export const value = 1;\n", &Config::default())
3589            .unwrap();
3590        assert!(manager.clients.is_empty());
3591    }
3592}
3593
3594#[cfg(test)]
3595mod post_edit_waiter_tests {
3596    use std::collections::HashMap;
3597    use std::path::PathBuf;
3598    use std::time::{Duration, Instant};
3599
3600    use super::LspManager;
3601    use crate::lsp::client::LspEvent;
3602    use crate::lsp::registry::ServerKind;
3603
3604    #[test]
3605    fn draining_an_event_wakes_registered_post_edit_waiter() {
3606        let mut manager = LspManager::new();
3607        let mut wait = manager.start_post_edit_diagnostics_wait(
3608            PathBuf::from("/workspace/src/main.rs").as_path(),
3609            &[],
3610            &HashMap::new(),
3611            Duration::from_secs(2),
3612        );
3613        manager.enqueue_event_for_test(LspEvent::Notification {
3614            server_kind: ServerKind::Rust,
3615            root: PathBuf::from("/workspace"),
3616            method: "custom/drainedElsewhere".to_string(),
3617            params: None,
3618        });
3619
3620        assert_eq!(manager.drain_events().events.len(), 1);
3621        let started = Instant::now();
3622        assert!(wait.next_event().is_none());
3623        assert!(
3624            started.elapsed() < Duration::from_millis(250),
3625            "a competing drain did not wake the parked post-edit waiter"
3626        );
3627        let _ = manager.poll_post_edit_diagnostics_wait(&mut wait, None);
3628        let _ = manager.finish_post_edit_diagnostics_wait(wait);
3629    }
3630}
3631
3632#[cfg(test)]
3633mod clear_diagnostics_tests {
3634    use std::path::PathBuf;
3635
3636    use super::LspManager;
3637    use crate::lsp::client::LspEvent;
3638    use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
3639    use crate::lsp::position::uri_for_path;
3640    use crate::lsp::registry::ServerKind;
3641    use crate::lsp::roots::ServerKey;
3642
3643    fn err_diag(file: &PathBuf) -> StoredDiagnostic {
3644        StoredDiagnostic {
3645            file: file.clone(),
3646            line: 1,
3647            column: 1,
3648            end_line: 1,
3649            end_column: 2,
3650            severity: DiagnosticSeverity::Error,
3651            message: "boom".into(),
3652            code: None,
3653            source: None,
3654        }
3655    }
3656
3657    // A just-deleted file can no longer be canonicalized directly, but its
3658    // store key was the canonical path from publish time. The manager must
3659    // reconstruct that key via the still-present parent dir so symlink-aliased
3660    // roots (macOS /var -> /private/var) still match and the diagnostic clears.
3661    #[test]
3662    fn clear_diagnostics_for_deleted_file_matches_canonical_key() {
3663        let dir = tempfile::tempdir().unwrap();
3664        // Canonicalize the parent the way publish time would have.
3665        let canonical_dir = std::fs::canonicalize(dir.path()).unwrap();
3666        let canonical_file = canonical_dir.join("gone.ts");
3667        // Write then remove the file so its parent exists but the file does not,
3668        // mirroring the post-delete state the watcher observes.
3669        std::fs::write(&canonical_file, "x").unwrap();
3670
3671        let mut manager = LspManager::new();
3672        let key = ServerKey {
3673            kind: ServerKind::TypeScript,
3674            root: canonical_dir.clone(),
3675        };
3676        manager.diagnostics_store_mut_for_test().publish(
3677            key,
3678            canonical_file.clone(),
3679            vec![err_diag(&canonical_file)],
3680        );
3681        assert_eq!(manager.warm_error_warning_counts(), (1, 0));
3682
3683        std::fs::remove_file(&canonical_file).unwrap();
3684
3685        // Clear by the NON-canonical path the watcher might hand us (the raw
3686        // tempdir path, which on macOS differs from the canonical /private form).
3687        let watcher_path = dir.path().join("gone.ts");
3688        let removed = manager.clear_diagnostics_for_file(&watcher_path);
3689
3690        assert!(removed, "expected the deleted file's diagnostic to clear");
3691        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
3692    }
3693
3694    #[cfg(windows)]
3695    #[test]
3696    fn clear_diagnostics_for_deleted_file_matches_normalized_publish_key() {
3697        let dir = tempfile::tempdir().unwrap();
3698        let file = dir.path().join("normalized-gone.ts");
3699        std::fs::write(&file, "x").unwrap();
3700        let normalized_file = crate::inspect::job::canonicalize_normalized(&file);
3701
3702        let mut manager = LspManager::new();
3703        let key = ServerKey {
3704            kind: ServerKind::TypeScript,
3705            root: normalized_file.parent().unwrap().to_path_buf(),
3706        };
3707        manager.diagnostics_store_mut_for_test().publish(
3708            key,
3709            normalized_file.clone(),
3710            vec![err_diag(&normalized_file)],
3711        );
3712        std::fs::remove_file(&file).unwrap();
3713
3714        assert!(manager.clear_diagnostics_for_file(&file));
3715        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
3716    }
3717
3718    #[cfg(windows)]
3719    #[test]
3720    fn stale_diagnostics_for_deleted_file_matches_normalized_publish_key() {
3721        let dir = tempfile::tempdir().unwrap();
3722        let file = dir.path().join("normalized-stale.ts");
3723        std::fs::write(&file, "x").unwrap();
3724        let normalized_file = crate::inspect::job::canonicalize_normalized(&file);
3725
3726        let mut manager = LspManager::new();
3727        let key = ServerKey {
3728            kind: ServerKind::TypeScript,
3729            root: normalized_file.parent().unwrap().to_path_buf(),
3730        };
3731        manager.diagnostics_store_mut_for_test().publish(
3732            key,
3733            normalized_file.clone(),
3734            vec![err_diag(&normalized_file)],
3735        );
3736        std::fs::remove_file(&file).unwrap();
3737
3738        let result = manager.mark_diagnostics_stale_for_file(&file);
3739        assert!(result.had_entries);
3740        assert!(result.changed);
3741        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
3742    }
3743
3744    #[test]
3745    fn clear_diagnostics_for_unknown_file_is_noop() {
3746        let mut manager = LspManager::new();
3747        assert!(!manager.clear_diagnostics_for_file(&PathBuf::from("/nope/missing.ts")));
3748        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
3749    }
3750
3751    #[test]
3752    fn drain_events_reports_publish_diagnostics_updates() {
3753        let dir = tempfile::tempdir().unwrap();
3754        let root = std::fs::canonicalize(dir.path()).unwrap();
3755        let file = root.join("main.ts");
3756        std::fs::write(&file, "const x: number = 'nope';").unwrap();
3757
3758        let mut manager = LspManager::new();
3759        let diagnostic = lsp_types::Diagnostic {
3760            range: lsp_types::Range {
3761                start: lsp_types::Position {
3762                    line: 0,
3763                    character: 0,
3764                },
3765                end: lsp_types::Position {
3766                    line: 0,
3767                    character: 1,
3768                },
3769            },
3770            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3771            code: None,
3772            code_description: None,
3773            source: Some("test".into()),
3774            message: "boom".into(),
3775            related_information: None,
3776            tags: None,
3777            data: None,
3778        };
3779        let params = serde_json::to_value(lsp_types::PublishDiagnosticsParams {
3780            uri: uri_for_path(&file).unwrap(),
3781            diagnostics: vec![diagnostic],
3782            version: Some(1),
3783        })
3784        .unwrap();
3785        manager
3786            .event_tx
3787            .send(LspEvent::Notification {
3788                server_kind: ServerKind::TypeScript,
3789                root,
3790                method: "textDocument/publishDiagnostics".into(),
3791                params: Some(params),
3792            })
3793            .unwrap();
3794
3795        let drained = manager.drain_events();
3796
3797        assert!(drained.diagnostics_changed);
3798        assert_eq!(drained.events.len(), 1);
3799        assert_eq!(manager.warm_error_warning_counts(), (1, 0));
3800    }
3801}
3802
3803#[cfg(test)]
3804mod inspect_path_tests {
3805    use super::LspManager;
3806    use crate::config::{Config, UserServerDef};
3807    use crate::lsp::registry::ServerKind;
3808
3809    #[test]
3810    fn applicability_resolution_does_not_start_or_open_a_server() {
3811        let temp_dir = tempfile::tempdir().expect("tempdir");
3812        let root = temp_dir.path().join("project");
3813        std::fs::create_dir_all(&root).expect("project root");
3814        std::fs::write(root.join("inspect-root.json"), "{}\n").expect("root marker");
3815        std::fs::write(root.join("input.inspectlang"), "value\n").expect("source file");
3816
3817        let config = Config {
3818            project_root: Some(root.clone()),
3819            lsp_servers: vec![UserServerDef {
3820                id: "inspect-test".to_string(),
3821                extensions: vec!["inspectlang".to_string()],
3822                binary: "inspect-test-lsp".to_string(),
3823                args: Vec::new(),
3824                root_markers: vec!["inspect-root.json".to_string()],
3825                env: Default::default(),
3826                initialization_options: None,
3827                disabled: false,
3828            }],
3829            ..Config::default()
3830        };
3831        let mut manager = LspManager::new();
3832        manager.override_binary(
3833            ServerKind::Custom("inspect-test".into()),
3834            std::env::current_exe().expect("current executable"),
3835        );
3836
3837        let snapshot = manager
3838            .resolve_applicable_servers_for_root(&root, &config)
3839            .expect("resolution succeeds without spawning");
3840        assert_eq!(snapshot.server_keys.len(), 1);
3841        assert_eq!(snapshot.server_keys[0].kind.id_str(), "inspect-test");
3842        assert_eq!(manager.server_count(), 0);
3843        assert!(!manager.document_is_open_for_test(&root.join("input.inspectlang")));
3844    }
3845
3846    #[test]
3847    fn applicability_resolution_preserves_an_empty_snapshot() {
3848        let temp_dir = tempfile::tempdir().expect("tempdir");
3849        let root = temp_dir.path().join("project");
3850        std::fs::create_dir_all(&root).expect("project root");
3851        std::fs::write(root.join("notes.txt"), "plain text\n").expect("fixture file");
3852
3853        let snapshot = LspManager::new()
3854            .resolve_applicable_servers_for_root(&root, &Config::default())
3855            .expect("an empty applicability set is valid");
3856
3857        assert!(snapshot.server_keys.is_empty());
3858        assert!(snapshot.candidates.is_empty());
3859    }
3860}
3861
3862// Every test in this module spawns a real child process, so the module is Unix-only.
3863#[cfg(all(test, unix))]
3864mod server_exit_reap_tests {
3865    use std::collections::HashMap;
3866    use std::path::Path;
3867    use std::process::{Child, Command};
3868    use std::thread;
3869    use std::time::{Duration, Instant};
3870
3871    use super::LspManager;
3872    use crate::config::Config;
3873    use crate::lsp::child_registry::LspChildRegistry;
3874    use crate::lsp::client::{LspClient, LspEvent, ServerExitReason};
3875    use crate::lsp::registry::ServerKind;
3876
3877    #[cfg(unix)]
3878    fn spawn_session_sleep() -> Child {
3879        use std::os::unix::process::CommandExt;
3880
3881        let mut command = Command::new("sh");
3882        command.args(["-c", "exec sleep 60"]);
3883        unsafe {
3884            command.pre_exec(|| {
3885                if libc::setsid() == -1 {
3886                    return Err(std::io::Error::last_os_error());
3887                }
3888                Ok(())
3889            });
3890        }
3891        command.spawn().expect("spawn session-leader sleep")
3892    }
3893
3894    #[cfg(unix)]
3895    fn wait_until_dead(pid: u32, timeout: Duration) -> bool {
3896        let started = Instant::now();
3897        while started.elapsed() < timeout {
3898            if !crate::bash_background::process::is_process_alive(pid) {
3899                return true;
3900            }
3901            thread::sleep(Duration::from_millis(20));
3902        }
3903        false
3904    }
3905
3906    #[cfg(unix)]
3907    fn spawn_malformed_then_sleep_client(
3908        event_tx: crossbeam_channel::Sender<LspEvent>,
3909        registry: LspChildRegistry,
3910        root: std::path::PathBuf,
3911    ) -> LspClient {
3912        LspClient::spawn(
3913            ServerKind::TypeScript,
3914            root,
3915            Path::new("sh"),
3916            &[
3917                "-c".to_string(),
3918                "printf 'Content-Length: 3\r\n\r\n{{{'; exec sleep 60".to_string(),
3919            ],
3920            &HashMap::new(),
3921            event_tx,
3922            registry,
3923        )
3924        .expect("spawn malformed-then-sleep stand-in")
3925    }
3926
3927    #[cfg(unix)]
3928    #[test]
3929    fn server_exited_handler_kills_live_child_and_untracks() {
3930        let registry = LspChildRegistry::new();
3931        let mut manager = LspManager::new();
3932        manager.set_child_registry(registry.clone());
3933        let tmp = tempfile::tempdir().unwrap();
3934        let root = tmp.path().to_path_buf();
3935        let mut client = spawn_malformed_then_sleep_client(
3936            manager.event_sender_for_test(),
3937            registry.clone(),
3938            root.clone(),
3939        );
3940        let pid = client.child_pid();
3941        client.suppress_kill_on_drop_for_test();
3942        manager.insert_client_for_test(client);
3943
3944        let deadline = Instant::now() + Duration::from_secs(5);
3945        let mut saw_exit = false;
3946        while Instant::now() < deadline {
3947            let drained = manager.drain_events();
3948            if drained.events.iter().any(|event| {
3949                matches!(
3950                    event,
3951                    LspEvent::ServerExited {
3952                        reason: ServerExitReason::ReadError(_),
3953                        ..
3954                    }
3955                )
3956            }) {
3957                saw_exit = true;
3958                break;
3959            }
3960            thread::sleep(Duration::from_millis(20));
3961        }
3962        assert!(saw_exit, "reader must emit ServerExited with ReadError");
3963        assert!(
3964            wait_until_dead(pid, Duration::from_secs(5)),
3965            "ServerExited handler must kill the still-running child"
3966        );
3967        assert!(
3968            !registry.pids().contains(&pid),
3969            "ServerExited handler must untrack the child"
3970        );
3971        assert_eq!(manager.active_client_count(), 0);
3972    }
3973
3974    #[cfg(unix)]
3975    #[test]
3976    fn lifecycle_census_reports_dropped_client_until_reaper_cleans_child() {
3977        let registry = LspChildRegistry::new();
3978        let (events, _event_rx) = crossbeam_channel::unbounded();
3979        let root = tempfile::tempdir().expect("tempdir");
3980        let mut client =
3981            spawn_malformed_then_sleep_client(events, registry.clone(), root.path().to_path_buf());
3982        client.suppress_kill_on_drop_for_test();
3983        drop(client);
3984
3985        let leaked = registry.health_snapshot();
3986        assert_eq!(leaked.children_without_client, 1);
3987        assert_eq!(leaked.children_total, 1);
3988        assert_eq!(registry.reap_children_without_client(), 1);
3989        assert_eq!(registry.health_snapshot().children_without_client, 0);
3990        assert_eq!(registry.health_snapshot().children_total, 0);
3991    }
3992
3993    #[cfg(unix)]
3994    #[test]
3995    fn ensure_server_reaps_unreferenced_children_before_spawn() {
3996        let tmp = tempfile::tempdir().unwrap();
3997        let root = tmp.path().join("workspace");
3998        let src = root.join("src");
3999        std::fs::create_dir_all(&src).unwrap();
4000        std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
4001        let main_rs = src.join("main.rs");
4002        std::fs::write(&main_rs, "fn main() {}\n").unwrap();
4003        let config = Config::default();
4004        let rust_def = crate::lsp::registry::servers_for_file(&main_rs, &config)
4005            .into_iter()
4006            .find(|def| matches!(def.kind, ServerKind::Rust))
4007            .expect("rust server applies to main.rs");
4008        let key = super::server_key_for_definition(&rust_def, &main_rs, &config)
4009            .expect("rust workspace root");
4010
4011        let registry = LspChildRegistry::new();
4012        let mut first = spawn_session_sleep();
4013        let mut second = spawn_session_sleep();
4014        let pid1 = first.id();
4015        let pid2 = second.id();
4016        registry.track_child(pid1, Some(&key.root), Some(&key.root), Some(&key.kind));
4017        registry.track_child(pid2, Some(&key.root), Some(&key.root), Some(&key.kind));
4018
4019        let mut manager = LspManager::new();
4020        manager.set_child_registry(registry.clone());
4021        manager.override_binary(ServerKind::Rust, Path::new("false").to_path_buf());
4022        assert_eq!(
4023            registry.pids_for_server(&key.root, &key.kind).len(),
4024            2,
4025            "both orphans must be registered under the spawn key"
4026        );
4027        let _ = manager.ensure_server_for_file(&main_rs, &config);
4028
4029        let deadline = Instant::now() + Duration::from_secs(5);
4030        loop {
4031            let first_exited = first.try_wait().ok().flatten().is_some();
4032            let second_exited = second.try_wait().ok().flatten().is_some();
4033            if first_exited && second_exited {
4034                break;
4035            }
4036            assert!(
4037                Instant::now() < deadline,
4038                "orphans must exit after rebind reap (first alive={}, second alive={})",
4039                crate::bash_background::process::is_process_alive(pid1),
4040                crate::bash_background::process::is_process_alive(pid2)
4041            );
4042            thread::sleep(Duration::from_millis(20));
4043        }
4044        assert!(
4045            !registry.pids().contains(&pid1) && !registry.pids().contains(&pid2),
4046            "reaped orphans must be untracked before the new spawn is tracked"
4047        );
4048    }
4049}