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