Skip to main content

strop_engine/editor/lsp/
lifecycle.rs

1//! Attachment admission, publication, trust and server lifetime ownership.
2use super::attach::{AttachKey, AttachRecord};
3use super::*;
4use std::sync::mpsc::channel;
5use strop_core::id::DocumentId;
6
7impl Editor {
8    /// Try to attach a language server for the current buffer. The
9    /// synchronous part only touches in-memory state; discovery
10    /// (config, root, trust, executability) is owned worker work behind the
11    /// replay gate.
12    /// The LSP half of the startup "start services" action: enable
13    /// attach, then attach for the current buffer. A pure state
14    /// transition performed identically live and replayed — the tape
15    /// gates the native discovery inside `lsp_maybe_attach`.
16    pub fn lsp_start_services(&mut self) {
17        self.lsp_state.attach.enabled = true;
18        self.lsp_maybe_attach();
19    }
20
21    pub(crate) fn lsp_maybe_attach(&mut self) {
22        if !self.lsp_state.attach.enabled {
23            return;
24        }
25        if self.cur().remote_metadata().is_some() && !self.remote_window_complete() {
26            // Typed refusal, never silence: a partial/follow window is
27            // not a document a server can be told about.
28            self.message = "lsp unavailable — partial remote window".into();
29            return;
30        }
31        let Some(doc) = self.lsp_current_doc_path() else {
32            return;
33        };
34        // Extensionless/ambiguous headers keep a navigation-bound
35        // context (0049 §4.4); without one there is nothing to attach.
36        let Some(language) = self.lsp_doc_language(self.current(), &doc.path) else {
37            return;
38        };
39        if self
40            .lsp_server_for(self.current(), &doc.path, &language, &doc.filesystem)
41            .is_some()
42        {
43            self.lsp_did_open_current();
44            return;
45        }
46        // Discovery runs only for an unambiguous extension — never
47        // rediscover project commands from /usr/include (0049 §4.7).
48        let Some(ext) = doc
49            .path
50            .extension()
51            .map(|e| format!(".{}", e.to_string_lossy()))
52        else {
53            return;
54        };
55        let Some(language) = registry::language_for_extension(&ext) else {
56            return;
57        };
58        let key = AttachKey {
59            target: doc.filesystem.clone(),
60            language: language.to_string(),
61            path: doc.path.clone(),
62        };
63        match self.lsp_state.attach.refused.get(&key) {
64            // Trust decisions change (`:trust`); re-discover those.
65            Some(attach::AttachDecision::TrustRequired { .. })
66            | Some(attach::AttachDecision::TrustError { .. })
67            // Remote infrastructure failures are transient: the
68            // connection may be back; re-discover.
69            | Some(attach::AttachDecision::RemoteIo { .. }) => {}
70            // Everything else was reported once and stays refused.
71            Some(_) => return,
72            None => {}
73        }
74        if self.lsp_state.attach.pending.contains_key(&key) {
75            return;
76        }
77        let ticket = match self.worker_ids.allocate() {
78            Ok(ticket) => ticket,
79            Err(error) => {
80                self.message = error.message;
81                return;
82            }
83        };
84        self.lsp_state.attach.pending.insert(key, ticket);
85        let args = attach::AttachArgs {
86            ticket,
87            path: doc.path.clone(),
88            language: language.to_string(),
89            target: doc.filesystem.clone(),
90        };
91        // Replay gate: no native config/trust/executability before this
92        // registration (R11).
93        match self.tape.request("lsp.attach", &args) {
94            Ok(true) => self.lsp_spawn_discovery(ticket, doc, ext, language),
95            Ok(false) => {}
96            Err(error) => {
97                self.lsp_state.attach.pending.remove(&args_key(&args));
98                self.message = format!("lsp attach diverged from trace: {error}");
99            }
100        }
101    }
102
103    fn lsp_spawn_discovery(
104        &mut self,
105        ticket: strop_core::worker::WorkerId,
106        doc: ResourceLocation,
107        ext: String,
108        language: &'static str,
109    ) {
110        let place = match doc.filesystem.clone() {
111            Filesystem::Local => attach::DiscoverPlace::Local {
112                abs: doc.path.clone(),
113                cwd: self.cwd.clone(),
114                git_workdir: self.git.as_ref().map(|g| g.workdir().to_path_buf()),
115            },
116            // The remote client is a cheap clone routed to the owned
117            // session actor; the document's lease keeps it connected.
118            Filesystem::Remote(_) => {
119                let Some(file) = self.remote_file().cloned() else {
120                    return;
121                };
122                attach::DiscoverPlace::Remote {
123                    file,
124                    client: self.remote_client(),
125                }
126            }
127            // The container workspace roots at the document's directory;
128            // the id is the canonical inspect identity.
129            Filesystem::Container(id) => attach::DiscoverPlace::Container {
130                root: doc.path.parent().unwrap_or(Path::new("/")).to_path_buf(),
131                id,
132            },
133        };
134        let input = attach::DiscoverInput {
135            ticket,
136            place,
137            ext,
138            language,
139            state_dir: self.state_dir.clone(),
140            xdg: strop_lsp::languages::xdg_path(),
141            transport: self.lsp_state.attach.transport.clone(),
142        };
143        let cancelled = attach::AttachRecord {
144            ticket,
145            server: None,
146            language: language.to_owned(),
147            name: language.to_owned(),
148            root: doc.path.parent().unwrap_or(Path::new("/")).to_owned(),
149            target: doc.filesystem,
150            outcome: attach::AttachDecision::Cancelled,
151            layers: Vec::new(),
152        };
153        let done = self.lsp_state.attach.attach_channel();
154        // Discovery is an owned worker: the remote reads and probes it
155        // performs are cancellable (superseded attach attempts are
156        // cancelled when a newer ticket takes the key).
157        let handle = strop_core::worker::spawn(
158            "strop-lsp-attach",
159            move |outcome| {
160                let record = match outcome {
161                    strop_core::worker::Outcome::Success(record) => record,
162                    strop_core::worker::Outcome::Cancelled(_) => cancelled,
163                    strop_core::worker::Outcome::Failed { failure, .. } => attach::AttachRecord {
164                        outcome: attach::AttachDecision::SpawnFailed {
165                            reason: failure.message,
166                        },
167                        ..cancelled
168                    },
169                };
170                let _ = done.send(record);
171            },
172            move |token| match attach::discover(input, &token) {
173                Some(record) => strop_core::worker::Outcome::Success(record),
174                None => strop_core::worker::Outcome::Cancelled(
175                    strop_core::worker::CancelReason::OwnerClosed,
176                ),
177            },
178        );
179        self.worker_handles.insert(ticket, handle);
180    }
181
182    /// The server placement for a buffer, if one is attached: exact
183    /// language match on the same filesystem target, longest covering
184    /// root wins.
185    /// Resolve the serving (server, root) for a document: the
186    /// navigation-bound context first (0049 §4.2 — the server that
187    /// brought this document here keeps answering inside it), then
188    /// longest-covering-root discovery for unbound ordinary opens.
189    /// Never a scan that picks the first server speaking the language.
190    pub(crate) fn lsp_server_for(
191        &self,
192        document: DocumentId,
193        path: &Path,
194        language: &str,
195        target: &Filesystem,
196    ) -> Option<(ServerId, PathBuf)> {
197        // The maps are the authority: `lsp_failed` drops a dead
198        // server's bindings and carried contexts, so a present entry is
199        // a live context. Replay resolves identically without a client.
200        if let Some(binding) = self.lsp_state.bindings.get(&document) {
201            if binding.target == *target && binding.language == language {
202                return Some((binding.server, binding.root.clone()));
203            }
204        }
205        if let Some(context) = self.lsp_state.jump_contexts.get(&document) {
206            if context.target == *target && context.language == language {
207                return Some((context.server, context.root.clone()));
208            }
209        }
210        let attach = &self.lsp_state.attach;
211        let best = attach
212            .attached
213            .iter()
214            .filter(|a| a.language == language && &a.target == target && path.starts_with(&a.root))
215            .max_by_key(|a| a.root.as_os_str().len())?;
216        Some((best.server, best.root.clone()))
217    }
218
219    pub(crate) fn handle_lsp_attach(&mut self, record: AttachRecord) {
220        trace_attach(&record);
221        self.worker_handles.remove(&record.ticket);
222        let key = self
223            .lsp_state
224            .attach
225            .pending
226            .iter()
227            .find_map(|(key, &owner)| (owner == record.ticket).then(|| key.clone()));
228        let Some(key) = key else {
229            trace::services::rejected("lsp", "attach completion superseded");
230            self.retire_superseded_transport(record.server);
231            return;
232        };
233        self.lsp_state.attach.pending.remove(&key);
234        if key.target != record.target || key.language != record.language {
235            self.retire_superseded_transport(record.server);
236            trace::services::rejected("lsp", "attach result differs from its requested workspace");
237            return;
238        }
239        let attach::AttachRecord {
240            ticket: _,
241            server,
242            language,
243            name,
244            root,
245            target,
246            outcome,
247            layers,
248        } = record;
249        // Malformed layers are diagnosed whatever the outcome (0033
250        // §2): a healthy fallback server must not erase them.
251        self.record_layer_diagnostics(&layers);
252        let warning = layer_suffix(&layers);
253        match outcome {
254            attach::AttachDecision::Cancelled => {}
255            attach::AttachDecision::Attached => {
256                let Some(server) = server else { return };
257                if self.lsp_state.attach.attached.iter().any(|attachment| {
258                    attachment.language == language
259                        && attachment.root == root
260                        && attachment.target == target
261                }) {
262                    self.retire_superseded_transport(Some(server));
263                    self.lsp_did_open_current();
264                    return;
265                }
266                self.lsp_state
267                    .attach
268                    .attached
269                    .retain(|a| !(a.language == language && a.root == root && a.target == target));
270                // The placement exists before any didOpen resolves
271                // against it — live and replayed alike.
272                self.lsp_state.attach.attached.push(attach::Attachment {
273                    language: language.clone(),
274                    root: root.clone(),
275                    server,
276                    target: target.clone(),
277                });
278                match warning {
279                    Some(warning) => self.message = format!("lsp: {warning}"),
280                    None => self.message = format!("lsp: {name} starting"),
281                }
282                let transport = self
283                    .lsp_state
284                    .attach
285                    .transport
286                    .lock()
287                    .ok()
288                    .and_then(|mut table| table.remove(&server));
289                match transport {
290                    Some(attach::LiveTransport { client, rx }) => {
291                        // TUI: forward like every late-attaching server.
292                        if let Some(app_tx) = &self.app_tx {
293                            let tx = app_tx.clone();
294                            std::thread::spawn(move || {
295                                while let Ok(event) = rx.recv() {
296                                    if tx
297                                        .send(crate::editor::events::AppEvent::Lsp(event))
298                                        .is_err()
299                                    {
300                                        break;
301                                    }
302                                }
303                            });
304                            let (_, empty) = channel();
305                            self.lsp_servers.push(LspServer {
306                                id: server,
307                                client: Some(client),
308                                rx: empty,
309                                ready: false,
310                            });
311                        } else {
312                            self.lsp_servers.push(LspServer {
313                                id: server,
314                                client: Some(client),
315                                rx,
316                                ready: false,
317                            });
318                        }
319                    }
320                    None => {
321                        // Replayed server: identity only, replies arrive
322                        // through the injected event stream.
323                        self.lsp_servers.push(LspServer {
324                            id: server,
325                            client: None,
326                            rx: channel().1,
327                            ready: false,
328                        });
329                    }
330                }
331                self.lsp_did_open_current();
332            }
333            decision => {
334                if matches!(
335                    decision,
336                    attach::AttachDecision::TrustRequired { .. }
337                        | attach::AttachDecision::TrustError { .. }
338                ) {
339                    self.lsp_state
340                        .attach
341                        .trust_roots
342                        .insert(key.clone(), root.clone());
343                }
344                let sticky = !matches!(
345                    decision,
346                    attach::AttachDecision::TrustRequired { .. }
347                        | attach::AttachDecision::TrustError { .. }
348                        | attach::AttachDecision::RemoteIo { .. }
349                );
350                let first = self
351                    .lsp_state
352                    .attach
353                    .refused
354                    .insert(key, decision.clone())
355                    .is_none();
356                if sticky && !first {
357                    return;
358                }
359                self.message = match decision {
360                    attach::AttachDecision::NoServer => format!("no language server for {name}"),
361                    attach::AttachDecision::TrustRequired { command } => {
362                        format!("project config wants to run `{command}` — :trust to allow (once)")
363                    }
364                    attach::AttachDecision::TrustError { error } => {
365                        format!("project trust: {error}")
366                    }
367                    attach::AttachDecision::NotExecutable {
368                        command,
369                        reason,
370                        hint,
371                    } => {
372                        format!("lsp: {command} {reason} — {hint}")
373                    }
374                    attach::AttachDecision::SpawnFailed { reason } => {
375                        format!("lsp: {name} could not start — {reason}")
376                    }
377                    attach::AttachDecision::RemoteIo { reason } => {
378                        format!("lsp: remote discovery failed — {reason}")
379                    }
380                    attach::AttachDecision::Attached | attach::AttachDecision::Cancelled => {
381                        unreachable!("matched above")
382                    }
383                };
384                if let Some(warning) = warning {
385                    self.message = format!("{} — {}", self.message, warning);
386                }
387            }
388        }
389    }
390
391    /// Record newly reported malformed-layer diagnostics (0033 §2),
392    /// deduped: every later attach for the same layers is already
393    /// covered.
394    fn record_layer_diagnostics(&mut self, layers: &[strop_lsp::languages::LayerDiagnostic]) {
395        for diagnostic in layers {
396            let state = &mut self.lsp_state.attach;
397            if !state.layer_diagnostics.contains(diagnostic) {
398                state.layer_diagnostics.push(diagnostic.clone());
399            }
400        }
401    }
402
403    /// The first recorded layer diagnostic, when any — readiness and
404    /// later messages must not erase it (0033 §2).
405    pub(super) fn layer_warning(&self) -> Option<String> {
406        layer_suffix(&self.lsp_state.attach.layer_diagnostics)
407    }
408
409    /// A superseded discovery may already have published a live
410    /// transport for its server: retire it so the attempt leaves no
411    /// orphan process and no undrained event stream.
412    fn retire_superseded_transport(&mut self, server: Option<ServerId>) {
413        let Some(server) = server else { return };
414        let transport = self
415            .lsp_state
416            .attach
417            .transport
418            .lock()
419            .ok()
420            .and_then(|mut table| table.remove(&server));
421        if let Some(attach::LiveTransport { client, .. }) = transport {
422            // Joining never blocks the input thread (same policy as
423            // lsp_failed).
424            std::thread::spawn(move || {
425                client.shutdown();
426                client.wait(std::time::Duration::from_secs(2));
427            });
428        }
429    }
430
431    /// Retire remote servers whose workspace no longer has a document:
432    /// closing the last owning remote workspace retires its server
433    /// (0036 RW8) — no orphan ssh, no orphan remote process. Local
434    /// servers keep their session-long lifetime.
435    pub(crate) fn lsp_retire_remote_servers(&mut self) {
436        let retired: Vec<ServerId> = self
437            .lsp_state
438            .attach
439            .attached
440            .iter()
441            .filter(|a| a.target.is_remote())
442            .filter(|a| {
443                let endpoint = match &a.target {
444                    Filesystem::Remote(endpoint) => endpoint,
445                    _ => return false,
446                };
447                !self.docs.iter().any(|(id, document)| {
448                    document.remote_metadata().is_some_and(|source| {
449                        source.file.endpoint() == endpoint
450                            && source.file.path().starts_with(&a.root)
451                            && lsp_language(source.file.path()) == Some(a.language.as_str())
452                            && source.window.is_complete()
453                            && !self.remote_following(id)
454                    })
455                })
456            })
457            .map(|a| a.server)
458            .collect();
459        for server in retired {
460            self.lsp_retire_server(server, "remote workspace closed");
461        }
462    }
463
464    /// Remove one server's placements, bindings and connection with a
465    /// graceful shutdown that never blocks the input thread.
466    fn lsp_retire_server(&mut self, server: ServerId, reason: &str) {
467        // Remove the placements first: the closes below re-enter the
468        // retirement scan, and this server must already be gone from
469        // the tables so the recursion is empty.
470        self.lsp_state
471            .attach
472            .attached
473            .retain(|a| a.server != server);
474        let connection = self
475            .lsp_servers
476            .iter()
477            .position(|s| s.id == server)
478            .map(|index| self.lsp_servers.remove(index));
479        let mut documents: Vec<_> = self
480            .lsp_state
481            .bindings
482            .iter()
483            .filter(|(_, binding)| binding.server == server)
484            .map(|(document, _)| *document)
485            .collect();
486        documents.sort();
487        for document in documents {
488            self.lsp_close_document(document);
489        }
490        if let Some(connection) = connection {
491            if let Some(client) = connection.client {
492                std::thread::spawn(move || {
493                    client.shutdown();
494                    client.wait(std::time::Duration::from_secs(2));
495                });
496            }
497        }
498        trace::services::rejected("lsp", reason);
499    }
500}
501
502impl Editor {
503    pub(crate) fn remote_trust_target(&self) -> Result<strop_workspace::RemoteFile, String> {
504        let doc = self
505            .lsp_current_doc_path()
506            .ok_or("trust requires a file buffer")?;
507        let Filesystem::Remote(endpoint) = doc.filesystem else {
508            return Err("not a remote workspace".into());
509        };
510        let language = lsp_language(&doc.path).ok_or("no language server for this file")?;
511        let key = AttachKey {
512            target: Filesystem::Remote(endpoint.clone()),
513            language: language.to_owned(),
514            path: doc.path,
515        };
516        let root = self
517            .lsp_state
518            .attach
519            .trust_roots
520            .get(&key)
521            .ok_or("no pending remote project trust request")?;
522        strop_workspace::RemoteFile::from_path(endpoint, root.clone())
523            .map_err(|error| error.to_string())
524    }
525}
526
527fn args_key(args: &attach::AttachArgs) -> AttachKey {
528    AttachKey {
529        target: args.target.clone(),
530        language: args.language.clone(),
531        path: args.path.clone(),
532    }
533}
534
535/// Modeline suffix for malformed layers: the first diagnostic's exact
536/// path, plus a count when more follow (0033 §2).
537fn layer_suffix(layers: &[strop_lsp::languages::LayerDiagnostic]) -> Option<String> {
538    let first = layers.first()?;
539    Some(if layers.len() == 1 {
540        first.display()
541    } else {
542        format!("{} (+{} more)", first.display(), layers.len() - 1)
543    })
544}
545
546/// Attach completions reach the structured trace with their outcome
547/// and any malformed-layer diagnostics (0033 §2/§3) — silence is not a
548/// report. Runs at handler entry, before ownership decisions.
549fn trace_attach(record: &attach::AttachRecord) {
550    use strop_trace::{record_with, EventKind};
551    record_with(EventKind::JobFinished, || {
552        let mut value = serde_json::json!({
553            "service": "lsp",
554            "result": "attach",
555            "outcome": record.outcome.label(),
556            "language": record.language,
557            "name": record.name,
558            "server": record.server,
559            "target": record.target.label(),
560            "root": trace::services::NativePath(record.root.clone()),
561            "layers": &record.layers,
562        });
563        match &record.outcome {
564            attach::AttachDecision::NotExecutable {
565                command,
566                reason,
567                hint,
568            } => {
569                value["command"] = serde_json::json!(command);
570                value["reason"] = serde_json::json!(reason);
571                value["hint"] = serde_json::json!(hint);
572            }
573            attach::AttachDecision::SpawnFailed { reason }
574            | attach::AttachDecision::RemoteIo { reason } => {
575                value["reason"] = serde_json::json!(reason);
576            }
577            _ => {}
578        }
579        value
580    });
581}