Skip to main content

aft/lsp/
client.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::{self, BufRead, BufReader, BufWriter};
3use std::path::{Path, PathBuf};
4#[cfg(windows)]
5use std::process::Command;
6use std::process::{Child, Stdio};
7use std::str::FromStr;
8use std::sync::atomic::{AtomicI64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use crossbeam_channel::{bounded, RecvTimeoutError, Sender};
14use serde::de::DeserializeOwned;
15use serde_json::{json, Value};
16
17use crate::lsp::child_registry::LspChildRegistry;
18use crate::lsp::jsonrpc::{
19    Notification, Request, RequestId, Response as JsonRpcResponse, ServerMessage,
20};
21use crate::lsp::position::path_to_uri;
22use crate::lsp::registry::ServerKind;
23use crate::lsp::{transport, LspError};
24
25/// Default timeout for interactive LSP requests (hover, goto-def, references, rename).
26const INTERACTIVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
27/// Longer budget for one-shot handshake requests (initialize, shutdown).
28const HANDSHAKE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
29const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
30const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
31const STDERR_TAIL_LINES: usize = 64;
32const STDERR_LINE_BYTES: usize = 4 * 1024;
33
34type PendingMap = HashMap<RequestId, Sender<JsonRpcResponse>>;
35type WatchedFileRegistrations = Arc<Mutex<HashSet<String>>>;
36
37/// Lifecycle state of a language server.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ServerState {
40    Starting,
41    Initializing,
42    Ready,
43    ShuttingDown,
44    Exited,
45}
46
47/// Events sent from background reader threads into the main loop.
48#[derive(Debug)]
49pub enum LspEvent {
50    /// Server sent a notification (e.g. publishDiagnostics).
51    Notification {
52        server_kind: ServerKind,
53        root: PathBuf,
54        method: String,
55        params: Option<Value>,
56    },
57    /// Server sent a request (e.g. workspace/configuration).
58    ServerRequest {
59        server_kind: ServerKind,
60        root: PathBuf,
61        id: RequestId,
62        method: String,
63        params: Option<Value>,
64    },
65    /// Server process exited or the transport stream closed.
66    ServerExited {
67        server_kind: ServerKind,
68        root: PathBuf,
69        reason: ServerExitReason,
70    },
71}
72
73/// Why the background reader stopped.
74///
75/// A framing or I/O error on a still-running server used to be collapsed into
76/// the same `ServerExited` event as a real EOF, so the manager dropped the
77/// client without knowing whether the child was actually gone.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ServerExitReason {
80    /// `read_message` returned `Ok(None)`: the stdout stream closed cleanly.
81    Eof,
82    /// `read_message` returned an I/O or framing error. The child may still be
83    /// alive; the payload is the `Display` of that error so the next leak can
84    /// be diagnosed from the log line.
85    ReadError(String),
86    /// The pending-response mutex was poisoned. The reader cannot continue.
87    PendingLockPoisoned,
88}
89
90impl std::fmt::Display for ServerExitReason {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::Eof => write!(f, "eof"),
94            Self::ReadError(err) => write!(f, "read error: {err}"),
95            Self::PendingLockPoisoned => write!(f, "pending lock poisoned"),
96        }
97    }
98}
99
100impl ServerExitReason {
101    /// Map a terminal `read_message` result onto the reason the reader stopped.
102    pub(crate) fn from_read_result(
103        result: io::Result<Option<crate::lsp::jsonrpc::ServerMessage>>,
104    ) -> Self {
105        match result {
106            Ok(None) => Self::Eof,
107            Err(err) => Self::ReadError(err.to_string()),
108            Ok(Some(_)) => {
109                debug_assert!(false, "from_read_result called on a live message");
110                Self::ReadError("unexpected live message".to_string())
111            }
112        }
113    }
114}
115
116/// Outcome of reaping a client after the reader thread stopped.
117#[derive(Debug)]
118pub(crate) enum ReaderExitReap {
119    AlreadyExited(std::process::ExitStatus),
120    KilledWhileAlive,
121}
122
123/// What this server told us it can do during the LSP `initialize` handshake.
124///
125/// We capture this once and use it to route diagnostic requests:
126/// - `pull_diagnostics` → use `textDocument/diagnostic` instead of waiting for push
127/// - `workspace_diagnostics` → use `workspace/diagnostic` for directory mode
128///
129/// Defaults are conservative: `false` means "fall back to push semantics".
130#[derive(Debug, Clone, Default)]
131pub struct ServerDiagnosticCapabilities {
132    /// Server supports `textDocument/diagnostic` (LSP 3.17 per-file pull).
133    pub pull_diagnostics: bool,
134    /// Server supports `workspace/diagnostic` (LSP 3.17 workspace-wide pull).
135    pub workspace_diagnostics: bool,
136    /// `identifier` field from server's diagnosticProvider, if any.
137    /// Used to scope previousResultId tracking when multiple servers share a file.
138    pub identifier: Option<String>,
139    /// Whether the server requested workspace diagnostic refresh notifications.
140    /// We declare `refreshSupport: false` in our client capabilities so this
141    /// should always be false in practice — kept for completeness.
142    pub refresh_support: bool,
143}
144
145/// A client connected to one language server process.
146pub struct LspClient {
147    kind: ServerKind,
148    root: PathBuf,
149    state: ServerState,
150    child: Child,
151    /// Child PID captured at spawn time. Used by Drop to untrack the
152    /// PID from the shared registry; we capture once rather than reading
153    /// `child.id()` later because Drop ordering with the Child can race.
154    child_pid: u32,
155    writer: Arc<Mutex<BufWriter<std::process::ChildStdin>>>,
156
157    /// Pending request responses, keyed by request ID.
158    pending: Arc<Mutex<PendingMap>>,
159    /// Next request ID counter.
160    next_id: AtomicI64,
161    /// Diagnostic capabilities reported by the server in its initialize response.
162    /// `None` until `initialize()` succeeds; conservative defaults thereafter
163    /// when the server doesn't advertise diagnosticProvider.
164    diagnostic_caps: Option<ServerDiagnosticCapabilities>,
165    /// Rust-analyzer's workspace analysis has reached quiescence. Other server
166    /// kinds do not use the experimental server-status signal and start
167    /// authoritative by default.
168    rust_analyzer_quiescent: bool,
169    /// Whether the server advertised static `workspace.didChangeWatchedFiles`
170    /// support during `initialize`. Dynamic registration is tracked separately
171    /// in `watched_file_registrations`; either path permits notifications.
172    /// Intentional default: `false` (conservative — requires server opt-in).
173    supports_watched_files: bool,
174    /// Dynamic `workspace/didChangeWatchedFiles` registrations requested by
175    /// the server via `client/registerCapability`. Per LSP, the client must
176    /// not send watched-file notifications merely because a server mentions
177    /// dynamic registration during initialize; a real registration is required.
178    watched_file_registrations: WatchedFileRegistrations,
179    /// Shared registry that tracks live LSP child PIDs across the process
180    /// so the signal handler can SIGKILL them on SIGTERM/SIGINT before
181    /// aft exits. Cloned via `Arc` — multiple clients share the same set.
182    child_registry: LspChildRegistry,
183    stderr_tail: Arc<Mutex<VecDeque<String>>>,
184    /// When true, `Drop` untracks but does not kill. Tests use this so a
185    /// `ServerExited` handler's kill is the only thing that can reap the child.
186    #[cfg(test)]
187    suppress_kill_on_drop: bool,
188}
189
190impl LspClient {
191    /// Spawn a new language server process and start the background reader thread.
192    ///
193    /// `child_registry` is a shared handle that records this child's PID so
194    /// the signal handler can SIGKILL it on SIGTERM/SIGINT. Tests that don't
195    /// care about signal cleanup can pass `LspChildRegistry::new()`.
196    pub fn spawn(
197        kind: ServerKind,
198        root: PathBuf,
199        binary: &Path,
200        args: &[String],
201        env: &HashMap<String, String>,
202        event_tx: Sender<LspEvent>,
203        child_registry: LspChildRegistry,
204    ) -> io::Result<Self> {
205        Self::spawn_with_reclaim_root(
206            kind,
207            root,
208            binary,
209            args,
210            env,
211            event_tx,
212            child_registry,
213            None,
214        )
215    }
216
217    /// Spawn a language server and associate it with a reclaim-marker root.
218    pub(crate) fn spawn_with_reclaim_root(
219        kind: ServerKind,
220        root: PathBuf,
221        binary: &Path,
222        args: &[String],
223        env: &HashMap<String, String>,
224        event_tx: Sender<LspEvent>,
225        child_registry: LspChildRegistry,
226        reclaim_root: Option<&Path>,
227    ) -> io::Result<Self> {
228        #[cfg(windows)]
229        let is_batch_file = crate::windows_command::is_batch_file(binary);
230        #[cfg(windows)]
231        let mut command = if is_batch_file {
232            crate::windows_command::batch_command(binary, args.iter())?
233        } else {
234            Command::new(binary)
235        };
236        #[cfg(not(windows))]
237        let mut command = crate::effective_path::new_command(binary);
238        #[cfg(windows)]
239        if !is_batch_file {
240            command.args(args);
241        }
242        #[cfg(not(windows))]
243        command.args(args);
244        command
245            .current_dir(&root)
246            .stdin(Stdio::piped())
247            .stdout(Stdio::piped())
248            // Drain stderr on a background thread so failed shims/crashes have
249            // actionable diagnostics without risking pipe-buffer deadlock.
250            .stderr(Stdio::piped());
251        for (key, value) in env {
252            #[cfg(windows)]
253            if is_batch_file && crate::windows_command::is_batch_internal_env(key, args.len()) {
254                crate::slog_warn!(
255                    "ignoring reserved batch-shim environment variable {key} for LSP server"
256                );
257                continue;
258            }
259            command.env(key, value);
260        }
261
262        // Put each LSP child in its own process group so we can SIGKILL the
263        // whole group on shutdown. Critical for npm-wrapped servers like
264        // biome (`node biome lsp-proxy` spawns `cli-darwin-arm64 biome
265        // lsp-proxy` as a child); killing just the wrapper PID leaves the
266        // real server orphaned to PID 1.
267        #[cfg(unix)]
268        unsafe {
269            use std::os::unix::process::CommandExt;
270            command.pre_exec(|| {
271                #[cfg(target_os = "linux")]
272                {
273                    // If aft is killed with SIGKILL, Rust cleanup and our
274                    // signal-handler thread never run. Ask the kernel to kill
275                    // the LSP process group as soon as the parent dies. This is
276                    // best-effort Linux coverage for the otherwise unhandleable
277                    // parent-death path.
278                    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
279                        return Err(io::Error::last_os_error());
280                    }
281                    if libc::getppid() == 1 {
282                        return Err(io::Error::other("parent died before LSP spawn completed"));
283                    }
284                }
285                if libc::setsid() == -1 {
286                    return Err(io::Error::last_os_error());
287                }
288                Ok(())
289            });
290        }
291
292        let mut child = child_registry.spawn_tracked_child(
293            &mut command,
294            reclaim_root,
295            Some(&root),
296            Some(&kind),
297        )?;
298        let child_pid = child.id();
299
300        let stdout = child
301            .stdout
302            .take()
303            .ok_or_else(|| io::Error::other("language server missing stdout pipe"))?;
304        let stdin = child
305            .stdin
306            .take()
307            .ok_or_else(|| io::Error::other("language server missing stdin pipe"))?;
308        let stderr = child
309            .stderr
310            .take()
311            .ok_or_else(|| io::Error::other("language server missing stderr pipe"))?;
312        let stderr_tail = Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
313        spawn_stderr_drain_thread(stderr, Arc::clone(&stderr_tail));
314
315        let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
316        let pending = Arc::new(Mutex::new(PendingMap::new()));
317        let watched_file_registrations = Arc::new(Mutex::new(HashSet::new()));
318        let reader_pending = Arc::clone(&pending);
319        let reader_writer = Arc::clone(&writer);
320        let reader_watched_file_registrations = Arc::clone(&watched_file_registrations);
321        let reader_kind = kind.clone();
322        let reader_root = root.clone();
323
324        thread::spawn(move || {
325            let mut reader = BufReader::new(stdout);
326            loop {
327                match transport::read_message(&mut reader) {
328                    Ok(Some(ServerMessage::Response(response))) => {
329                        if let Ok(mut guard) = reader_pending.lock() {
330                            if let Some(tx) = guard.remove(&response.id) {
331                                if tx.send(response).is_err() {
332                                    log::debug!("response channel closed");
333                                }
334                            }
335                        } else {
336                            let _ = event_tx.send(LspEvent::ServerExited {
337                                server_kind: reader_kind.clone(),
338                                root: reader_root.clone(),
339                                reason: ServerExitReason::PendingLockPoisoned,
340                            });
341                            break;
342                        }
343                    }
344                    Ok(Some(ServerMessage::Notification { method, params })) => {
345                        let _ = event_tx.send(LspEvent::Notification {
346                            server_kind: reader_kind.clone(),
347                            root: reader_root.clone(),
348                            method,
349                            params,
350                        });
351                    }
352                    Ok(Some(ServerMessage::Request { id, method, params })) => {
353                        record_watched_file_registration(
354                            &reader_watched_file_registrations,
355                            &method,
356                            params.as_ref(),
357                        );
358                        // Auto-respond to server requests to prevent deadlocks.
359                        // Server requests (like client/registerCapability,
360                        // window/workDoneProgress/create) block the server until
361                        // we respond. If we don't respond, the server won't send
362                        // responses to OUR pending requests → deadlock.
363                        //
364                        // Dispatch by method to return correct types:
365                        // - workspace/configuration expects Vec<Value> (one per item)
366                        // - Everything else gets null (safe default for registration/progress)
367                        let response_value = if method == "workspace/configuration" {
368                            workspace_configuration_response(
369                                &reader_kind,
370                                &reader_root,
371                                params.as_ref(),
372                            )
373                        } else {
374                            serde_json::Value::Null
375                        };
376                        if let Ok(mut w) = reader_writer.lock() {
377                            let response = super::jsonrpc::OutgoingResponse::success(
378                                id.clone(),
379                                response_value,
380                            );
381                            let _ = transport::write_response(&mut *w, &response);
382                        }
383                        // Also forward as event for any interested handlers
384                        let _ = event_tx.send(LspEvent::ServerRequest {
385                            server_kind: reader_kind.clone(),
386                            root: reader_root.clone(),
387                            id,
388                            method,
389                            params,
390                        });
391                    }
392                    terminal @ (Ok(None) | Err(_)) => {
393                        if let Ok(mut guard) = reader_pending.lock() {
394                            guard.clear();
395                        }
396                        let _ = event_tx.send(LspEvent::ServerExited {
397                            server_kind: reader_kind.clone(),
398                            root: reader_root.clone(),
399                            reason: ServerExitReason::from_read_result(terminal),
400                        });
401                        break;
402                    }
403                }
404            }
405        });
406
407        let rust_analyzer_quiescent = !matches!(&kind, ServerKind::Rust);
408        child_registry.mark_client_live(child_pid);
409        Ok(Self {
410            kind,
411            root,
412            state: ServerState::Starting,
413            child,
414            child_pid,
415            writer,
416            pending,
417            next_id: AtomicI64::new(1),
418            diagnostic_caps: None,
419            rust_analyzer_quiescent,
420            supports_watched_files: false,
421            watched_file_registrations,
422            child_registry,
423            stderr_tail,
424            #[cfg(test)]
425            suppress_kill_on_drop: false,
426        })
427    }
428
429    /// Send the initialize request and wait for response. Transition to Ready.
430    pub fn initialize(
431        &mut self,
432        workspace_root: &Path,
433        initialization_options: Option<serde_json::Value>,
434    ) -> Result<lsp_types::InitializeResult, LspError> {
435        self.initialize_with_timeout(
436            workspace_root,
437            initialization_options,
438            HANDSHAKE_REQUEST_TIMEOUT,
439        )
440    }
441
442    /// Initialize within a caller-owned deadline rather than extending it with
443    /// the normal standalone handshake budget.
444    pub(crate) fn initialize_with_timeout(
445        &mut self,
446        workspace_root: &Path,
447        initialization_options: Option<serde_json::Value>,
448        timeout: Duration,
449    ) -> Result<lsp_types::InitializeResult, LspError> {
450        self.ensure_can_send()?;
451        self.state = ServerState::Initializing;
452
453        let root_url = path_to_uri(workspace_root)?;
454        let root_uri = lsp_types::Uri::from_str(root_url.as_str()).map_err(|_| {
455            LspError::NotFound(format!(
456                "failed to convert workspace root '{}' to file URI",
457                workspace_root.display()
458            ))
459        })?;
460
461        let mut params_value = json!({
462            "processId": std::process::id(),
463            "rootUri": root_uri,
464            "capabilities": {
465                "experimental": {
466                    "serverStatusNotification": true
467                },
468                "workspace": {
469                    "workspaceFolders": true,
470                    "configuration": true,
471                    "didChangeWatchedFiles": {
472                        "dynamicRegistration": true
473                    },
474                    // LSP 3.17 workspace diagnostic pull. We declare refreshSupport=false
475                    // because we drive diagnostics on-demand via pull/push and re-query
476                    // when the agent calls lsp_diagnostics again — we don't need the
477                    // server to proactively push refresh notifications.
478                    "diagnostic": {
479                        "refreshSupport": false
480                    }
481                },
482                "textDocument": {
483                    "synchronization": {
484                        "dynamicRegistration": false,
485                        "didSave": true,
486                        "willSave": false,
487                        "willSaveWaitUntil": false
488                    },
489                    "publishDiagnostics": {
490                        "relatedInformation": true,
491                        "versionSupport": true,
492                        "codeDescriptionSupport": true,
493                        "dataSupport": true
494                    },
495                    // LSP 3.17 textDocument diagnostic pull. dynamicRegistration=false
496                    // because we use static capability discovery from the InitializeResult.
497                    // relatedDocumentSupport=true to receive cascading diagnostics for
498                    // files that became known while analyzing the requested one.
499                    "diagnostic": {
500                        "dynamicRegistration": false,
501                        "relatedDocumentSupport": true
502                    }
503                }
504            },
505            "clientInfo": {
506                "name": "aft",
507                "version": env!("CARGO_PKG_VERSION")
508            },
509            "workspaceFolders": [
510                {
511                    "uri": root_uri,
512                    "name": workspace_root
513                        .file_name()
514                        .and_then(|name| name.to_str())
515                        .unwrap_or("workspace")
516                }
517            ]
518        });
519        if let Some(initialization_options) = initialization_options {
520            params_value["initializationOptions"] = initialization_options;
521        }
522
523        let params = serde_json::from_value::<lsp_types::InitializeParams>(params_value)?;
524
525        let result_value = self.send_request_value_with_timeout(
526            <lsp_types::request::Initialize as lsp_types::request::Request>::METHOD,
527            params,
528            timeout.min(HANDSHAKE_REQUEST_TIMEOUT),
529        )?;
530        let result: lsp_types::InitializeResult = serde_json::from_value(result_value.clone())?;
531
532        // Capture diagnostic capabilities from the initialize response. We parse
533        // from a re-serialized JSON Value because the lsp-types crate's
534        // diagnostic_provider strict variants reject some shapes real servers
535        // emit (e.g. bare `true`), and we want defensive Default fallback.
536        let caps_value = result_value
537            .get("capabilities")
538            .cloned()
539            .unwrap_or_else(|| serde_json::to_value(&result.capabilities).unwrap_or(Value::Null));
540        self.diagnostic_caps = Some(parse_diagnostic_capabilities(&caps_value));
541
542        // Capture initialize-time (static) workspace/didChangeWatchedFiles
543        // support. Runtime client/registerCapability subscriptions are recorded
544        // separately by the reader thread. Missing capability is unsupported by
545        // default; callers must not send notifications unless one of those two
546        // server opt-in paths is present.
547        self.supports_watched_files = caps_value
548            .pointer("/workspace/didChangeWatchedFiles/dynamicRegistration")
549            .and_then(|v| v.as_bool())
550            .unwrap_or(false)
551            || caps_value
552                .pointer("/workspace/didChangeWatchedFiles")
553                .map(|v| v.is_object() || v.as_bool() == Some(true))
554                .unwrap_or(false);
555
556        self.send_notification::<lsp_types::notification::Initialized>(serde_json::from_value(
557            json!({}),
558        )?)?;
559        self.state = ServerState::Ready;
560        Ok(result)
561    }
562
563    /// Diagnostic capabilities advertised by the server. Returns `None` until
564    /// `initialize()` has succeeded; returns `Some` with conservative defaults
565    /// (all `false`) when the server didn't advertise diagnosticProvider.
566    pub fn diagnostic_capabilities(&self) -> Option<&ServerDiagnosticCapabilities> {
567        self.diagnostic_caps.as_ref()
568    }
569
570    /// Whether diagnostics from this server instance should be treated as
571    /// provisional because rust-analyzer has not reached quiescence.
572    pub fn diagnostics_are_provisional(&self) -> bool {
573        matches!(&self.kind, ServerKind::Rust) && !self.rust_analyzer_quiescent
574    }
575
576    /// Record a rust-analyzer server-status transition. Returns true only for
577    /// the first transition to quiescent, which is the completion boundary that
578    /// makes each latest warming report authoritative.
579    pub fn set_rust_analyzer_quiescent(&mut self, quiescent: bool) -> bool {
580        if !matches!(&self.kind, ServerKind::Rust) || !quiescent || self.rust_analyzer_quiescent {
581            return false;
582        }
583        self.rust_analyzer_quiescent = true;
584        true
585    }
586
587    /// Whether the server advertised initialize-time
588    /// `workspace/didChangeWatchedFiles` support. Dynamic registrations are
589    /// reported by `has_watched_file_registration()`.
590    pub fn supports_watched_files(&self) -> bool {
591        self.supports_watched_files
592    }
593
594    /// Whether this server currently has an active dynamic watched-file
595    /// registration. This, not the initialize-time capability shape, controls
596    /// whether `workspace/didChangeWatchedFiles` may be sent.
597    pub fn has_watched_file_registration(&self) -> bool {
598        self.watched_file_registrations
599            .lock()
600            .map(|registrations| !registrations.is_empty())
601            .unwrap_or(false)
602    }
603
604    /// Send a request and wait for the response.
605    pub fn send_request<R>(&mut self, params: R::Params) -> Result<R::Result, LspError>
606    where
607        R: lsp_types::request::Request,
608        R::Params: serde::Serialize,
609        R::Result: DeserializeOwned,
610    {
611        self.ensure_can_send()?;
612
613        let value = self.send_request_value(R::METHOD, params)?;
614        serde_json::from_value(value).map_err(Into::into)
615    }
616
617    /// Send a request and wait up to `timeout` for the response. If the local
618    /// deadline expires, remove the pending response handler and notify the
619    /// server with `$/cancelRequest` so it can stop work.
620    pub fn send_request_with_timeout<R>(
621        &mut self,
622        params: R::Params,
623        timeout: Duration,
624    ) -> Result<R::Result, LspError>
625    where
626        R: lsp_types::request::Request,
627        R::Params: serde::Serialize,
628        R::Result: DeserializeOwned,
629    {
630        self.ensure_can_send()?;
631
632        let value = self.send_request_value_with_timeout(R::METHOD, params, timeout)?;
633        serde_json::from_value(value).map_err(Into::into)
634    }
635
636    fn send_request_value<P>(&mut self, method: &'static str, params: P) -> Result<Value, LspError>
637    where
638        P: serde::Serialize,
639    {
640        self.send_request_value_with_timeout(method, params, INTERACTIVE_REQUEST_TIMEOUT)
641    }
642
643    fn send_request_value_with_timeout<P>(
644        &mut self,
645        method: &'static str,
646        params: P,
647        timeout: Duration,
648    ) -> Result<Value, LspError>
649    where
650        P: serde::Serialize,
651    {
652        self.ensure_can_send()?;
653
654        let id = RequestId::Int(self.next_id.fetch_add(1, Ordering::Relaxed));
655        let (tx, rx) = bounded(1);
656        {
657            let mut pending = self.lock_pending()?;
658            pending.insert(id.clone(), tx);
659        }
660
661        let request = Request::new(id.clone(), method, Some(serde_json::to_value(params)?));
662        {
663            let mut writer = self
664                .writer
665                .lock()
666                .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
667            if let Err(err) = transport::write_request(&mut *writer, &request) {
668                self.remove_pending(&id);
669                return Err(err.into());
670            }
671        }
672
673        let response = match rx.recv_timeout(timeout) {
674            Ok(response) => response,
675            Err(RecvTimeoutError::Timeout) => {
676                self.remove_pending(&id);
677                self.send_cancel_request(&id)?;
678                return Err(LspError::Timeout(format!(
679                    "timed out waiting for '{}' response from {:?}",
680                    method, self.kind
681                )));
682            }
683            Err(RecvTimeoutError::Disconnected) => {
684                self.remove_pending(&id);
685                return Err(LspError::ServerNotReady(format!(
686                    "language server {:?} disconnected while waiting for '{}'",
687                    self.kind, method
688                )));
689            }
690        };
691
692        if let Some(error) = response.error {
693            return Err(LspError::ServerError {
694                code: error.code,
695                message: error.message,
696            });
697        }
698
699        Ok(response.result.unwrap_or(Value::Null))
700    }
701
702    /// Send a notification (fire-and-forget).
703    pub fn send_notification<N>(&mut self, params: N::Params) -> Result<(), LspError>
704    where
705        N: lsp_types::notification::Notification,
706        N::Params: serde::Serialize,
707    {
708        self.ensure_can_send()?;
709        let notification = Notification::new(N::METHOD, Some(serde_json::to_value(params)?));
710        let mut writer = self
711            .writer
712            .lock()
713            .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
714        transport::write_notification(&mut *writer, &notification)?;
715        Ok(())
716    }
717
718    /// Graceful shutdown: send shutdown request, then exit notification.
719    pub fn shutdown(&mut self) -> Result<(), LspError> {
720        self.shutdown_with_request_timeout(HANDSHAKE_REQUEST_TIMEOUT)
721    }
722
723    /// Idle reclaim must not sit on the initialize-length Shutdown handshake.
724    /// A short request timeout falls through to the kill-if-still-running error
725    /// path so the detached reap thread finishes within `SHUTDOWN_TIMEOUT`.
726    pub(crate) fn shutdown_for_idle_reap(&mut self) -> Result<(), LspError> {
727        self.shutdown_with_request_timeout(EXIT_POLL_INTERVAL)
728    }
729
730    fn shutdown_with_request_timeout(&mut self, request_timeout: Duration) -> Result<(), LspError> {
731        if self.state == ServerState::Exited {
732            self.child_registry.untrack(self.child_pid);
733            return Ok(());
734        }
735
736        if self.child.try_wait()?.is_some() {
737            self.state = ServerState::Exited;
738            self.child_registry.untrack(self.child_pid);
739            return Ok(());
740        }
741
742        if let Err(err) =
743            self.send_request_with_timeout::<lsp_types::request::Shutdown>((), request_timeout)
744        {
745            self.state = ServerState::ShuttingDown;
746            return self.abort_live_child_after_shutdown_error(err);
747        }
748
749        if let Err(err) = self.send_notification::<lsp_types::notification::Exit>(()) {
750            return self.abort_live_child_after_shutdown_error(err);
751        }
752        self.state = ServerState::ShuttingDown;
753
754        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
755        loop {
756            if self.child.try_wait()?.is_some() {
757                self.state = ServerState::Exited;
758                return Ok(());
759            }
760            if Instant::now() >= deadline {
761                // Kill the entire process group, not just the wrapper PID, so
762                // npm-wrapped servers (biome's `node biome lsp-proxy` spawns
763                // a separate cli-darwin-arm64 child) don't leak orphans.
764                kill_lsp_child_group(&mut self.child);
765                self.state = ServerState::Exited;
766                self.child_registry.untrack(self.child_pid);
767                return Err(LspError::Timeout(format!(
768                    "timed out waiting for {:?} to exit",
769                    self.kind
770                )));
771            }
772            thread::sleep(EXIT_POLL_INTERVAL);
773        }
774    }
775
776    pub fn stderr_tail(&self) -> String {
777        self.stderr_tail
778            .lock()
779            .map(|tail| stderr_tail_to_string(&tail))
780            .unwrap_or_default()
781    }
782
783    pub fn child_exited(&mut self) -> bool {
784        self.child.try_wait().ok().flatten().is_some()
785    }
786
787    pub fn child_exit_status(&mut self) -> Option<std::process::ExitStatus> {
788        self.child.try_wait().ok().flatten()
789    }
790
791    pub(crate) fn child_pid(&self) -> u32 {
792        self.child_pid
793    }
794
795    /// If the child is still running, kill its process group and wait bounded.
796    /// Always untrack. The caller logs whether this was a real exit or a reader
797    /// death that left the child alive.
798    pub(crate) fn reap_after_reader_exit(&mut self, _reason: &ServerExitReason) -> ReaderExitReap {
799        let outcome = match self.child.try_wait() {
800            Ok(Some(status)) => ReaderExitReap::AlreadyExited(status),
801            Ok(None) | Err(_) => {
802                kill_lsp_child_group(&mut self.child);
803                self.wait_for_child_exit_bounded();
804                ReaderExitReap::KilledWhileAlive
805            }
806        };
807        self.state = ServerState::Exited;
808        self.child_registry.untrack(self.child_pid);
809        outcome
810    }
811
812    fn abort_live_child_after_shutdown_error(&mut self, err: LspError) -> Result<(), LspError> {
813        if self.child.try_wait()?.is_some() {
814            self.state = ServerState::Exited;
815            self.child_registry.untrack(self.child_pid);
816            return Ok(());
817        }
818        kill_lsp_child_group(&mut self.child);
819        self.wait_for_child_exit_bounded();
820        self.state = ServerState::Exited;
821        self.child_registry.untrack(self.child_pid);
822        Err(err)
823    }
824
825    fn wait_for_child_exit_bounded(&mut self) {
826        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
827        loop {
828            if self.child.try_wait().ok().flatten().is_some() {
829                return;
830            }
831            if Instant::now() >= deadline {
832                return;
833            }
834            thread::sleep(EXIT_POLL_INTERVAL);
835        }
836    }
837
838    // Used only by the Unix-gated child-spawning test modules.
839    #[cfg(all(test, unix))]
840    pub(crate) fn suppress_kill_on_drop_for_test(&mut self) {
841        self.suppress_kill_on_drop = true;
842    }
843
844    // Used only by the Unix-gated child-spawning test modules.
845    #[cfg(all(test, unix))]
846    pub(crate) fn poison_writer_for_test(&self) {
847        let writer = Arc::clone(&self.writer);
848        let _ = thread::spawn(move || {
849            let _guard = writer.lock().expect("writer lock");
850            panic!("poison lsp writer for test");
851        })
852        .join();
853    }
854
855    pub fn state(&self) -> ServerState {
856        self.state
857    }
858
859    pub fn kind(&self) -> ServerKind {
860        self.kind.clone()
861    }
862
863    pub fn root(&self) -> &Path {
864        &self.root
865    }
866
867    fn ensure_can_send(&self) -> Result<(), LspError> {
868        if matches!(self.state, ServerState::ShuttingDown | ServerState::Exited) {
869            return Err(LspError::ServerNotReady(format!(
870                "language server {:?} is not ready (state: {:?})",
871                self.kind, self.state
872            )));
873        }
874        Ok(())
875    }
876
877    fn lock_pending(&self) -> Result<std::sync::MutexGuard<'_, PendingMap>, LspError> {
878        self.pending
879            .lock()
880            .map_err(|_| io::Error::other("pending response map poisoned").into())
881    }
882
883    fn remove_pending(&self, id: &RequestId) {
884        if let Ok(mut pending) = self.pending.lock() {
885            pending.remove(id);
886        }
887    }
888
889    fn send_cancel_request(&mut self, id: &RequestId) -> Result<(), LspError> {
890        let notification = Notification::new("$/cancelRequest", Some(json!({ "id": id })));
891        let mut writer = self
892            .writer
893            .lock()
894            .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
895        transport::write_notification(&mut *writer, &notification)?;
896        Ok(())
897    }
898}
899
900impl Drop for LspClient {
901    fn drop(&mut self) {
902        #[cfg(test)]
903        if self.suppress_kill_on_drop {
904            // Test-only crash seam: retain the tracked child so the reaper and
905            // lifecycle census observe the same orphan signature as a failed
906            // client teardown instead of hiding it by untracking first.
907            self.child_registry.mark_client_gone(self.child_pid);
908            return;
909        }
910        // Record the transition before normal teardown untracks it. A control
911        // thread that snapshots in this narrow window sees an honest orphan
912        // rather than a child that is still reported as client-owned.
913        self.child_registry.mark_client_gone(self.child_pid);
914        // Untrack before the synchronous kill so signal cleanup cannot race this
915        // normal teardown.
916        self.child_registry.untrack(self.child_pid);
917        kill_lsp_child_group(&mut self.child);
918    }
919}
920
921fn spawn_stderr_drain_thread(
922    stderr: std::process::ChildStderr,
923    stderr_tail: Arc<Mutex<VecDeque<String>>>,
924) {
925    thread::spawn(move || {
926        let mut reader = BufReader::new(stderr);
927        let mut line = String::new();
928
929        loop {
930            line.clear();
931            match reader.read_line(&mut line) {
932                Ok(0) => break,
933                Ok(_) => {
934                    if let Ok(mut tail) = stderr_tail.lock() {
935                        append_stderr_tail(&mut tail, &line);
936                    } else {
937                        break;
938                    }
939                }
940                Err(_) => break,
941            }
942        }
943    });
944}
945
946fn append_stderr_tail(tail: &mut VecDeque<String>, line: &str) {
947    if tail.len() == STDERR_TAIL_LINES {
948        tail.pop_front();
949    }
950    tail.push_back(trim_stderr_line(line));
951}
952
953fn trim_stderr_line(line: &str) -> String {
954    let line = line.trim_end_matches(|ch| ch == '\r' || ch == '\n');
955    if line.len() <= STDERR_LINE_BYTES {
956        return line.to_string();
957    }
958
959    let mut start = line.len() - STDERR_LINE_BYTES;
960    while start < line.len() && !line.is_char_boundary(start) {
961        start += 1;
962    }
963    format!("...{}", &line[start..])
964}
965
966fn stderr_tail_to_string(tail: &VecDeque<String>) -> String {
967    tail.iter()
968        .map(String::as_str)
969        .collect::<Vec<_>>()
970        .join("\n")
971}
972
973/// Force-terminate an LSP child and its entire process group on Unix.
974/// On Windows, `taskkill /F /T` kills the process tree.
975///
976/// Necessary because some LSP servers ship as npm-installed Node shims that
977/// spawn the real binary as a child. Killing only the wrapper PID leaves the
978/// real server orphaned to PID 1 and accumulates over time.
979fn kill_lsp_child_group(child: &mut std::process::Child) {
980    #[cfg(unix)]
981    {
982        let pgid = child.id() as i32;
983        crate::bash_background::process::terminate_pgid(pgid, Some(child));
984        let _ = child.wait();
985    }
986    #[cfg(not(unix))]
987    {
988        crate::bash_background::process::terminate_process(child);
989        let _ = child.wait();
990    }
991}
992
993fn record_watched_file_registration(
994    registrations: &WatchedFileRegistrations,
995    method: &str,
996    params: Option<&Value>,
997) {
998    match method {
999        "client/registerCapability" => {
1000            let Some(items) = params
1001                .and_then(|params| params.get("registrations"))
1002                .and_then(|registrations| registrations.as_array())
1003            else {
1004                return;
1005            };
1006            if let Ok(mut guard) = registrations.lock() {
1007                for item in items {
1008                    if item.get("method").and_then(Value::as_str)
1009                        == Some("workspace/didChangeWatchedFiles")
1010                    {
1011                        if let Some(id) = item.get("id").and_then(Value::as_str) {
1012                            guard.insert(id.to_string());
1013                        }
1014                    }
1015                }
1016            }
1017        }
1018        "client/unregisterCapability" => {
1019            let Some(items) = params
1020                .and_then(|params| params.get("unregisterations"))
1021                .and_then(|registrations| registrations.as_array())
1022            else {
1023                return;
1024            };
1025            if let Ok(mut guard) = registrations.lock() {
1026                for item in items {
1027                    if item.get("method").and_then(Value::as_str)
1028                        == Some("workspace/didChangeWatchedFiles")
1029                    {
1030                        if let Some(id) = item.get("id").and_then(Value::as_str) {
1031                            guard.remove(id);
1032                        }
1033                    }
1034                }
1035            }
1036        }
1037        _ => {}
1038    }
1039}
1040
1041fn workspace_configuration_response(
1042    kind: &ServerKind,
1043    root: &Path,
1044    params: Option<&Value>,
1045) -> Value {
1046    let items = params
1047        .and_then(|params| params.get("items"))
1048        .and_then(Value::as_array);
1049    let python_path = (kind == &ServerKind::Python)
1050        .then(|| project_python_path(root))
1051        .flatten()
1052        // Lossy on purpose: PathBuf's Serialize rejects non-UTF-8 bytes and a
1053        // panic here would wedge the reader thread mid-handshake. A lossy
1054        // interpreter path degrades one exotic workspace instead.
1055        .map(|path| path.to_string_lossy().into_owned());
1056
1057    Value::Array(match items {
1058        Some(items) => items
1059            .iter()
1060            .map(|item| {
1061                if item.get("section").and_then(Value::as_str) == Some("python") {
1062                    if let Some(path) = &python_path {
1063                        return json!({ "pythonPath": path });
1064                    }
1065                }
1066                Value::Null
1067            })
1068            .collect(),
1069        None => vec![Value::Null],
1070    })
1071}
1072
1073fn project_python_path(root: &Path) -> Option<PathBuf> {
1074    [root.join(".venv"), root.join("venv")]
1075        .into_iter()
1076        .find_map(|virtualenv| {
1077            if cfg!(windows) {
1078                let candidate = virtualenv.join("Scripts").join("python.exe");
1079                return candidate.is_file().then_some(candidate);
1080            }
1081
1082            ["python", "python3"]
1083                .into_iter()
1084                .map(|binary| virtualenv.join("bin").join(binary))
1085                .find(|candidate| candidate.is_file())
1086        })
1087}
1088
1089/// Parse `ServerDiagnosticCapabilities` from a re-serialized
1090/// `ServerCapabilities` JSON value.
1091///
1092/// LSP 3.17 spec for `diagnosticProvider`:
1093/// - `capabilities.diagnosticProvider` may be absent (no pull support),
1094///   `DiagnosticOptions`, or `DiagnosticRegistrationOptions`.
1095/// - If present:
1096///   - `interFileDependencies: bool` (we don't currently use this)
1097///   - `workspaceDiagnostics: bool` → workspace pull support
1098///   - `identifier?: string` → optional identifier scoping result IDs
1099///
1100/// We parse the raw JSON Value defensively: presence of any
1101/// `diagnosticProvider` value (object or `true`) means the server supports
1102/// at least `textDocument/diagnostic` pull.
1103fn parse_diagnostic_capabilities(value: &Value) -> ServerDiagnosticCapabilities {
1104    let mut caps = ServerDiagnosticCapabilities::default();
1105
1106    if let Some(provider) = value.get("diagnosticProvider") {
1107        // diagnosticProvider can be `true` (rare) or an object. Treat both as
1108        // pull_diagnostics support.
1109        if provider.is_object() || provider.as_bool() == Some(true) {
1110            caps.pull_diagnostics = true;
1111        }
1112
1113        if let Some(obj) = provider.as_object() {
1114            if obj
1115                .get("workspaceDiagnostics")
1116                .and_then(|v| v.as_bool())
1117                .unwrap_or(false)
1118            {
1119                caps.workspace_diagnostics = true;
1120            }
1121            if let Some(identifier) = obj.get("identifier").and_then(|v| v.as_str()) {
1122                caps.identifier = Some(identifier.to_string());
1123            }
1124        }
1125    }
1126
1127    // Workspace diagnostic refresh (rare — most servers don't request this,
1128    // and we declared refreshSupport=false in our client capabilities anyway).
1129    if let Some(refresh) = value
1130        .get("workspace")
1131        .and_then(|w| w.get("diagnostic"))
1132        .and_then(|d| d.get("refreshSupport"))
1133        .and_then(|r| r.as_bool())
1134    {
1135        caps.refresh_support = refresh;
1136    }
1137
1138    caps
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144    // Only the Unix-gated reap tests below spawn real children and name paths.
1145    #[cfg(unix)]
1146    use std::collections::HashMap;
1147    use std::io::{BufReader, Cursor};
1148    #[cfg(unix)]
1149    use std::path::{Path, PathBuf};
1150
1151    #[test]
1152    fn parse_caps_no_diagnostic_provider() {
1153        let value = json!({});
1154        let caps = parse_diagnostic_capabilities(&value);
1155        assert!(!caps.pull_diagnostics);
1156        assert!(!caps.workspace_diagnostics);
1157        assert!(caps.identifier.is_none());
1158    }
1159
1160    #[test]
1161    fn parse_caps_basic_pull_only() {
1162        let value = json!({
1163            "diagnosticProvider": {
1164                "interFileDependencies": false,
1165                "workspaceDiagnostics": false
1166            }
1167        });
1168        let caps = parse_diagnostic_capabilities(&value);
1169        assert!(caps.pull_diagnostics);
1170        assert!(!caps.workspace_diagnostics);
1171    }
1172
1173    #[test]
1174    fn parse_caps_full_pull_with_workspace() {
1175        let value = json!({
1176            "diagnosticProvider": {
1177                "interFileDependencies": true,
1178                "workspaceDiagnostics": true,
1179                "identifier": "rust-analyzer"
1180            }
1181        });
1182        let caps = parse_diagnostic_capabilities(&value);
1183        assert!(caps.pull_diagnostics);
1184        assert!(caps.workspace_diagnostics);
1185        assert_eq!(caps.identifier.as_deref(), Some("rust-analyzer"));
1186    }
1187
1188    #[test]
1189    fn parse_caps_provider_as_bare_true() {
1190        // LSP 3.17 allows DiagnosticOptions OR boolean — treat true as pull_diagnostics
1191        let value = json!({
1192            "diagnosticProvider": true
1193        });
1194        let caps = parse_diagnostic_capabilities(&value);
1195        assert!(caps.pull_diagnostics);
1196        assert!(!caps.workspace_diagnostics);
1197    }
1198
1199    #[test]
1200    fn interactive_request_timeout_is_eight_seconds() {
1201        assert_eq!(INTERACTIVE_REQUEST_TIMEOUT, Duration::from_secs(8));
1202    }
1203
1204    #[test]
1205    fn handshake_request_timeout_remains_thirty_seconds() {
1206        assert_eq!(HANDSHAKE_REQUEST_TIMEOUT, Duration::from_secs(30));
1207    }
1208
1209    #[test]
1210    fn parse_caps_workspace_refresh_support() {
1211        let value = json!({
1212            "workspace": {
1213                "diagnostic": {
1214                    "refreshSupport": true
1215                }
1216            }
1217        });
1218        let caps = parse_diagnostic_capabilities(&value);
1219        assert!(caps.refresh_support);
1220        // No diagnosticProvider → pull still false
1221        assert!(!caps.pull_diagnostics);
1222    }
1223
1224    #[test]
1225    fn pyright_configuration_uses_workspace_virtualenv_interpreter() {
1226        let tmp = tempfile::tempdir().unwrap();
1227        let root = tmp.path();
1228        let python = if cfg!(windows) {
1229            root.join(".venv").join("Scripts").join("python.exe")
1230        } else {
1231            root.join(".venv").join("bin").join("python")
1232        };
1233        std::fs::create_dir_all(python.parent().unwrap()).unwrap();
1234        std::fs::write(&python, []).unwrap();
1235        let params = json!({
1236            "items": [
1237                { "section": "python" },
1238                { "section": "pyright" }
1239            ]
1240        });
1241
1242        let response = workspace_configuration_response(&ServerKind::Python, root, Some(&params));
1243
1244        assert_eq!(response[0]["pythonPath"], python.display().to_string());
1245        assert!(response[1].is_null());
1246    }
1247
1248    #[test]
1249    fn ty_configuration_does_not_receive_pyright_interpreter_settings() {
1250        let tmp = tempfile::tempdir().unwrap();
1251        let root = tmp.path();
1252        let python = if cfg!(windows) {
1253            root.join(".venv").join("Scripts").join("python.exe")
1254        } else {
1255            root.join(".venv").join("bin").join("python")
1256        };
1257        std::fs::create_dir_all(python.parent().unwrap()).unwrap();
1258        std::fs::write(python, []).unwrap();
1259        let params = json!({ "items": [{ "section": "python" }] });
1260
1261        let response = workspace_configuration_response(&ServerKind::Ty, root, Some(&params));
1262
1263        assert!(response[0].is_null());
1264    }
1265
1266    #[test]
1267    fn eof_read_maps_to_eof_reason() {
1268        let mut reader = BufReader::new(Cursor::new([]));
1269        let result = transport::read_message(&mut reader);
1270        assert!(matches!(result, Ok(None)));
1271        assert_eq!(
1272            ServerExitReason::from_read_result(result),
1273            ServerExitReason::Eof
1274        );
1275    }
1276
1277    #[test]
1278    fn malformed_frame_maps_to_read_error_reason() {
1279        let mut reader = BufReader::new(Cursor::new(b"Content-Length: 3\r\n\r\n{{{"));
1280        let result = transport::read_message(&mut reader);
1281        assert!(result.is_err());
1282        match ServerExitReason::from_read_result(result) {
1283            ServerExitReason::ReadError(message) => assert!(
1284                !message.is_empty(),
1285                "ReadError must carry the concrete framing error"
1286            ),
1287            other => panic!("expected ReadError, got {other:?}"),
1288        }
1289    }
1290
1291    #[cfg(unix)]
1292    fn spawn_long_lived_client(
1293        script: &str,
1294        event_tx: Sender<LspEvent>,
1295        registry: LspChildRegistry,
1296        root: PathBuf,
1297    ) -> LspClient {
1298        LspClient::spawn(
1299            ServerKind::TypeScript,
1300            root,
1301            Path::new("sh"),
1302            &["-c".to_string(), script.to_string()],
1303            &HashMap::new(),
1304            event_tx,
1305            registry,
1306        )
1307        .expect("spawn long-lived LSP stand-in")
1308    }
1309
1310    #[cfg(unix)]
1311    #[test]
1312    fn reader_emits_read_error_for_malformed_frame() {
1313        let (tx, rx) = crossbeam_channel::unbounded();
1314        let registry = LspChildRegistry::new();
1315        let tmp = tempfile::tempdir().unwrap();
1316        let client = spawn_long_lived_client(
1317            "printf 'Content-Length: 3\r\n\r\n{{{'; exec sleep 60",
1318            tx,
1319            registry,
1320            tmp.path().to_path_buf(),
1321        );
1322        let event = rx
1323            .recv_timeout(Duration::from_secs(5))
1324            .expect("reader should emit ServerExited");
1325        match event {
1326            LspEvent::ServerExited {
1327                reason: ServerExitReason::ReadError(message),
1328                ..
1329            } => assert!(!message.is_empty()),
1330            other => panic!("expected ReadError ServerExited, got {other:?}"),
1331        }
1332        drop(client);
1333    }
1334
1335    #[cfg(unix)]
1336    #[test]
1337    fn reader_emits_eof_when_child_closes_stdout() {
1338        let (tx, rx) = crossbeam_channel::unbounded();
1339        let registry = LspChildRegistry::new();
1340        let tmp = tempfile::tempdir().unwrap();
1341        let client = spawn_long_lived_client("exit 0", tx, registry, tmp.path().to_path_buf());
1342        let event = rx
1343            .recv_timeout(Duration::from_secs(5))
1344            .expect("reader should emit ServerExited on EOF");
1345        match event {
1346            LspEvent::ServerExited {
1347                reason: ServerExitReason::Eof,
1348                ..
1349            } => {}
1350            other => panic!("expected Eof ServerExited, got {other:?}"),
1351        }
1352        drop(client);
1353    }
1354
1355    #[cfg(unix)]
1356    #[test]
1357    fn shutdown_error_kills_and_untracks_live_child() {
1358        let (tx, _rx) = crossbeam_channel::unbounded();
1359        let registry = LspChildRegistry::new();
1360        let tmp = tempfile::tempdir().unwrap();
1361        let mut client = spawn_long_lived_client(
1362            "exec sleep 60",
1363            tx,
1364            registry.clone(),
1365            tmp.path().to_path_buf(),
1366        );
1367        let pid = client.child_pid();
1368        assert!(
1369            registry.pids().contains(&pid),
1370            "child must be tracked before shutdown"
1371        );
1372        assert!(
1373            crate::bash_background::process::is_process_alive(pid),
1374            "child must still be running"
1375        );
1376        client.poison_writer_for_test();
1377        let result = client.shutdown();
1378        assert!(result.is_err(), "shutdown must return Err, got {result:?}");
1379        assert!(
1380            !crate::bash_background::process::is_process_alive(pid),
1381            "shutdown Err must not leave a live child"
1382        );
1383        assert!(
1384            !registry.pids().contains(&pid),
1385            "shutdown Err must untrack the child"
1386        );
1387    }
1388}