Skip to main content

codelens_engine/lsp/
session.rs

1use crate::project::ProjectRoot;
2use anyhow::{Context, Result, bail};
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10use url::Url;
11
12use super::commands::is_allowed_lsp_command;
13use super::protocol::{language_id_for_path, poll_readable, read_message, send_message};
14use super::registry::resolve_lsp_binary_with_hint;
15use super::types::{
16    LspCodeActionRefactorPlan, LspCodeActionRefactorResult, LspCodeActionRequest, LspDiagnostic,
17    LspDiagnosticRequest, LspReference, LspRenamePlan, LspRenamePlanRequest, LspRenameRequest,
18    LspRequest, LspResolveTargetRequest, LspResolvedTarget, LspTypeHierarchyRequest,
19    LspWorkspaceEditTransaction, LspWorkspaceSymbol, LspWorkspaceSymbolRequest,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23struct SessionKey {
24    command: String,
25    args: Vec<String>,
26}
27
28impl SessionKey {
29    fn new(command: &str, args: &[String]) -> Self {
30        Self {
31            command: command.to_owned(),
32            args: args.to_owned(),
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38struct OpenDocumentState {
39    version: i32,
40    text: String,
41}
42
43pub struct LspSessionPool {
44    project: ProjectRoot,
45    sessions: std::sync::Mutex<HashMap<SessionKey, LspSession>>,
46}
47
48pub(super) struct LspSession {
49    pub(super) project: ProjectRoot,
50    child: Child,
51    stdin: ChildStdin,
52    reader: BufReader<ChildStdout>,
53    next_request_id: u64,
54    documents: HashMap<String, OpenDocumentState>,
55    #[allow(dead_code)] // retained for future stderr diagnostics
56    stderr_buffer: std::sync::Arc<std::sync::Mutex<String>>,
57    /// Server-reported readiness (P1.1): `Some(true)` once the server has
58    /// signalled a quiescent state (rust-analyzer `experimental/serverStatus`
59    /// with `quiescent: true`), `Some(false)` while it reports active
60    /// indexing, `None` for servers that never emit a readiness signal.
61    /// Consumed by warm-routing/confidence calibration — a warm session is
62    /// not necessarily a *quiescent* one.
63    server_quiescent: Option<bool>,
64}
65
66fn ensure_session<'a>(
67    sessions: &'a mut HashMap<SessionKey, LspSession>,
68    project: &ProjectRoot,
69    command: &str,
70    args: &[String],
71) -> Result<&'a mut LspSession> {
72    if !is_allowed_lsp_command(command) {
73        bail!(
74            "Blocked: '{command}' is not a known LSP server. Only whitelisted LSP binaries are allowed."
75        );
76    }
77
78    let key = SessionKey::new(command, args);
79
80    // Check for dead sessions: if the child process has exited, remove the stale entry.
81    if let Some(session) = sessions.get_mut(&key) {
82        match session.child.try_wait() {
83            Ok(Some(_status)) => {
84                // Process exited — remove stale session so we start fresh below.
85                sessions.remove(&key);
86            }
87            Ok(None) => {} // Still running — will return it via Occupied below.
88            Err(_) => {
89                sessions.remove(&key);
90            }
91        }
92    }
93
94    match sessions.entry(key) {
95        std::collections::hash_map::Entry::Occupied(e) => Ok(e.into_mut()),
96        std::collections::hash_map::Entry::Vacant(e) => {
97            let session = LspSession::start(project, command, args)?;
98            Ok(e.insert(session))
99        }
100    }
101}
102
103fn is_retriable_lsp_transport_error(err: &anyhow::Error) -> bool {
104    let text = err.to_string().to_ascii_lowercase();
105    [
106        "unexpected eof",
107        "broken pipe",
108        "connection reset",
109        "connection aborted",
110        "transport endpoint is not connected",
111        "os error 32",
112        "os error 54",
113    ]
114    .iter()
115    .any(|marker| text.contains(marker))
116}
117
118impl LspSessionPool {
119    pub fn new(project: ProjectRoot) -> Self {
120        Self {
121            project,
122            sessions: std::sync::Mutex::new(HashMap::new()),
123        }
124    }
125
126    /// Replace the project root and close all existing sessions.
127    pub fn reset(&self, project: ProjectRoot) -> Self {
128        // Drop existing sessions so LSP processes are killed.
129        self.sessions
130            .lock()
131            .unwrap_or_else(|p| p.into_inner())
132            .clear();
133        Self::new(project)
134    }
135
136    /// Shutdown all active LSP sessions in this pool by dropping them.
137    pub fn shutdown(&self) {
138        self.sessions
139            .lock()
140            .unwrap_or_else(|p| p.into_inner())
141            .clear();
142    }
143
144    pub fn session_count(&self) -> usize {
145        self.sessions
146            .lock()
147            .unwrap_or_else(|p| p.into_inner())
148            .len()
149    }
150
151    /// Non-spawning warmth probe for the latency-sensitive default reference
152    /// path. Returns `true` only when a live LSP session for `command`+`args`
153    /// is already resident (child process still running). It **never spawns**
154    /// a server — a cold or absent language returns `false` and leaves the
155    /// pool untouched, so callers can gate precise LSP routing on warmth
156    /// without risking a 2-30s cold start. Stale (exited) sessions are reaped
157    /// as a side effect, mirroring `ensure_session`.
158    pub fn has_warm_session(&self, command: &str, args: &[String]) -> bool {
159        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
160        let key = SessionKey::new(command, args);
161        match sessions.get_mut(&key) {
162            Some(session) => match session.child.try_wait() {
163                Ok(None) => true,
164                _ => {
165                    sessions.remove(&key);
166                    false
167                }
168            },
169            None => false,
170        }
171    }
172
173    /// Spawn-and-initialize a session so later default-path calls find it
174    /// warm (P1.3 pre-warm pool). Idempotent: an already-live session is a
175    /// no-op. Runs the full spawn+initialize handshake, so callers should
176    /// invoke this from a background thread — never on a bind/request hot
177    /// path. Command whitelisting is enforced by `ensure_session`.
178    pub fn prewarm_session(&self, command: &str, args: &[String]) -> Result<()> {
179        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
180        ensure_session(&mut sessions, &self.project, command, args).map(|_| ())
181    }
182
183    /// Readiness of a warm session (P1.1): outer `None` = no live session for
184    /// this server; `Some(None)` = live but the server never emitted a
185    /// readiness signal (unknown — do NOT assume ready); `Some(Some(q))` =
186    /// the server's latest `experimental/serverStatus` quiescence state.
187    /// Warm ≠ quiescent: confidence calibration must treat `Some(Some(false))`
188    /// (still indexing) as degraded evidence.
189    pub fn warm_session_quiescence(&self, command: &str, args: &[String]) -> Option<Option<bool>> {
190        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
191        let key = SessionKey::new(command, args);
192        match sessions.get_mut(&key) {
193            Some(session) => match session.child.try_wait() {
194                Ok(None) => Some(session.server_quiescent()),
195                _ => {
196                    sessions.remove(&key);
197                    None
198                }
199            },
200            None => None,
201        }
202    }
203
204    pub fn find_referencing_symbols(&self, request: &LspRequest) -> Result<Vec<LspReference>> {
205        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
206        let session = ensure_session(
207            &mut sessions,
208            &self.project,
209            &request.command,
210            &request.args,
211        )?;
212        session.find_references(request)
213    }
214
215    /// Same as [`find_referencing_symbols`], but also reports whether *this*
216    /// call had to spawn the LSP server (a cold start). `false` means an
217    /// already-resident warm session was reused. The warmth check and the
218    /// spawn decision happen under the same lock, so the flag is race-free for
219    /// this call: a caller that gated on an earlier `has_warm_session` probe
220    /// can use it to detect the rare TOCTOU case where the server died between
221    /// the probe and this request and had to be respawned mid-flight. Routing
222    /// is unchanged — the flag only lets the caller describe what happened.
223    pub fn find_referencing_symbols_tracking_spawn(
224        &self,
225        request: &LspRequest,
226    ) -> Result<(Vec<LspReference>, bool)> {
227        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
228        let key = SessionKey::new(&request.command, &request.args);
229        // A live child for this key means `ensure_session` reuses it (no
230        // spawn); its absence or a dead child means it will spawn below.
231        let was_warm = sessions
232            .get_mut(&key)
233            .map(|session| matches!(session.child.try_wait(), Ok(None)))
234            .unwrap_or(false);
235        let session = ensure_session(
236            &mut sessions,
237            &self.project,
238            &request.command,
239            &request.args,
240        )?;
241        let references = session.find_references(request)?;
242        Ok((references, !was_warm))
243    }
244
245    pub fn get_diagnostics(&self, request: &LspDiagnosticRequest) -> Result<Vec<LspDiagnostic>> {
246        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
247        let result = {
248            let session = ensure_session(
249                &mut sessions,
250                &self.project,
251                &request.command,
252                &request.args,
253            )?;
254            session.get_diagnostics(request)
255        };
256
257        match result {
258            Ok(diagnostics) => Ok(diagnostics),
259            Err(err) if is_retriable_lsp_transport_error(&err) => {
260                let key = SessionKey::new(&request.command, &request.args);
261                sessions.remove(&key);
262                let session = ensure_session(
263                    &mut sessions,
264                    &self.project,
265                    &request.command,
266                    &request.args,
267                )?;
268                session
269                    .get_diagnostics(request)
270                    .with_context(|| "retried diagnostics after stale LSP transport")
271            }
272            Err(err) => Err(err),
273        }
274    }
275
276    pub fn search_workspace_symbols(
277        &self,
278        request: &LspWorkspaceSymbolRequest,
279    ) -> Result<Vec<LspWorkspaceSymbol>> {
280        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
281        let session = ensure_session(
282            &mut sessions,
283            &self.project,
284            &request.command,
285            &request.args,
286        )?;
287        session.search_workspace_symbols(request)
288    }
289
290    pub fn get_type_hierarchy(
291        &self,
292        request: &LspTypeHierarchyRequest,
293    ) -> Result<HashMap<String, Value>> {
294        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
295        let session = ensure_session(
296            &mut sessions,
297            &self.project,
298            &request.command,
299            &request.args,
300        )?;
301        session.get_type_hierarchy(request)
302    }
303
304    pub fn resolve_symbol_target(
305        &self,
306        request: &LspResolveTargetRequest,
307    ) -> Result<Vec<LspResolvedTarget>> {
308        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
309        let session = ensure_session(
310            &mut sessions,
311            &self.project,
312            &request.command,
313            &request.args,
314        )?;
315        session.resolve_symbol_target(request)
316    }
317
318    pub fn get_rename_plan(&self, request: &LspRenamePlanRequest) -> Result<LspRenamePlan> {
319        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
320        let session = ensure_session(
321            &mut sessions,
322            &self.project,
323            &request.command,
324            &request.args,
325        )?;
326        session.get_rename_plan(request)
327    }
328
329    pub fn rename_symbol(&self, request: &LspRenameRequest) -> Result<crate::rename::RenameResult> {
330        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
331        let session = ensure_session(
332            &mut sessions,
333            &self.project,
334            &request.command,
335            &request.args,
336        )?;
337        session.rename_symbol(request)
338    }
339
340    pub fn rename_symbol_transaction(
341        &self,
342        request: &LspRenameRequest,
343    ) -> Result<LspWorkspaceEditTransaction> {
344        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
345        let session = ensure_session(
346            &mut sessions,
347            &self.project,
348            &request.command,
349            &request.args,
350        )?;
351        session.rename_symbol_transaction(request)
352    }
353
354    pub fn code_action_refactor(
355        &self,
356        request: &LspCodeActionRequest,
357    ) -> Result<LspCodeActionRefactorResult> {
358        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
359        let session = ensure_session(
360            &mut sessions,
361            &self.project,
362            &request.command,
363            &request.args,
364        )?;
365        session.code_action_refactor(request)
366    }
367
368    pub fn code_action_refactor_plan(
369        &self,
370        request: &LspCodeActionRequest,
371    ) -> Result<LspCodeActionRefactorPlan> {
372        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
373        let session = ensure_session(
374            &mut sessions,
375            &self.project,
376            &request.command,
377            &request.args,
378        )?;
379        session.code_action_refactor_plan(request)
380    }
381}
382
383impl LspSession {
384    fn start(project: &ProjectRoot, command: &str, args: &[String]) -> Result<Self> {
385        let command_path = resolve_lsp_binary_with_hint(command, Some(project.as_path()))
386            .unwrap_or_else(|| command.into());
387        let mut child = Command::new(&command_path)
388            .args(args)
389            .stdin(Stdio::piped())
390            .stdout(Stdio::piped())
391            .stderr(Stdio::piped())
392            .spawn()
393            .with_context(|| format!("failed to spawn LSP server {}", command_path.display()))?;
394
395        let stdin = child.stdin.take().context("failed to open LSP stdin")?;
396        let stdout = child.stdout.take().context("failed to open LSP stdout")?;
397
398        // Capture stderr in a background thread (bounded 4KB ring buffer).
399        let stderr_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
400        if let Some(stderr) = child.stderr.take() {
401            let buf = std::sync::Arc::clone(&stderr_buffer);
402            thread::spawn(move || {
403                let mut reader = BufReader::new(stderr);
404                let mut line = String::new();
405                while reader.read_line(&mut line).unwrap_or(0) > 0 {
406                    if let Ok(mut b) = buf.lock() {
407                        if b.len() > 4096 {
408                            let drain_to = b.len() - 2048;
409                            b.drain(..drain_to);
410                        }
411                        b.push_str(&line);
412                    }
413                    line.clear();
414                }
415            });
416        }
417
418        let mut session = Self {
419            project: project.clone(),
420            child,
421            stdin,
422            reader: BufReader::new(stdout),
423            next_request_id: 1,
424            documents: HashMap::new(),
425            stderr_buffer,
426            server_quiescent: None,
427        };
428        session.initialize(command)?;
429        if let Some(grace) = configured_startup_grace() {
430            thread::sleep(grace);
431        }
432        Ok(session)
433    }
434
435    fn initialize(&mut self, command: &str) -> Result<()> {
436        let id = self.next_id();
437        let root_uri = Url::from_directory_path(self.project.as_path())
438            .ok()
439            .map(|url| url.to_string());
440        let workspace_name = self
441            .project
442            .as_path()
443            .file_name()
444            .and_then(|n| n.to_str())
445            .unwrap_or("workspace")
446            .to_owned();
447        self.send_request(
448            id,
449            "initialize",
450            initialize_params(
451                root_uri,
452                &workspace_name,
453                initialization_options_for_command(command),
454            ),
455        )?;
456        let _ = self.read_response_for_id(id)?;
457        self.send_notification("initialized", json!({}))?;
458        Ok(())
459    }
460
461    pub(super) fn prepare_document(&mut self, absolute_path: &Path) -> Result<(String, String)> {
462        let uri = Url::from_file_path(absolute_path).map_err(|_| {
463            anyhow::anyhow!("failed to build file uri for {}", absolute_path.display())
464        })?;
465        let uri_string = uri.to_string();
466        let source = std::fs::read_to_string(absolute_path)
467            .with_context(|| format!("failed to read {}", absolute_path.display()))?;
468        let language_id = language_id_for_path(absolute_path)?;
469        self.sync_document(&uri_string, language_id, &source)?;
470        Ok((uri_string, source))
471    }
472
473    pub(super) fn sync_document(
474        &mut self,
475        uri: &str,
476        language_id: &str,
477        source: &str,
478    ) -> Result<()> {
479        if let Some(state) = self.documents.get(uri)
480            && state.text == source
481        {
482            return Ok(());
483        }
484
485        if let Some(state) = self.documents.get_mut(uri) {
486            state.version += 1;
487            state.text = source.to_owned();
488            let version = state.version;
489            return self.send_notification(
490                "textDocument/didChange",
491                json!({
492                    "textDocument":{"uri":uri,"version":version},
493                    "contentChanges":[{"text":source}]
494                }),
495            );
496        }
497
498        self.documents.insert(
499            uri.to_owned(),
500            OpenDocumentState {
501                version: 1,
502                text: source.to_owned(),
503            },
504        );
505        self.send_notification(
506            "textDocument/didOpen",
507            json!({
508                "textDocument":{
509                    "uri":uri,
510                    "languageId":language_id,
511                    "version":1,
512                    "text":source
513                }
514            }),
515        )
516    }
517
518    pub(super) fn next_id(&mut self) -> u64 {
519        let id = self.next_request_id;
520        self.next_request_id += 1;
521        id
522    }
523
524    pub(super) fn send_request(&mut self, id: u64, method: &str, params: Value) -> Result<()> {
525        send_message(
526            &mut self.stdin,
527            &json!({
528                "jsonrpc":"2.0",
529                "id":id,
530                "method":method,
531                "params":params
532            }),
533        )
534    }
535
536    fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
537        send_message(
538            &mut self.stdin,
539            &json!({
540                "jsonrpc":"2.0",
541                "method":method,
542                "params":params
543            }),
544        )
545    }
546
547    pub(super) fn read_response_for_id(&mut self, expected_id: u64) -> Result<Value> {
548        let deadline = Instant::now() + Duration::from_secs(30);
549        let mut discarded = 0u32;
550        const MAX_DISCARDED: u32 = 500;
551
552        loop {
553            let remaining = deadline.saturating_duration_since(Instant::now());
554            if remaining.is_zero() {
555                bail!(
556                    "LSP response timeout: no response for request id {expected_id} within 30s \
557                     ({discarded} unrelated messages discarded)"
558                );
559            }
560            if discarded >= MAX_DISCARDED {
561                bail!(
562                    "LSP response loop: discarded {MAX_DISCARDED} messages without finding id {expected_id}"
563                );
564            }
565
566            // Poll the pipe before blocking read — prevents infinite hang
567            if !poll_readable(self.reader.get_ref(), remaining.min(Duration::from_secs(5))) {
568                continue; // no data yet, re-check deadline
569            }
570
571            let message = read_message(&mut self.reader)?;
572            let method = message.get("method").and_then(Value::as_str);
573
574            // P1.1: server→client REQUEST (both `id` and `method` present).
575            // Historically these were discarded, which violates the protocol —
576            // a server blocked on `workspace/configuration` either stalls or
577            // falls back to defaults nondeterministically. Answer instead of
578            // discarding; unknown methods get a spec-correct MethodNotFound.
579            if let Some(method) = method {
580                if let Some(request_id) = message.get("id").filter(|id| !id.is_null()) {
581                    let request_id = request_id.clone();
582                    let reply = server_request_reply_payload(method, message.get("params"));
583                    self.answer_server_request(&request_id, reply)?;
584                } else {
585                    // Server notification: harvest readiness signals before
586                    // dropping. Counted against MAX_DISCARDED so a
587                    // notification-flooding server still trips the breaker.
588                    self.observe_server_notification(method, message.get("params"));
589                    discarded += 1;
590                }
591                continue;
592            }
593
594            let matches_id = message
595                .get("id")
596                .and_then(Value::as_u64)
597                .map(|id| id == expected_id)
598                .unwrap_or(false);
599            if matches_id {
600                if let Some(error) = message.get("error") {
601                    let code = error.get("code").and_then(Value::as_i64).unwrap_or(-1);
602                    let error_message = error
603                        .get("message")
604                        .and_then(Value::as_str)
605                        .unwrap_or("unknown LSP error");
606                    bail!("LSP request failed ({code}): {error_message}");
607                }
608                return Ok(message);
609            }
610            discarded += 1;
611        }
612    }
613
614    /// Send the prepared reply for a server→client request.
615    fn answer_server_request(
616        &mut self,
617        request_id: &Value,
618        reply: std::result::Result<Value, Value>,
619    ) -> Result<()> {
620        let body = match reply {
621            Ok(result) => json!({"jsonrpc":"2.0","id":request_id,"result":result}),
622            Err(error) => json!({"jsonrpc":"2.0","id":request_id,"error":error}),
623        };
624        send_message(&mut self.stdin, &body)
625    }
626
627    /// Harvest readiness state from server notifications (P1.1).
628    fn observe_server_notification(&mut self, method: &str, params: Option<&Value>) {
629        if method == "experimental/serverStatus"
630            && let Some(quiescent) = params
631                .and_then(|params| params.get("quiescent"))
632                .and_then(Value::as_bool)
633        {
634            self.server_quiescent = Some(quiescent);
635        }
636    }
637
638    /// True once the server has reported a quiescent (fully indexed) state.
639    /// `None` when the server never emitted a readiness signal — callers must
640    /// treat that as "unknown", not "ready".
641    pub(super) fn server_quiescent(&self) -> Option<bool> {
642        self.server_quiescent
643    }
644
645    fn shutdown(&mut self) -> Result<()> {
646        let id = self.next_id();
647        self.send_request(id, "shutdown", Value::Null)?;
648        let _ = self.read_response_for_id(id)?;
649        self.send_notification("exit", Value::Null)
650    }
651}
652
653/// Pure decision table for server→client requests (P1.1): what to reply.
654/// `Ok` carries the `result` payload, `Err` carries the `error` payload.
655///
656/// - `workspace/configuration` — one `null` per requested item = "use your
657///   defaults". Deterministic, and unblocks servers that wait on the reply.
658/// - `client/registerCapability` / `client/unregisterCapability` /
659///   `window/workDoneProgress/create` — plain acknowledgement (`null`).
660/// - `workspace/applyEdit` — REFUSED (`applied: false`): the read path must
661///   never let a server mutate the workspace behind the caller's back; every
662///   CodeLens mutation flows through the verifier-gated edit transaction.
663/// - anything else — spec-correct `MethodNotFound` (-32601) instead of
664///   silence, so the server can degrade deterministically.
665fn server_request_reply_payload(
666    method: &str,
667    params: Option<&Value>,
668) -> std::result::Result<Value, Value> {
669    match method {
670        "workspace/configuration" => {
671            let item_count = params
672                .and_then(|params| params.get("items"))
673                .and_then(Value::as_array)
674                .map(Vec::len)
675                .unwrap_or(0);
676            Ok(Value::Array(vec![Value::Null; item_count]))
677        }
678        "client/registerCapability"
679        | "client/unregisterCapability"
680        | "window/workDoneProgress/create" => Ok(Value::Null),
681        "workspace/applyEdit" => Ok(json!({
682            "applied": false,
683            "failureReason": "codelens read sessions do not accept server-initiated edits"
684        })),
685        _ => Err(json!({
686            "code": -32601,
687            "message": format!("method not supported by codelens LSP client: {method}")
688        })),
689    }
690}
691
692/// Server-specific `initializationOptions` table (P1.1c).
693///
694/// Extension policy: officially documented options only, and the minimum
695/// set per server — an unknown or unlisted server MUST get `None`, which
696/// sends `initialize` without an `initializationOptions` field at all.
697/// Matching is on the binary file name (mirroring `is_allowed_lsp_command`)
698/// so path-qualified commands hit the same entry.
699fn initialization_options_for_command(command: &str) -> Option<Value> {
700    let binary = Path::new(command)
701        .file_name()
702        .and_then(|n| n.to_str())
703        .unwrap_or(command);
704    match binary {
705        // rust-analyzer (documented option): this client only drives
706        // references/navigation, never reads save-time diagnostics, so
707        // `cargo check` on save is pure daemon CPU cost.
708        "rust-analyzer" => Some(json!({"checkOnSave": false})),
709        _ => None,
710    }
711}
712
713/// Build the `initialize` request params. The capabilities payload is
714/// invariant across servers; `initialization_options` is attached as the
715/// `initializationOptions` field only when `Some` — a `None` entry must
716/// produce the exact pre-P1.1c params shape (no empty/null field).
717fn initialize_params(
718    root_uri: Option<String>,
719    workspace_name: &str,
720    initialization_options: Option<Value>,
721) -> Value {
722    let mut params = json!({
723        "processId":null,
724        "rootUri": root_uri.clone(),
725        "capabilities":{
726            "workspace":{
727                "workspaceEdit":{
728                    "documentChanges":true,
729                    "resourceOperations":["create","rename","delete"],
730                    "failureHandling":"textOnlyTransactional"
731                },
732                "symbol":{"dynamicRegistration":false}
733            },
734            "textDocument":{
735                "declaration":{"dynamicRegistration":false},
736                "definition":{"dynamicRegistration":false},
737                "implementation":{"dynamicRegistration":false},
738                "typeDefinition":{"dynamicRegistration":false},
739                "references":{"dynamicRegistration":false},
740                "rename":{"dynamicRegistration":false,"prepareSupport":true},
741                "diagnostic":{"dynamicRegistration":false},
742                "typeHierarchy":{"dynamicRegistration":false},
743                "codeAction":{
744                    "dynamicRegistration":false,
745                    "codeActionLiteralSupport":{
746                        "codeActionKind":{
747                            "valueSet":["quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite"]
748                        }
749                    },
750                    "resolveSupport":{"properties":["edit","command"]}
751                }
752            },
753            // P1.1: rust-analyzer only emits `experimental/serverStatus`
754            // (the quiescence/readiness signal) when the client
755            // advertises support for it. Servers that don't know the
756            // extension ignore it.
757            "experimental":{"serverStatusNotification":true}
758        },
759        "workspaceFolders":[
760            {
761                "uri": root_uri,
762                "name": workspace_name
763            }
764        ]
765    });
766    if let Some(options) = initialization_options {
767        params["initializationOptions"] = options;
768    }
769    params
770}
771
772fn configured_startup_grace() -> Option<Duration> {
773    let millis = std::env::var("CODELENS_LSP_STARTUP_GRACE_MS")
774        .ok()
775        .and_then(|value| value.trim().parse::<u64>().ok())
776        .unwrap_or(0)
777        .min(10_000);
778    (millis > 0).then(|| Duration::from_millis(millis))
779}
780
781impl Drop for LspSession {
782    fn drop(&mut self) {
783        let _ = self.shutdown();
784        let deadline = Instant::now() + Duration::from_millis(250);
785        while Instant::now() < deadline {
786            match self.child.try_wait() {
787                Ok(Some(_status)) => return,
788                Ok(None) => thread::sleep(Duration::from_millis(10)),
789                Err(_) => break,
790            }
791        }
792        let _ = self.child.kill();
793        let _ = self.child.wait();
794    }
795}
796
797#[cfg(test)]
798mod warm_probe_tests {
799    use super::*;
800
801    fn temp_project() -> ProjectRoot {
802        let dir = std::env::temp_dir().join(format!(
803            "codelens-warmprobe-{}-{}",
804            std::process::id(),
805            std::time::SystemTime::now()
806                .duration_since(std::time::UNIX_EPOCH)
807                .unwrap()
808                .as_nanos()
809        ));
810        std::fs::create_dir_all(&dir).unwrap();
811        ProjectRoot::new(dir.to_str().unwrap()).unwrap()
812    }
813
814    #[test]
815    fn has_warm_session_is_false_and_non_spawning_when_cold() {
816        let pool = LspSessionPool::new(temp_project());
817        // A cold language must probe `false` — no session was ever started.
818        assert!(!pool.has_warm_session("pyright-langserver", &["--stdio".to_owned()]));
819        // ...and the probe must not have spawned anything: the pool stays
820        // empty, preserving the default path's no-cold-start latency contract.
821        assert_eq!(pool.session_count(), 0);
822    }
823
824    #[test]
825    fn warm_session_quiescence_is_none_and_non_spawning_when_cold() {
826        let pool = LspSessionPool::new(temp_project());
827        assert_eq!(
828            pool.warm_session_quiescence("pyright-langserver", &["--stdio".to_owned()]),
829            None,
830            "no live session must report outer None (not 'unknown readiness')"
831        );
832        assert_eq!(pool.session_count(), 0, "readiness probe must not spawn");
833    }
834}
835
836#[cfg(test)]
837mod initialization_options_tests {
838    use super::*;
839
840    #[test]
841    fn rust_analyzer_disables_check_on_save() {
842        assert_eq!(
843            initialization_options_for_command("rust-analyzer"),
844            Some(json!({"checkOnSave": false}))
845        );
846    }
847
848    #[test]
849    fn path_qualified_rust_analyzer_hits_the_same_entry() {
850        // Matching mirrors `is_allowed_lsp_command`: basename, not raw string.
851        assert_eq!(
852            initialization_options_for_command("/opt/homebrew/bin/rust-analyzer"),
853            Some(json!({"checkOnSave": false}))
854        );
855    }
856
857    #[test]
858    fn unknown_servers_get_none() {
859        for command in [
860            "pyright-langserver",
861            "typescript-language-server",
862            "gopls",
863            "clangd",
864            "not-an-lsp",
865        ] {
866            assert_eq!(
867                initialization_options_for_command(command),
868                None,
869                "{command} must not receive initializationOptions"
870            );
871        }
872    }
873
874    #[test]
875    fn initialize_params_omits_options_field_when_none() {
876        let params = initialize_params(Some("file:///tmp/proj/".to_owned()), "proj", None);
877        assert!(
878            params.get("initializationOptions").is_none(),
879            "None must omit the field entirely (no null/empty placeholder)"
880        );
881    }
882
883    #[test]
884    fn initialize_params_attaches_options_when_some() {
885        let options = json!({"checkOnSave": false});
886        let params = initialize_params(
887            Some("file:///tmp/proj/".to_owned()),
888            "proj",
889            Some(options.clone()),
890        );
891        assert_eq!(params.get("initializationOptions"), Some(&options));
892    }
893
894    #[test]
895    fn options_do_not_alter_the_rest_of_the_params() {
896        // Capabilities/rootUri/workspaceFolders must be invariant across
897        // servers — the table only ever adds the one extra field.
898        let root_uri = Some("file:///tmp/proj/".to_owned());
899        let without = initialize_params(root_uri.clone(), "proj", None);
900        let mut with = initialize_params(root_uri, "proj", Some(json!({"checkOnSave": false})));
901        assert!(
902            with.as_object_mut()
903                .expect("params is an object")
904                .remove("initializationOptions")
905                .is_some()
906        );
907        assert_eq!(with, without);
908    }
909}
910
911#[cfg(test)]
912mod server_request_reply_tests {
913    use super::*;
914
915    #[test]
916    fn workspace_configuration_returns_one_null_per_item() {
917        // "Use your defaults" — deterministic and unblocks servers that
918        // wait on the reply (the pre-P1.1 discard path stalled them).
919        let params = json!({"items": [{"section": "rust-analyzer"}, {"section": "python"}]});
920        let reply = server_request_reply_payload("workspace/configuration", Some(&params));
921        assert_eq!(reply, Ok(json!([null, null])));
922    }
923
924    #[test]
925    fn workspace_configuration_with_no_items_returns_empty_array() {
926        let reply = server_request_reply_payload("workspace/configuration", None);
927        assert_eq!(reply, Ok(json!([])));
928    }
929
930    #[test]
931    fn capability_registration_and_progress_create_are_acknowledged() {
932        for method in [
933            "client/registerCapability",
934            "client/unregisterCapability",
935            "window/workDoneProgress/create",
936        ] {
937            assert_eq!(
938                server_request_reply_payload(method, None),
939                Ok(Value::Null),
940                "{method} must be acknowledged, not discarded"
941            );
942        }
943    }
944
945    #[test]
946    fn server_initiated_apply_edit_is_refused() {
947        // Read sessions must never let a server mutate the workspace behind
948        // the caller's back — mutations flow through the verifier-gated
949        // edit transaction only.
950        let reply = server_request_reply_payload("workspace/applyEdit", Some(&json!({"edit": {}})))
951            .expect("applyEdit is answered, not errored");
952        assert_eq!(reply.get("applied"), Some(&json!(false)));
953    }
954
955    #[test]
956    fn unknown_server_request_gets_method_not_found() {
957        let err = server_request_reply_payload("window/showMessageRequest", None)
958            .expect_err("unknown requests must be rejected explicitly");
959        assert_eq!(err.get("code"), Some(&json!(-32601)));
960    }
961
962    /// Live proof that the quiescence signal is actually received (P1.1):
963    /// spawns a real rust-analyzer against a tiny fixture crate, issues
964    /// reference requests (whose read loops harvest `experimental/serverStatus`
965    /// notifications), and asserts the pool eventually reports
966    /// `Some(Some(true))`. Requires rust-analyzer on PATH and several seconds
967    /// of indexing — run manually:
968    /// `cargo test -p codelens-engine --lib quiescence_signal -- --ignored`
969    #[test]
970    #[ignore = "spawns a live rust-analyzer; run manually"]
971    fn quiescence_signal_is_harvested_from_live_rust_analyzer() {
972        use crate::lsp::types::LspRequest;
973
974        let dir = std::env::temp_dir().join(format!(
975            "codelens-quiescence-{}-{}",
976            std::process::id(),
977            std::time::SystemTime::now()
978                .duration_since(std::time::UNIX_EPOCH)
979                .unwrap()
980                .as_nanos()
981        ));
982        std::fs::create_dir_all(dir.join("src")).unwrap();
983        std::fs::write(
984            dir.join("Cargo.toml"),
985            "[package]\nname = \"quiescence_fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
986        )
987        .unwrap();
988        std::fs::write(
989            dir.join("src/lib.rs"),
990            "pub fn target() -> u32 { 41 }\npub fn caller() -> u32 { target() + 1 }\n",
991        )
992        .unwrap();
993        let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
994        let pool = LspSessionPool::new(project);
995
996        let request = LspRequest {
997            command: "rust-analyzer".to_owned(),
998            args: Vec::new(),
999            file_path: "src/lib.rs".to_owned(),
1000            line: 1,
1001            column: 8,
1002            max_results: 10,
1003        };
1004        let deadline = std::time::Instant::now() + Duration::from_secs(60);
1005        let mut quiescence = None;
1006        while std::time::Instant::now() < deadline {
1007            // Each request's read loop drains pending server notifications,
1008            // harvesting the latest serverStatus before returning.
1009            let _ = pool.find_referencing_symbols(&request);
1010            quiescence = pool.warm_session_quiescence("rust-analyzer", &[]);
1011            if quiescence == Some(Some(true)) {
1012                break;
1013            }
1014            thread::sleep(Duration::from_millis(500));
1015        }
1016        assert_eq!(
1017            quiescence,
1018            Some(Some(true)),
1019            "rust-analyzer must report quiescent=true within 60s (signal harvested)"
1020        );
1021    }
1022}