From f19eacc33647406533bd48802567776d3e175e89 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:08:16 +0800 Subject: [PATCH] fix(java): skip unchanged Maven launch builds (#667) Run/Debug of a Maven module with Kotlin/Groovy/Scala sources rebuilt the module and its reactor dependencies with Maven before every launch, even when nothing had changed. Each successful launch build now records a fingerprint of its inputs (sources and resource roots of the dependency closure, module and parent POMs, imported BOMs, wrapper/.mvn/settings/toolchains, JDK, command line and environment) together with an immutable snapshot of its outputs in the project cache. A later launch with the same fingerprint reuses the snapshot without running Maven; Run and Debug share it, application and test launches are kept apart. Inputs that cannot be resolved reliably (dynamic roots overridden by profiles or -D) always rebuild. A failed, cancelled or concurrently edited build never publishes a reusable record. Superseded snapshots stay available while JVMs of the current session may still load classes and are removed when the project closes; snapshots left by earlier sessions and no longer referenced are cleaned on the first launch. Fixes #530 --- crates/ide-build/src/compile/fingerprint.rs | 321 +++++++++++++++++ crates/ide-build/src/compile/mod.rs | 3 + crates/ide-build/src/maven.rs | 66 ++++ crates/ide-build/src/process.rs | 2 +- crates/ide-host/src/ops/java.rs | 59 ++-- crates/ide-host/src/ops/java/cache.rs | 371 ++++++++++++++++++++ crates/ide-host/src/project.rs | 12 +- 7 files changed, 798 insertions(+), 36 deletions(-) create mode 100644 crates/ide-build/src/compile/fingerprint.rs create mode 100644 crates/ide-host/src/ops/java/cache.rs diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs new file mode 100644 index 000000000..8274003d6 --- /dev/null +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -0,0 +1,321 @@ +//! Metadata fingerprints for successful Maven launch builds. +use super::*; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::time::SystemTime; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchFingerprint { + version: u32, + parameters: String, + files: BTreeMap>, +} + +fn scan( + path: &Path, + files: &mut BTreeMap>, + ancestors: &mut HashSet, + cancel: &CancellationToken, +) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let meta = match fs::metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + files.insert(path.to_path_buf(), None); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + if meta.is_dir() { + let canonical = dunce::canonicalize(path)?; + if !ancestors.insert(canonical.clone()) { + return Err(IdeError::internal("cyclic launch input directory")); + } + // Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions. + files.insert(path.to_path_buf(), None); + for entry in fs::read_dir(path)? { + scan(&entry?.path(), files, ancestors, cancel)?; + } + ancestors.remove(&canonical); + } else if meta.is_file() { + files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?))); + } else { + return Err(IdeError::internal("unsupported launch input file")); + } + Ok(()) +} + +/// `None` means an input cannot be resolved safely: build normally without reuse. +/// All filesystem work belongs on the caller's blocking thread. +pub fn launch_fingerprint( + root: &Path, + model: &BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, +) -> IdeResult> { + let Some(m) = &model.maven else { return Ok(None) }; + let projects = maven_launch_projects(root, m, module, test); + if projects.is_empty() { + return Ok(None); + } + let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) }; + let plan = launch_plan(root, model, module, test)?; + let mut environment: BTreeMap = std::env::vars() + .map(|(key, value)| (key.to_ascii_uppercase(), value)) + .filter(|(key, _)| { + key.starts_with("MAVEN_") + || key.starts_with("JAVA_") + || key.starts_with("JDK_") + || matches!( + key.as_str(), + "PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME" + ) + }) + .collect(); + // Explicit step values override the inherited environment just as they do in Command. + for step in &plan.steps { + environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone()))); + for dir in step.cwd.ancestors() { + inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]); + if dir == root { + break; + } + } + let program = process::program_path(&step.program, &step.env); + inputs.push(program.clone()); + // Maven installed via a symlink still reads conf from its distribution. + if let Ok(program) = dunce::canonicalize(program) + && let Some(home) = program.parent().and_then(Path::parent) + { + for conf in ["conf", "libexec/conf"] { + inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]); + } + } + if !environment.contains_key("JAVA_HOME") { + let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env); + inputs.push(java.clone()); + if let Ok(java) = dunce::canonicalize(java) + && let Some(home) = java.parent().and_then(Path::parent) + { + inputs.push(home.join("release")); + } + } + for (i, arg) in step.args.iter().enumerate() { + if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains") + && let Some(file) = step.args.get(i + 1) + { + inputs.push(step.cwd.join(file)); + } + for prefix in ["--settings=", "--global-settings=", "--toolchains="] { + if let Some(file) = arg.strip_prefix(prefix) { + inputs.push(step.cwd.join(file)); + } + } + } + } + if let Some(home) = dirs::home_dir() { + inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]); + } + for key in ["MAVEN_HOME", "M2_HOME"] { + if let Some(home) = environment.get(key) { + inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]); + } + } + if let Some(home) = environment.get("JAVA_HOME") { + inputs.extend([ + Path::new(home).join("release"), + Path::new(home).join("lib/modules"), + Path::new(home).join("lib/tools.jar"), + Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }), + ]); + } + let steps: Vec<_> = + plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect(); + // Profiles and plugins can read arbitrary environment variables. Keep a signature + // of the inherited environment without persisting unrelated credentials. + let inherited: BTreeMap<_, _> = std::env::vars_os().collect(); + let mut inherited_hash = std::hash::DefaultHasher::new(); + inherited.hash(&mut inherited_hash); + let parameters = + serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects)) + .map_err(|e| IdeError::internal(e.to_string()))?; + let mut files = BTreeMap::new(); + for path in inputs { + scan(&path, &mut files, &mut HashSet::new(), cancel)?; + } + // Store project inputs relative to the project, including each file's relative path. + let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect(); + Ok(Some(LaunchFingerprint { version: 1, parameters, files })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, value: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, value).unwrap(); + } + + fn fixture(root: &Path) -> BuildModel { + write( + root, + "pom.xml", + r#"demoparent1 + pomlibapp"#, + ); + write( + root, + "lib/pom.xml", + r#"demoparent1 + libcodeconf"#, + ); + write( + root, + "app/pom.xml", + r#"demoparent1 + appdemolib1"#, + ); + write(root, "lib/src/main/kotlin/Channel.kt", "value"); + write(root, "app/src/main/resources/app.properties", "key=value"); + write(root, "lib/conf/custom.properties", "custom=value"); + write(root, "lib/code/Custom.java", "class Custom {}"); + BuildModel { maven: Some(maven::load(root)), ..Default::default() } + } + + fn fingerprint(root: &Path, model: &BuildModel) -> Option { + launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap() + } + + #[test] + fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() { + for rel in [ + "lib/src/main/kotlin/Channel.kt", + "app/src/main/resources/app.properties", + "lib/conf/custom.properties", + "lib/code/Custom.java", + "app/pom.xml", + "lib/pom.xml", + "pom.xml", + ".mvn/maven.config", + "lib/src/main/groovy/New.groovy", + "app/src/test/scala/Test.scala", + ] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let before = fingerprint(root, &model); + assert!(before.is_some()); + assert_eq!(before, fingerprint(root, &model), "unchanged {rel}"); + let serialized = serde_json::to_vec(&before).unwrap(); + assert_eq!(before, serde_json::from_slice(&serialized).unwrap()); + let value = if rel.ends_with("pom.xml") { + format!("{}", fs::read_to_string(root.join(rel)).unwrap()) + } else { + "changed input".to_owned() + }; + write(root, rel, &value); + let changed = fingerprint(root, &model); + assert_ne!(before, changed, "changed/added {rel}"); + let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap(); + file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap(); + let touched = fingerprint(root, &model); + assert_ne!(changed, touched, "mtime-only {rel}"); + fs::remove_file(root.join(rel)).unwrap(); + assert_ne!(touched, fingerprint(root, &model), "deleted {rel}"); + } + } + + #[test] + fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut model = fixture(root); + let before = fingerprint(root, &model); + write(root, "lib/target/classes/Channel.class", "JDT stub"); + write(root, "app/target/classes/Main.class", "JDT stub"); + assert_eq!(before, fingerprint(root, &model)); + let mut previous = before.clone(); + for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] { + model.maven.as_mut().unwrap().projects[0].options = vec![option.into()]; + let current = fingerprint(root, &model); + assert_ne!(previous, current, "{option}"); + previous = current; + } + model.maven.as_mut().unwrap().projects[0].options.clear(); + model.maven.as_mut().unwrap().projects[0] + .env + .insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned()); + let jdk = fingerprint(root, &model); + assert_ne!(before, jdk); + write(root, "jdk/release", "JAVA_VERSION=21"); + assert_ne!(jdk, fingerprint(root, &model)); + model.maven.as_mut().unwrap().projects[0].env.clear(); + let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new()) + .unwrap() + .unwrap(); + assert_ne!(before, Some(test)); + write(root, "mvnw", "wrapper changed"); + assert_ne!(before, fingerprint(root, &model)); + } + + #[test] + fn unresolved_roots_and_cancel_never_claim_up_to_date() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + write( + root, + "lib/pom.xml", + "lib${unknown}/src", + ); + assert!( + launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new()) + .unwrap() + .is_none() + ); + let model = fixture(root); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err()); + } + #[test] + fn external_parent_and_selected_settings_files_invalidate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let repo = dir.path().join("repository"); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + write( + &root, + "pom.xml", + "demoparent1app", + ); + let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone())); + let settings = dir.path().join("custom-settings.xml"); + fs::write(&settings, "").unwrap(); + model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()]; + let get = + || launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap(); + let before = get(); + assert!(before.is_some()); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + let parent = get(); + assert_ne!(before, parent); + fs::write(settings, "dev") + .unwrap(); + assert_ne!(parent, get()); + } +} diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index b674e1adf..9cd0ea26f 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -3,8 +3,11 @@ //! background with cancellation and parses the compiler output into messages //! for the Build tool window, the Problems view and the editor. +mod fingerprint; mod parse; +pub use fingerprint::{LaunchFingerprint, launch_fingerprint}; + use crate::process::{self, Stream}; use crate::{BuildModel, maven}; use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity}; diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 2c0e3f8fb..7f2561003 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -601,6 +601,72 @@ impl Resolver { } } +/// Files and source/resource trees consumed by a launch build. Unresolved custom +/// roots disable reuse rather than silently omitting an input. +pub(crate) fn launch_inputs( + root: &Path, + projects: &[&MavenProject], + local_repo: Option, +) -> Option> { + fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec) -> Option<()> { + let input = + matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir") + || (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs") + && matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir")) + || (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory") + || (parent == "filters" && el.name == "filter"); + // Profile activation may depend on the presence of an otherwise unrelated file. + let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing"); + if input || activation { + // Other properties can be overridden by -D, profiles or settings. + // Without Maven's effective roots, conservatively disable reuse for them. + let value = el + .text + .trim() + .replace("${project.basedir}", &dir.to_string_lossy()) + .replace("${basedir}", &dir.to_string_lossy()); + if value.contains("${") { + return None; + } + if !value.is_empty() { + out.push(dir.join(value)); + } + } + for child in &el.children { + roots(child, &el.name, dir, out)?; + } + Some(()) + } + let mut resolver = Resolver::new(local_repo.or_else(default_local_repo)); + let mut out = Vec::new(); + for project in projects { + let dir = root.join(&project.dir); + // Include all conventional language/resource roots, including nonstandard files under src. + out.push(dir.join("src")); + let pom = root.join(&project.pom); + resolver.context(&pom, 0); + let mut path = pom; + let mut seen = HashSet::new(); + loop { + let canonical = dunce::canonicalize(&path).ok()?; + if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH { + return None; + } + out.push(path.clone()); + let text = std::fs::read_to_string(&path).ok()?; + roots(&xml::parse(&text)?, "", &dir, &mut out)?; + let raw = resolver.pom(&path)?; + let Some(parent) = raw.parent else { break }; + path = resolver.parent_path(path.parent()?, &parent)?; + } + } + // Also track imported BOMs read while resolving inheritance. + out.extend(resolver.poms.into_keys()); + out.sort(); + out.dedup(); + Some(out) +} + // ---------------------------------------------------------------- plugins /// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`. diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index b598eb791..8177089f0 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) { /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. -fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { +pub(crate) fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { let path = std::path::Path::new(program); if !path.is_relative() || path.components().count() > 1 { return path.to_path_buf(); diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 6e334ca73..47beb38d5 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -1,4 +1,6 @@ //! Java launch support: classpath / JDK resolution and the java-debug server (jdtls). +pub(crate) mod cache; + use crate::Host; use crate::rpc::Dispatcher; @@ -44,10 +46,13 @@ async fn resolve_launch( let settings = settings.clone(); let module = module.clone(); tokio::task::spawn_blocking(move || { + project.java_launch_snapshots.lock().initialize(); 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)); + let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); + let delegated = build + && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)) + && (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module)); + let external = non_java || delegated; (model, external, delegated) }) .await @@ -60,20 +65,24 @@ async fn resolve_launch( let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { 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); + let outputs = project.java_launch_snapshots.lock().build( + &project.root, + &model, + &module, + test, + ide_sdk::apply::maven_local_repository(&project.root, &settings), + &cancel, + )?; + let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); info.build = Some(JavaBuildStatus::Succeeded); info.errors.clear(); info.error_count = 0; copies + } else if external { + compile::build_for_launch(&project.root, &model, &module, test, &cancel)? } else { - built + Vec::new() }; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { @@ -122,19 +131,10 @@ 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)> { - let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?; +fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec { 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 dest = snapshot.join(index.to_string()); let original = source.to_string_lossy(); info.class_paths.retain(|p| p != original.as_ref()); let mut modular = false; @@ -148,7 +148,7 @@ fn snapshot_outputs( copies.push(dest); } } - Ok((snapshot, copies)) + copies } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). @@ -199,8 +199,11 @@ mod tests { 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(); + let snapshot = tempfile::tempdir().unwrap(); + for (i, source) in [&app, &lib].iter().enumerate() { + ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap(); + } + let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path()); 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()]); @@ -212,11 +215,5 @@ mod tests { "Maven bytecode" ); } - let cancel = CancellationToken::new(); - cancel.cancel(); - assert_eq!( - snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind, - ide_core::error::ErrorKind::Cancelled - ); } } diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs new file mode 100644 index 000000000..422ade156 --- /dev/null +++ b/crates/ide-host/src/ops/java/cache.rs @@ -0,0 +1,371 @@ +//! Immutable Maven outputs retained in the project cache, including across close/reopen. +use ide_build::compile::{self, LaunchFingerprint}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct Outputs { + fingerprint: Option, + pub snapshot: PathBuf, + pub originals: Vec, +} + +impl Outputs { + fn reusable(&self, fingerprint: &Option, snapshots: &Path) -> bool { + fingerprint.is_some() + && self.fingerprint == *fingerprint + && self.snapshot.parent() == Some(snapshots) + && !self.originals.is_empty() + && (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir()) + } +} + +fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option { + Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test { + "test.json" + } else { + "main.json" + })) +} + +pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool { + [false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file())) +} + +/// Owned by Project: running JVMs may still load superseded outputs until it drops. +pub(crate) struct Session { + cache: PathBuf, + initialized: bool, + unreferenced: Vec, +} + +impl Session { + pub(crate) fn new(cache: PathBuf) -> Self { + Self { cache, initialized: false, unreferenced: Vec::new() } + } + + pub(super) fn initialize(&mut self) { + if !self.initialized { + self.initialized = true; + if let Err(error) = sweep(&self.cache) { + tracing::warn!(%error, "Cannot clean previous Java launch snapshots"); + } + } + } + + pub(super) fn build( + &mut self, + root: &Path, + model: &ide_build::BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, + ) -> IdeResult { + self.initialize(); + let cache = &self.cache; + let record = + record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; + let snapshots = cache.join("java-launch/snapshots"); + let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) { + Ok(value) => Ok(value), + Err(e) if cancel.is_cancelled() => Err(e), + Err(e) => { + tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding"); + Ok(None) + } + }; + let before = fingerprint()?; + let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Some(outputs) = previous.as_ref() + && outputs.reusable(&before, &snapshots) + { + tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); + return Ok(previous.unwrap()); + } + // Retain a non-reusable record even on failure, so removing the final mixed + // source still delegates to Maven to clean its old classes on the next launch. + fs::create_dir_all(record.parent().unwrap())?; + fs::write(&record, b"null")?; + if let Some(previous) = previous { + self.unreferenced.push(previous.snapshot); + } + let originals = compile::build_for_launch(root, model, module, test, cancel)?; + fs::create_dir_all(&snapshots)?; + let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep(); + // Retain every new snapshot even if copying or publishing its record fails. + self.unreferenced.push(snapshot.clone()); + for (i, source) in originals.iter().enumerate() { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + ide_vfs::copy(source, &snapshot.join(i.to_string()))?; + } + let after = fingerprint()?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut outputs = Outputs { fingerprint: None, snapshot, originals }; + if before.is_some() && before == after { + outputs.fingerprint = before; + fs::create_dir_all(record.parent().unwrap())?; + let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?; + serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?; + file.persist(&record).map_err(|e| IdeError::from(e.error))?; + self.unreferenced.retain(|path| path != &outputs.snapshot); + } + Ok(outputs) + } +} + +impl Drop for Session { + fn drop(&mut self) { + // Recheck all records: another module or launch mode may reference a path. + if let Ok(referenced) = referenced_snapshots(&self.cache) { + let snapshots = self.cache.join("java-launch/snapshots"); + for path in &self.unreferenced { + remove_unreferenced(path, &snapshots, &referenced); + } + } + } +} + +fn referenced_snapshots(cache: &Path) -> std::io::Result> { + let mut referenced = HashSet::new(); + let records = cache.join("java-launch/records"); + if !records.try_exists()? { + return Ok(referenced); + } + let mut pending = vec![records]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + pending.push(entry.path()); + } else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") { + let outputs: Option = serde_json::from_slice(&fs::read(entry.path())?)?; + if let Some(outputs) = outputs + && let Ok(path) = fs::canonicalize(outputs.snapshot) + { + referenced.insert(path); + } + } + } + } + Ok(referenced) +} + +fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet) { + // Only real launch-* directories directly inside snapshots, never symlinks. + if path.parent() != Some(snapshots) + || !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-")) + || !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir()) + { + return; + } + if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots)) + && path.parent() == Some(snapshots.as_path()) + && !referenced.contains(&path) + && let Err(error) = fs::remove_dir_all(&path) + { + tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot"); + } +} + +fn sweep(cache: &Path) -> std::io::Result<()> { + let referenced = referenced_snapshots(cache)?; + let snapshots = cache.join("java-launch/snapshots"); + if snapshots.try_exists()? { + for entry in fs::read_dir(&snapshots)? { + remove_unreferenced(&entry?.path(), &snapshots, &referenced); + } + } + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn fixture(root: &Path) -> ide_build::BuildModel { + fs::write( + root.join("pom.xml"), + r#"demoapp1"#, + ) + .unwrap(); + let script = root.join("mvnw"); + fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir_all(root.join("src/main/kotlin")).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap(); + ide_build::load(root, &root.join("cache"), None) + } + + #[test] + fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let mut build = || session.build(root, &model, root, false, None, &cancel); + let first = build().unwrap(); + let snapshot = first.snapshot.clone(); + drop(first); + // Dropping an Outputs value leaves the persisted snapshot reusable. + let second = build().unwrap(); + assert_eq!(snapshot, second.snapshot); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1); + fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap(); + assert_eq!(snapshot, build().unwrap().snapshot); + assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first"); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let updated = build().unwrap(); + assert_ne!(snapshot, updated.snapshot); + assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated"); + assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it"); + fs::remove_dir_all(updated.snapshot.join("0")).unwrap(); + let rebuilt = build().unwrap(); + assert_ne!(rebuilt.snapshot, updated.snapshot); + fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap(); + assert!(build().is_err()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + fs::remove_file(root.join("src/main/kotlin/broken")).unwrap(); + assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success"); + cancel.cancel(); + assert!(build().is_err()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5); + } + #[test] + fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let second = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + // Another mode can keep a superseded snapshot alive. + let test_record = cache.join("java-launch/records/nested/test.json"); + fs::create_dir_all(test_record.parent().unwrap()).unwrap(); + fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap(); + drop(session); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + let mut session = Session::new(cache.clone()); + let reused = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_eq!(reused.snapshot, second.snapshot); + fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap(); + let third = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(second.snapshot.is_dir()); + drop(session); + assert!(!second.snapshot.exists()); + assert!(first.snapshot.is_dir()); + assert!(third.snapshot.is_dir()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3); + } + + #[test] + fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("cache"); + let snapshots = cache.join("java-launch/snapshots"); + let retained = snapshots.join("launch-retained"); + let orphan = snapshots.join("launch-orphan"); + let other = snapshots.join("other"); + let nested = other.join("launch-nested"); + let outside = dir.path().join("launch-outside"); + for path in [&retained, &orphan, &nested, &outside] { + fs::create_dir_all(path).unwrap(); + } + fs::write(outside.join("classes"), "keep").unwrap(); + std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap(); + fs::write(snapshots.join("launch-file"), "keep").unwrap(); + let record = cache.join("java-launch/records/deep/module/test.json"); + fs::create_dir_all(record.parent().unwrap()).unwrap(); + let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() }; + fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); + let mut session = Session::new(cache); + session.initialize(); + assert!(!orphan.exists()); + assert!(retained.is_dir()); + assert!(nested.is_dir()); + assert!(outside.join("classes").is_file()); + assert!(snapshots.join("launch-link").is_symlink()); + assert!(snapshots.join("launch-file").is_file()); + // Initialization must not sweep snapshots still in use by this session. + fs::create_dir_all(&orphan).unwrap(); + session.initialize(); + assert!(orphan.is_dir()); + session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]); + drop(session); + assert!(outside.join("classes").is_file()); + assert!(other.join("launch-nested").is_dir()); + } + + #[test] + fn failed_record_publish_retains_snapshot_until_session_drop() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err()); + assert_eq!(session.unreferenced.len(), 1); + let snapshot = session.unreferenced[0].clone(); + assert!(snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!snapshot.exists()); + } + + #[test] + fn edits_during_build_do_not_publish_a_success_record() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap(); + assert!(outputs.fingerprint.is_none()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + assert!(outputs.snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!outputs.snapshot.exists()); + } + #[test] + fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap(); + assert!(!compile::launch_has_non_java_sources(root, &model, root, false)); + assert!(has_record(root, &cache, root)); + assert!(session.build(root, &model, root, false, None, &cancel).is_err()); + assert!(has_record(root, &cache, root)); + fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap(); + let restored = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_ne!(first.snapshot, restored.snapshot); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index caf5afea0..e3d868e21 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -42,8 +42,8 @@ pub struct Project { pub detected_roots: Arc>>, /// 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>, + /// Superseded launch snapshots retained until this project session ends. + pub(crate) java_launch_snapshots: Mutex, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. @@ -368,7 +368,9 @@ impl Host { syntax, detected_roots, cache_dir: self.data_dir.join("cache").join(&project_key), - java_launch_outputs: Mutex::new(Vec::new()), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents, @@ -484,7 +486,9 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.data_dir.join("cache").join(&project_key), - java_launch_outputs: Mutex::new(Vec::new()), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents,