fix(java): build mixed-language Maven dependencies before Java launch (#659)

Run/Debug of a Java-only module that depends on a Kotlin/Groovy/Scala module in the same reactor launched with stale classes or failed with Unresolved compilation problem, and a successful Maven build was still reported with the old JDT compile errors.

Root cause: the pre-launch external build only checked the launch module's own sources, and Maven shared target/classes with JDT autobuild.

Fix: walk the launch module's reactor dependency closure (transitive, test scope aware) for non-Java sources; when found, run Maven from the reactor root (-pl <module> -am clean compile, test-compile for test launches) and block the launch on failure or cancel with the Maven errors in the console. JDT autobuild is paused while Maven runs and restored afterwards, and the launch uses a per-launch snapshot of the closure's output so JDT cannot overwrite it. A successful Maven build no longer shows the stale JDT warning. Pure Java projects and Gradle keep their previous path; the debugger adapter choice still follows the launch module.

Verified on macOS with single- and multi-module Maven projects (new Kotlin values in Run and Debug, Kotlin error blocks the launch, cancel, pure Java does not invoke Maven). Editor type resolution for Kotlin in Java is handled separately.

Part of #428
This commit is contained in:
l0ng-ai
2026-11-01 15:18:53 +08:00
committed by GitHub
parent 1716f3e062
commit 0c54266d03
6 changed files with 395 additions and 20 deletions
+230 -7
View File
@@ -237,12 +237,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = tops
.iter()
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
.max_by_key(|t| t.dir.len())
.copied()
.unwrap_or(tops[0]);
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
@@ -611,6 +606,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -620,7 +699,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -635,6 +714,14 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -821,6 +908,142 @@ mod tests {
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -48,7 +48,7 @@ parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
tempfile.workspace = true
+99 -11
View File
@@ -5,7 +5,7 @@ use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::JavaLaunchInfo;
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -39,23 +39,42 @@ async fn resolve_launch(
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
tokio::task::spawn_blocking(move || {
let model = load(&project.root, &project.build_cache_dir(), &settings);
let external = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let delegated = external
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
(model, external, delegated)
})
.await
.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 lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let built = if build && compile::has_non_java_sources(&module) {
compile::build_for_launch(
&project.root,
&load(&project.root, &project.build_cache_dir(), &settings),
&module,
test,
&cancel,
)?
let _external_build = external_build;
let built = if external {
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
} else {
Vec::new()
};
let built = if delegated {
let (snapshot, copies) = snapshot_outputs(&mut info, &built, &cancel)?;
project.java_launch_outputs.lock().push(snapshot);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
copies
} else {
built
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
@@ -103,6 +122,35 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn snapshot_outputs(
info: &mut JavaLaunchInfo,
built: &[PathBuf],
cancel: &CancellationToken,
) -> IdeResult<(tempfile::TempDir, Vec<PathBuf>)> {
let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?;
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let dest = snapshot.path().join(index.to_string());
ide_vfs::copy(source, &dest)?;
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
}
if !modular {
copies.push(dest);
}
}
Ok((snapshot, copies))
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
host.require_trust(window)?;
@@ -132,3 +180,43 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
let app = root.path().join("app/target/classes");
let lib = root.path().join("lib/target/classes");
for dir in [&app, &lib] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
}
let mut info = JavaLaunchInfo {
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
module_paths: vec![lib.to_string_lossy().into_owned()],
..Default::default()
};
let (snapshot, paths) =
snapshot_outputs(&mut info, &[app.clone(), lib.clone()], &CancellationToken::new()).unwrap();
assert_eq!(paths, [snapshot.path().join("0")]);
assert_eq!(info.class_paths, ["stdlib.jar"]);
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
std::fs::remove_dir_all(lib).unwrap();
for index in ["0", "1"] {
assert_eq!(
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
"Maven bytecode"
);
}
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
}
+4
View File
@@ -42,6 +42,8 @@ pub struct Project {
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
/// Per-project cache directory (build tool sync results…).
pub cache_dir: PathBuf,
/// Launch snapshots stay alive while any JVM of this project may load classes.
pub(crate) java_launch_outputs: Mutex<Vec<tempfile::TempDir>>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -366,6 +368,7 @@ impl Host {
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_outputs: Mutex::new(Vec::new()),
db,
http_history,
agents,
@@ -481,6 +484,7 @@ impl Host {
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_outputs: Mutex::new(Vec::new()),
db,
http_history,
agents,
+37
View File
@@ -189,6 +189,24 @@ pub struct JavaCompileError {
pub message: String,
}
/// Holds off JDT writers while an external compiler builds and snapshots launch outputs.
/// Restores current settings even when preparation fails or is cancelled.
pub struct JavaExternalBuild {
server: Arc<Server>,
run: Running,
_lock: tokio::sync::OwnedMutexGuard<()>,
}
impl Drop for JavaExternalBuild {
fn drop(&mut self) {
self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
let settings = crate::handler::all_settings(&self.server.effective_config());
if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) {
tracing::debug!("jdtls: restoring autobuild: {e}");
}
}
}
/// Error diagnostics sorted by file and position, capped at `limit`, and their total.
pub fn compile_errors(
diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>,
@@ -433,9 +451,28 @@ impl LspManager {
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
}
/// Pause autobuild before resolving the launch classpath. That query takes the
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaExternalBuild> {
let (server, run) = self.jdtls(path, cancel).await?;
let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?;
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
let guard = JavaExternalBuild { server, run, _lock: lock };
let settings = crate::handler::all_settings(&guard.server.effective_config());
guard
.run
.peer
.notify("workspace/didChangeConfiguration", json!({ "settings": settings }))
.map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?;
Ok(guard)
}
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
/// class files are up to date before a launch.
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
let (server, _) = self.jdtls(path, cancel).await?;
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
// The first build can finish the Maven import before ServiceReady is handled.
let (server, run) = self.jdtls(path, cancel).await?;
+24 -1
View File
@@ -99,6 +99,8 @@ pub struct Server {
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
@@ -126,6 +128,8 @@ impl Server {
published_notify: Notify::new(),
idle_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),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
@@ -134,7 +138,13 @@ impl Server {
/// Configuration of the current (or last) instance; the base config before the first launch.
pub fn effective_config(&self) -> LanguageServerConfig {
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone());
if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) {
let mut settings = crate::handler::all_settings(&config);
settings["java"]["autobuild"]["enabled"] = serde_json::json!(false);
config.settings = Some(settings);
}
config
}
pub fn set_effective_config(&self, config: LanguageServerConfig) {
@@ -553,6 +563,19 @@ mod tests {
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
}
#[test]
fn external_build_suspends_only_the_effective_autobuild_setting() {
let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}}));
let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java"));
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
let settings = crate::handler::all_settings(&server.effective_config());
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(settings["java"]["other"], "kept");
server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
assert_eq!(server.effective_config(), config);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_start_reports_its_status_before_waiters_wake() {
// The race behind "jdtls is not ready (Starting)": a request woken by the