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.
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-07 15:47:48 +08:00
1 parent 50567473f7
commit a36c7ed3d5
2 files changed
+86

No files matched your search

+31
View File
@@ -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);
+55
View File
@@ -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!({}))]);