diff --git a/crates/ide-lsp/src/caps.rs b/crates/ide-lsp/src/caps.rs index ad84f59e..ff41c949 100644 --- a/crates/ide-lsp/src/caps.rs +++ b/crates/ide-lsp/src/caps.rs @@ -162,7 +162,8 @@ pub fn client_capabilities() -> Value { "callHierarchy": { "dynamicRegistration": false }, "typeHierarchy": { "dynamicRegistration": false } }, - "experimental": {} + // rust-analyzer: report when the project has loaded (`quiescent`). + "experimental": { "serverStatusNotification": true } }) } diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 1c89fd9c..c8b8f9f0 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -234,6 +234,11 @@ impl Handler for ClientHandler { let Some(mgr) = self.mgr.upgrade() else { return }; match method { "$/progress" => self.progress(&mgr, params), + "experimental/serverStatus" => { + if let Some(quiescent) = params.get("quiescent").and_then(Value::as_bool) { + self.server.set_loading(mgr.sink(), !quiescent); + } + } "textDocument/publishDiagnostics" => self.publish_diagnostics(&mgr, params), "window/showMessage" => self.show_message(&mgr, ¶ms), "window/logMessage" => { diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 2ace34ad..556dd1ed 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -18,6 +18,7 @@ mod inspect; pub mod jdtls; mod launch; mod library_source; +mod loading; mod pull; mod scope; mod server; @@ -216,6 +217,15 @@ fn runtime_handle() -> tokio::runtime::Handle { }) } +fn goto_is_empty(r: &Option) -> bool { + match r { + None => true, + Some(lsp::GotoDefinitionResponse::Scalar(_)) => false, + Some(lsp::GotoDefinitionResponse::Array(v)) => v.is_empty(), + Some(lsp::GotoDefinitionResponse::Link(v)) => v.is_empty(), + } +} + fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError { match e { RpcError::Cancelled => IdeError::cancelled(), @@ -1163,8 +1173,13 @@ impl LspManager { let mut out = Outcome::new(); for t in targets.iter().filter(|t| snap.asks(t, pos)) { let r: IdeResult> = - self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await; + loading::while_loading(&t.server, Some(&cancel), loading::LOADING_WAIT, Option::is_none, || { + self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT) + }) + .await; match r { + // Still loading: no documentation yet, not an error. + Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None), Ok(Some(h)) => { if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) { return Ok(Some(h)); @@ -1254,16 +1269,17 @@ impl LspManager { ) -> IdeResult> { let mut out = Outcome::new(); for t in targets.iter().filter(|t| snap.asks(t, pos)) { - match self - .call::<_, Option>( + let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || { + self.call::<_, Option>( t, method, snap.pos_params(t, pos), cancel, DEFAULT_TIMEOUT, ) - .await - { + }) + .await; + match r { Ok(Some(r)) => { let locs = convert::goto_response(&self.conv(t.enc()), r); let locs = library_sources(snap, pos, locs).await; diff --git a/crates/ide-lsp/src/loading.rs b/crates/ide-lsp/src/loading.rs new file mode 100644 index 00000000..8474ac28 --- /dev/null +++ b/crates/ide-lsp/src/loading.rs @@ -0,0 +1,76 @@ +//! Requests answered while a server is still loading the project. +//! +//! rust-analyzer answers navigation with nothing until it has read the project's +//! files, misses macro-generated items until build scripts and proc macros are +//! built, and withdraws answers (`ContentModified`) when loading changes its +//! data. Such an answer is not final: the request runs again as loading +//! progresses, so ⌘B right after opening a project lands once the server can +//! answer instead of reporting "not found". Only servers that report loading +//! through `experimental/serverStatus` wait; for others nothing changes. + +use crate::server::Server; +use ide_core::error::ErrorKind; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::future::Future; +use std::time::Duration; + +/// How long an explicit request waits for a server that is still loading. +pub(crate) const LOADING_WAIT: Duration = Duration::from_secs(30); +/// Retry interval while it loads, for phases that end without a notification. +const POLL: Duration = Duration::from_secs(3); + +/// Runs `query` on `server`. While the server is loading, an empty answer or a +/// withdrawn one (`ContentModified`; not a cancellation of `cancel`) runs again +/// whenever a loading phase ends, until it answers, loading ends, or `wait` +/// passes; then a still-loading server yields a `TimedOut` error saying so. +pub(crate) async fn while_loading( + server: &Server, + cancel: Option<&CancellationToken>, + wait: Duration, + is_empty: impl Fn(&R) -> bool, + query: F, +) -> IdeResult +where + F: Fn() -> Fut, + Fut: Future>, +{ + let cancelled = || cancel.is_some_and(CancellationToken::is_cancelled); + let deadline = tokio::time::Instant::now() + wait; + loop { + let phase = server.phase_notified(); + tokio::pin!(phase); + phase.as_mut().enable(); + let asked_while_loading = server.is_loading(); + let result = query().await; + let retry = match &result { + Ok(r) => is_empty(r), + Err(e) => e.kind == ErrorKind::Cancelled && !cancelled(), + }; + if !retry || cancelled() || server.running().is_none() { + return result; + } + if !server.is_loading() { + // Loading ended while the server answered from its old state: ask once more. + if asked_while_loading { + continue; + } + return result; + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(IdeError::new(ErrorKind::TimedOut, format!("{} is still loading the project", server.name))); + } + let until = deadline.min(now + POLL); + let stop = async { + match cancel { + Some(c) => c.cancelled().await, + None => std::future::pending().await, + } + }; + tokio::select! { + () = phase => {} + () = tokio::time::sleep_until(until) => {} + () = stop => return Err(IdeError::cancelled()), + } + } +} diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 6d57b887..5018951b 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -110,6 +110,11 @@ pub struct Server { pub published_notify: Notify, /// Woken when the last `$/progress` task ends. idle_notify: Notify, + /// `experimental/serverStatus` (rust-analyzer): `None` until the server sends + /// one, then whether it is still loading the project. + loading: Mutex>, + /// Woken whenever a `$/progress` task ends or the server's loading state changes. + phase_notify: Notify, transient: Mutex>, pub java_build_lock: Arc>, pub java_autobuild_suspended: std::sync::atomic::AtomicBool, @@ -144,6 +149,8 @@ impl Server { published: Mutex::new(HashMap::new()), published_notify: Notify::new(), idle_notify: Notify::new(), + loading: Mutex::new(None), + phase_notify: Notify::new(), transient: Mutex::new(HashMap::new()), java_build_lock: Arc::new(tokio::sync::Mutex::new(())), java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), @@ -389,22 +396,48 @@ impl Server { if self.running().is_none() { return; } - let busy = !self.progress.lock().is_empty(); + let busy = self.is_busy(); self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None); } /// Whether the server reports work in progress (indexing, analysis). pub fn is_busy(&self) -> bool { - !self.progress.lock().is_empty() + !self.progress.lock().is_empty() || *self.loading.lock() == Some(true) + } + + /// Whether the server says it is still loading the project, so an empty + /// answer may not be final. Only `serverStatus` tells: `$/progress` also + /// covers background work (indexing, builds) during which answers are final. + pub fn is_loading(&self) -> bool { + *self.loading.lock() == Some(true) + } + + /// Records `experimental/serverStatus`: `quiescent` false while the project loads. + pub fn set_loading(&self, sink: &SharedSink, loading: bool) { + if self.loading.lock().replace(loading) == Some(loading) { + return; + } + self.refresh_busy_status(sink); + self.phase_ended(); } /// Called after a `$/progress` task ended. pub fn progress_ended(&self) { + self.phase_ended(); + } + + fn phase_ended(&self) { + self.phase_notify.notify_waiters(); if !self.is_busy() { self.idle_notify.notify_waiters(); } } + /// Woken when the next loading phase ends. + pub fn phase_notified(&self) -> tokio::sync::futures::Notified<'_> { + self.phase_notify.notified() + } + /// Waits until no `$/progress` task is active; false on timeout or cancellation. pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool { let deadline = tokio::time::Instant::now() + timeout; @@ -499,6 +532,7 @@ impl Server { } pub fn take_progress(&self) -> Vec<(TaskId, String)> { + *self.loading.lock() = None; self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect() } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 274e1ba7..c1cc65ff 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1009,6 +1009,78 @@ async fn progress_tasks_and_busy_status() { assert!(tasks[2].3); } +/// While a server says it is loading the project (rust-analyzer: `serverStatus` +/// not quiescent) an empty or withdrawn answer is asked again once loading moves +/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait. +#[tokio::test(flavor = "multi_thread")] +async fn navigation_waits_for_a_server_that_is_still_loading() { + #[derive(Clone, Copy, Debug)] + enum Answer { + Empty, + ContentModified, + } + for answer in [Answer::Empty, Answer::ContentModified] { + let h = harness(&[("mock", incremental_caps())]); + let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + let peer = s.peer(); + peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + if let Answer::ContentModified = answer { + s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into())); + } + let mgr = h.mgr.clone(); + let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await }); + eventually(|| !s.requested("textDocument/definition").is_empty()).await; + assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final"); + + s.errors.lock().clear(); + let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}}); + s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r })); + peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap(); + let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap(); + assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}"); + h.wait_status("mock", ServerStatus::Ready).await; + } + + // Hover documentation the same way. + let h = harness(&[("mock", incremental_caps())]); + let (doc, _) = h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let mgr = h.mgr.clone(); + let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await }); + eventually(|| !s.requested("textDocument/hover").is_empty()).await; + s.respond("textDocument/hover", json!({ "contents": "docs" })); + s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap(); + let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap(); + assert!(hover.is_some()); + + // "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check` + // running, and a server without `serverStatus` indexing in the background. + for quiescent in [Some(true), None] { + let h = harness(&[("mock", incremental_caps())]); + let (doc, _) = h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + let peer = s.peer(); + if let Some(q) = quiescent { + peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap(); + } + peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition)) + .await + .unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work")) + .unwrap(); + assert!(defs.is_empty()); + assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn progress_titled_with_an_executable_path_shows_the_program_name() { let h = harness(&[("mock", incremental_caps())]); @@ -3333,7 +3405,8 @@ async fn interactive_capability_is_scoped_to_gopls() { let (doc, _) = h.open("probe.mk", "x\n"); h.wait_status("mock", ServerStatus::Ready).await; let s = h.launcher.state("mock"); - assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({})); + let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"]; + assert!(experimental.get("interactiveResolve").is_none(), "{experimental}"); let form = h .mgr .resolve_command(