From a36c7ed3d500d5aa23c900d39e556852ef71ffbf Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 15:47:48 +0800 Subject: [PATCH] fix(lsp): drop language servers whose workspace root was deleted (#1935) * fix(lsp): drop language servers whose workspace root was deleted (#1726) A per-root server (e.g. a vtsls rooted at a nested tsconfig dir) is spawned with its root as the process working directory. On Windows, once that directory is deleted every spawn fails with os error 267 and the crash backoff keeps respawning it, leaving a permanently 'Crashed' entry in the Language Servers panel and a process tree that can never work again. - ensure_started: skip starting a server whose root no longer exists. - on_instance_closed: after a crash, if the root is gone, drop the server (LanguageServerRemoved) instead of retrying forever. - restart: return a clear error when the panel restart is pressed for a server whose root vanished. - open_for_file_op: skip sampled candidates already deleted on disk. Fixes #1726 * fix(lsp): drop dead-root servers claimed by ensure_started A crash with the root still alive enters the restart backoff; if the root is deleted during that sleep (or an open-file request arrives later), ensure_started used to return early and leave a permanently "Crashed" entry: no instance, no watcher, nothing re-arming it. Claim the Idle server first (begin_start never claims a live one), then drop it when its root is gone, mirroring on_instance_closed. Adds root_deleted_during_crash_backoff_drops_the_server, which fails on the previous code (server never removed) and passes with this fix. --- crates/ide-lsp/src/lib.rs | 31 ++++++++++++++++ crates/ide-lsp/tests/mock_server.rs | 55 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0eebfed4..4b55372e 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -507,8 +507,20 @@ impl LspManager { if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) { return; } + // `begin_start` only claims Idle servers, so a claimed server is never a + // live one. Its root is the process's working directory: starting with + // one deleted from disk fails (`os error 267` on Windows). Such a + // server can never serve again, so drop it instead of leaving a + // permanently "Crashed" entry behind (e.g. the root vanishing during + // the crash-restart backoff, or an open-file request arriving later). let Some(generation) = server.begin_start() else { return }; let Some(me) = self.me.upgrade() else { return }; + if !server.root.is_dir() { + tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display()); + server.announce_down(); + self.forget(std::slice::from_ref(server)); + return; + } let server = server.clone(); self.runtime.spawn(async move { me.run_instance(server, generation).await }); } @@ -705,6 +717,14 @@ impl LspManager { server.announce_down(); return; } + // The workspace root gone means this server can never serve again: + // restarting would respawn with a dead working directory, so the entry + // is dropped instead of staying "Crashed" forever. + if !server.root.is_dir() { + server.announce_down(); + self.forget(std::slice::from_ref(&server)); + return; + } let tail = server.stderr_tail(5); let cfg = server.effective_config(); let mut message = match (code, &cfg.connect) { @@ -2216,6 +2236,10 @@ impl LspManager { }, ); for file in candidate { + // A move may have already removed the sampled entry on disk. + if !file.is_file() { + continue; + } let language = self.languages.detect(&file, None); if !languages.contains(&language) { continue; @@ -3072,6 +3096,13 @@ impl LspManager { .find(|s| s.id == server) .cloned() .ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?; + if !s.root.is_dir() { + return Err(IdeError::unavailable(format!( + "the workspace root of {} is gone: {}", + s.name, + s.root.display() + ))); + } self.stop_instance(&s, false).await; s.reset_for_restart(); self.ensure_started(&s); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 8deea1a2..e47ab84b 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1235,6 +1235,61 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() { assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready); } +/// The whole workspace root deleted: the server can never start again +/// (its cwd is gone), so the crash drops the entry instead of restarting. +#[tokio::test(flavor = "multi_thread")] +async fn a_crash_below_a_deleted_workspace_root_drops_the_server() { + let h = harness(&[("mock", incremental_caps())]); + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + + // A crash on a root that still exists restarts; removing it changes that. + s.peer().kill(); + eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await; + eventually(|| s.launches.load(Ordering::SeqCst) == 2).await; + h.wait_status("mock", ServerStatus::Ready).await; + + std::fs::remove_dir_all(&h.root).unwrap(); + s.peer().kill(); + eventually(|| h.removed("mock")).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(s.launches.load(Ordering::SeqCst), 2, "no restart with a dead working directory"); +} + +/// The root deleted while a crash backoff is still sleeping: the pending +/// restart must drop the server rather than leave a permanently "Crashed" row. +#[tokio::test(flavor = "multi_thread")] +async fn root_deleted_during_crash_backoff_drops_the_server() { + let h = harness(&[("mock", incremental_caps())]); + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + + // Crash with the root alive: "Crashed" is published before the backoff + // sleep, so removing the root here lands inside that window. + s.peer().kill(); + eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await; + std::fs::remove_dir_all(&h.root).unwrap(); + eventually(|| h.removed("mock")).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart with a dead working directory"); +} + +/// A manual restart with the root gone fails instead of spawning into a dead cwd. +#[tokio::test(flavor = "multi_thread")] +async fn restarting_below_a_deleted_workspace_root_fails() { + let h = harness(&[("mock", incremental_caps())]); + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let id = h.mgr.servers()[0].id; + + std::fs::remove_dir_all(&h.root).unwrap(); + let err = h.mgr.restart(id).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + assert!(err.message.contains("workspace root"), "{err:?}"); +} + #[tokio::test(flavor = "multi_thread")] async fn missing_server_reports_once() { let h = harness(&[("ghost", json!({}))]);