From c99a22c49cbaaa103bdbdb0af78f2cc99db9d1c8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:44:28 +0800 Subject: [PATCH] fix(java): rebuild when a launch's output folders were deleted outside the IDE (#2608) Deleting a module's output folder outside the IDE (mvn clean in a terminal or by an agent) left JDT's incremental build state current, so the build before a launch compiled nothing and Run failed with NoClassDefFoundError. After the incremental build, the launch classpath is checked for folders inside the project that no longer exist; if any, the workspace is built fully once and the classpath resolved again. Folders still missing afterwards are remembered so later launches don't rebuild for them. Refs #2476 --- crates/ide-lsp/src/jdtls.rs | 64 ++++++++++++++++++++++++++++++++++++ crates/ide-lsp/src/server.rs | 4 +++ 2 files changed, 68 insertions(+) diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 37292a2e2..bbf8614c0 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -478,6 +478,17 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath { } } +/// Folders of `cp` inside the project that don't exist: build outputs deleted outside +/// the IDE. Archives and folders outside the project (dependencies) are left out. +fn missing_outputs(cp: &JavaClasspath, project_root: &Path) -> Vec { + cp.class_paths + .iter() + .chain(&cp.module_paths) + .map(PathBuf::from) + .filter(|p| p.starts_with(project_root) && p.extension().is_none() && !p.exists()) + .collect() +} + /// `vscode.java.startDebugSession` answers the port as a number (or numeric string). pub fn parse_port(v: &Value) -> Option { v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).and_then(|p| u16::try_from(p).ok()) @@ -905,6 +916,43 @@ impl LspManager { }; (cp, java_home) }; + // An output folder deleted outside the IDE (`mvn clean` in a terminal) leaves JDT's + // incremental state current: the build compiles nothing and the launch fails with + // NoClassDefFoundError. Rebuild fully, once per folder that stays missing. + let missing = match (build, &cp) { + (Some(_), Some(found)) if !full_build && !build_lock_held => { + let (server, _) = self.jdtls(module, cancel).await?; + let absent = server.java_absent_outputs.lock(); + missing_outputs(found, &self.project_root).into_iter().filter(|p| !absent.contains(p)).collect() + } + _ => Vec::new(), + }; + let (build, cp) = if missing.is_empty() { + (build, cp) + } else { + tracing::info!(?missing, "Java output folders are missing; rebuilding the workspace"); + let rebuilt = self.java_build(module, true, cancel).await?; + if rebuilt == JavaBuildStatus::Cancelled { + return Err(IdeError::cancelled()); + } + let (server, _) = self.jdtls(module, cancel).await?; + let fresh = { + let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?; + self.java_classpath(module, test, cancel).await + }; + let cp = match fresh { + Ok(fresh) => Some(fresh), + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(e) => { + tracing::debug!("java.project.getClasspaths after rebuild: {e}"); + cp + } + }; + if let Some(cp) = &cp { + server.java_absent_outputs.lock().extend(missing_outputs(cp, &self.project_root)); + } + (Some(rebuilt), cp) + }; let (errors, error_count, scoped) = match (build, build_lock_held) { (Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed), _) | (None, true) => { let scope_module = module.to_path_buf(); @@ -1329,6 +1377,22 @@ mod tests { assert_eq!(java_style.project_root, Some("/p/my demo".into())); } + #[test] + fn missing_outputs_are_project_folders_that_no_longer_exist() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("demo"); + let kept = root.join("app/target/classes"); + std::fs::create_dir_all(&kept).unwrap(); + let deleted = root.join("core/target/classes"); + let s = |p: &Path| p.to_string_lossy().into_owned(); + let cp = JavaClasspath { + project_root: None, + class_paths: vec![s(&kept), s(&deleted), s(&root.join("lib/absent.jar")), s(&dir.path().join("elsewhere"))], + module_paths: vec![s(&root.join("api/target/classes"))], + }; + assert_eq!(missing_outputs(&cp, &root), [deleted, root.join("api/target/classes")]); + } + #[test] fn ports_settings_and_status() { assert_eq!(parse_port(&json!(53122)), Some(53122)); diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 09023cb5c..34ccc790f 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -120,6 +120,9 @@ pub struct Server { phase_notify: Notify, transient: Mutex>, pub java_build_lock: Arc>, + /// Classpath folders still missing after a full build (a module without output), so a + /// launch rebuilds for a missing output folder only once. + pub java_absent_outputs: Mutex>, /// JDT retirement waits for launch and initialization cleanup, even before `Ready`. pub startup_lock: Arc>, pub java_autobuild_suspended: std::sync::atomic::AtomicBool, @@ -167,6 +170,7 @@ impl Server { phase_notify: Notify::new(), transient: Mutex::new(HashMap::new()), java_build_lock: Arc::new(tokio::sync::Mutex::new(())), + java_absent_outputs: Mutex::new(Default::default()), startup_lock: Arc::new(tokio::sync::Mutex::new(())), java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), external_project: tokio::sync::Mutex::new(Default::default()),