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/<dependency>, does not match outer scope rule: P/<module>".

`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>
This commit is contained in:
arookieofc
2026-11-03 14:39:29 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent c50d212a1d
commit 2161a2ca03
3 changed files with 55 additions and 8 deletions
+1 -1
View File
@@ -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();
+20 -7
View File
@@ -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<JavaLaunchInfo> {
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,
+34
View File
@@ -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.