From 2161a2ca03876418a3cc18d56609702fade0c8fa Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:39:29 +0800 Subject: [PATCH] fix(lsp): serialize the jdt.ls classpath resolve with the build lock (#946) Running a main class in a multi-module Maven project could fail with "Cannot resolve the classpath: jdtls: Attempted to beginRule: P/, does not match outer scope rule: P/". `java.project.getClasspaths` and `getSettings` ran outside the jdt.ls build lock, so they could overlap a build or an m2e refresh of a sibling module that takes a nested Eclipse scheduling rule. The launch now resolves the classpath and JDK under the same build lock the build commands use. A delegated external build already holds that lock across the launch, so its callers pass `build_lock_held` instead of waiting on it again, which would deadlock. Fixes #933. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/java.rs | 2 +- crates/ide-lsp/src/jdtls.rs | 27 +++++++++++++++++------ crates/ide-lsp/tests/mock_server.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index d1cc0a61..00da1803 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -59,7 +59,7 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? }; let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; - let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 8198a443..3b84f156 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -521,11 +521,15 @@ impl LspManager { } /// Builds (when asked), then resolves the classpath and JDK of the module at `module`. + /// The classpath queries take the module's workspace scheduling rule like the build + /// commands do, so they run under the build lock; pass `build_lock_held` when the caller + /// already holds it (the delegated external build), or the wait would deadlock. pub async fn java_launch( &self, module: &Path, test: bool, build: bool, + build_lock_held: bool, cancel: &CancellationToken, ) -> IdeResult { let build = if build { Some(self.java_build(module, cancel).await?) } else { None }; @@ -538,14 +542,23 @@ impl LspManager { } _ => (Vec::new(), 0), }; - let cp = self.java_classpath(module, test, cancel).await?; - let java_home = match self.java_vm_location(module, cancel).await { - Ok(home) => home, - Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), - Err(e) => { - tracing::debug!("java.project.getSettings: {e}"); + let (cp, java_home) = { + let (server, _) = self.jdtls(module, cancel).await?; + let _lock = if build_lock_held { None - } + } else { + Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?) + }; + let cp = self.java_classpath(module, test, cancel).await?; + let java_home = match self.java_vm_location(module, cancel).await { + Ok(home) => home, + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(e) => { + tracing::debug!("java.project.getSettings: {e}"); + None + } + }; + (cp, java_home) }; Ok(JavaLaunchInfo { build, diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index b33110a2..dc66dd96 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1166,6 +1166,40 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() { assert!(state.notified("textDocument/didOpen").is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn java_launch_serializes_the_classpath_with_the_build_lock() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, _file) = h.open("Main.java", "public class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + state.respond("java/buildWorkspace", json!(1)); + + // A launch with a build re-takes the lock after java_build has released it. + let info = tokio::time::timeout( + Duration::from_secs(30), + h.mgr.java_launch(&h.root, false, true, false, &Default::default()), + ) + .await + .expect("java_launch deadlocked on the build lock") + .unwrap(); + assert!(info.class_paths.is_empty()); + + // The delegated external build holds the lock while java_launch resolves the + // classpath; java_launch must not re-take it, or the launch would deadlock. + let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default())) + .await + .expect("java_launch deadlocked on the delegated external build's lock") + .unwrap(); + drop(guard); + let commands: Vec<_> = state + .requested("workspace/executeCommand") + .iter() + .filter_map(|p| p["command"].as_str().map(str::to_owned)) + .collect(); + assert!(commands.contains(&"java.project.getClasspaths".to_owned())); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started.