diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0eebfed48..4b55372e7 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 8deea1a2a..e47ab84ba 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!({}))]);