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
This commit is contained in:
l0ng-ai
2026-11-11 07:44:28 +08:00
committed by GitHub
parent 41262a2878
commit c99a22c49c
2 changed files with 68 additions and 0 deletions
+64
View File
@@ -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<PathBuf> {
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<u16> {
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));
+4
View File
@@ -120,6 +120,9 @@ pub struct Server {
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
/// 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<std::collections::HashSet<PathBuf>>,
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
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()),