From 56a57d3c7fa4b1ee4604a970816db1a7cdf11f93 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 13:58:21 -0400 Subject: [PATCH] Fix: defer pre-init LSP notifications until the server is Initialized send_notification wrote frames straight to stdin regardless of lifecycle state. At CLI startup the buffer.after-load hook fires did_open while clangd's initialize is still in flight; the LSP spec lets a server discard any notification before the initialize/initialized handshake, and clangd does. The document is then never "added", so every later request fails with `-32602 trying to get AST for non-added document` (and no diagnostics ever appear). Lenient servers (rust-analyzer, gopls) queue internally, which is why the M4.5 arc didn't catch it. Buffer notifications issued while Starting/Initializing on the client and replay them, in issue order, immediately after the `initialized` notification goes out (flush_deferred_notifications, from the initialize-response handler). `initialized`/`exit` are sent directly by the lifecycle handler and never pass through send_notification, so they bypass the gate. The queue is cleared on (re)start_generation since the reattach path re-sends fresh did_opens against the new process. This also makes the stale lsp.lua:211 comment ("the manager queues it cleanly even while starting/initializing") finally true. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lsp.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/lsp.rs b/src/lsp.rs index e87e17f..a15269e 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -666,6 +666,16 @@ pub struct LspClient { /// "response for unknown request id" `ProtocolError`. Mirrors /// `mcp.rs`'s `cancelled_rids`. cancelled_rids: HashSet, + /// Notifications issued before the server reached `Initialized`. + /// The LSP lifecycle requires `initialize` / `initialized` to + /// complete before any other notification; a strict server + /// (clangd) silently discards a pre-init `textDocument/didOpen`, + /// after which every later request fails with + /// `-32602 trying to get AST for non-added document`. These are + /// held in issue order while `Starting` / `Initializing` and + /// replayed the instant the server reaches `Initialized` (right + /// after the `initialized` notification is sent). + deferred_notifications: Vec<(String, Value)>, /// T M4.5 Option B: encoding the server negotiated for `Position` /// `character` counts. Set from the `initialize` response; /// defaults to the LSP spec default (UTF-16) until then. @@ -684,6 +694,7 @@ impl LspClient { attempt: 0, next_restart_at: None, cancelled_rids: HashSet::new(), + deferred_notifications: Vec::new(), position_encoding: PositionEncoding::default(), } } @@ -1193,6 +1204,11 @@ impl LspManager { // space; stale cancelled ids can never collide, so reset to // keep the set from growing across restarts. client.cancelled_rids.clear(); + // Drop notifications buffered against the dead generation: + // they reference its (now-gone) document state, and the + // editor's reattach path issues fresh `did_open`s after the + // new generation finishes initializing. + client.deferred_notifications.clear(); // T M4.7: drop any pending response routes for this server. // Their request ids belong to the previous generation; the // new server starts request id numbering fresh. @@ -1286,9 +1302,7 @@ impl LspManager { .clients .get_mut(&id) .ok_or_else(|| format!("unknown server: {id}"))?; - // Notifications during init are allowed (the spec actually - // *requires* `initialized` during the initializing phase), - // but disallowed after Stopped/Crashed since stdin is gone. + // Disallowed after Stopped/Crashed since stdin is gone. if matches!( client.state, LspClientState::Stopped { .. } | LspClientState::Crashed { .. } @@ -1298,11 +1312,47 @@ impl LspManager { state_label(&client.state) )); } + // Before the `initialize` / `initialized` handshake completes + // the only notification the spec permits is `initialized` + // itself — and that one is sent directly by `handle_response`, + // never through here. Everything else issued this early + // (`textDocument/didOpen` from the editor's attach hook is the + // common one) is buffered and replayed in order once the + // server reaches `Initialized`; sending it now would be + // discarded by a strict server, breaking every later request + // with "non-added document". + if matches!( + client.state, + LspClientState::Starting | LspClientState::Initializing { .. } + ) { + client.deferred_notifications.push((method, params)); + return Ok(()); + } let body = make_notification(&method, params); send_frame_to(&self.supervisor, client, &body)?; Ok(()) } + /// Replay, in issue order, the notifications buffered by + /// [`Self::send_notification`] while `sid` was still initializing. + /// Called once, from the `initialize`-response handler, right + /// after the `initialized` notification is sent. + fn flush_deferred_notifications(&mut self, sid: LspServerId) { + let deferred = self + .clients + .get_mut(&sid) + .map(|c| std::mem::take(&mut c.deferred_notifications)) + .unwrap_or_default(); + if !deferred.is_empty() + && let Some(client) = self.clients.get(&sid) + { + for (method, params) in deferred { + let body = make_notification(&method, params); + let _ = send_frame_to(&self.supervisor, client, &body); + } + } + } + /// The position encoding `sid` negotiated, or the spec default /// (UTF-16) if the server is unknown / not yet initialized. fn position_encoding(&self, sid: LspServerId) -> PositionEncoding { @@ -2326,6 +2376,10 @@ impl LspManager { initialized_at: now, }; } + // The handshake is complete: replay every notification + // buffered while the server was still initializing, after + // the `initialized` notification that went out above. + self.flush_deferred_notifications(sid); self.push_event(sid, now, LspEventKind::Initialized { capabilities: caps }); return; }