diff --git a/Cargo.lock b/Cargo.lock index 5a3e48cb..276aeda0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4121,6 +4121,7 @@ dependencies = [ name = "ide-build" version = "0.1.37" dependencies = [ + "blake3", "dirs 6.0.0", "dunce", "ide-core", diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index bda630cf..0de84af2 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true [dependencies] +blake3.workspace = true dunce.workspace = true ide-core.workspace = true zip.workspace = true diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs index 8274003d..7ed4b923 100644 --- a/crates/ide-build/src/compile/fingerprint.rs +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -2,18 +2,49 @@ use super::*; use std::fs; use std::hash::{Hash, Hasher}; +use std::io::Read; use std::time::SystemTime; +/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only. +type Stamp = (u64, SystemTime, Option); + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LaunchFingerprint { version: u32, parameters: String, - files: BTreeMap>, + /// Per input: a [`Stamp`] for regular files, `None` for + /// directories and inputs that cannot be resolved. The content hash catches external + /// edits that preserve both length and mtime (timestamp-preserving tools, coarse + /// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside + /// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp. + files: BTreeMap>, +} + +/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An +/// unreadable file records a sentinel so one permission problem does not drop the whole +/// fingerprint (that would just force a normal rebuild). +fn content_hash(path: &Path) -> IdeResult { + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()), + Err(e) => return Err(e.into()), + }; + let mut hasher = blake3::Hasher::new(); + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(hasher.finalize().to_hex().to_string()) } fn scan( + root: &Path, path: &Path, - files: &mut BTreeMap>, + files: &mut BTreeMap>, ancestors: &mut HashSet, cancel: &CancellationToken, ) -> IdeResult<()> { @@ -36,11 +67,12 @@ fn scan( // 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)?; + scan(root, &entry?.path(), files, ancestors, cancel)?; } ancestors.remove(&canonical); } else if meta.is_file() { - files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?))); + let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None }; + files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash))); } else { return Err(IdeError::internal("unsupported launch input file")); } @@ -145,11 +177,11 @@ pub fn launch_fingerprint( .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)?; + scan(root, &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 })) + Ok(Some(LaunchFingerprint { version: 2, parameters, files })) } #[cfg(test)] @@ -231,6 +263,25 @@ mod tests { } } + /// An external tool that keeps the byte length and the mtime (timestamp-preserving + /// writes, coarse-granularity filesystems) still changes the content: the fingerprint + /// must differ via the content hash, not via `(length, mtime)`. + #[test] + fn external_edit_preserving_length_and_mtime_still_invalidates() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let source = root.join("lib/src/main/kotlin/Channel.kt"); + let before = fingerprint(root, &model); + assert!(before.is_some()); + let mtime = fs::metadata(&source).unwrap().modified().unwrap(); + // Same 5-byte length, different content. + write(root, "lib/src/main/kotlin/Channel.kt", "valve"); + fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap(); + let after = fingerprint(root, &model); + assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint"); + } + #[test] fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs index 422ade15..3f169c62 100644 --- a/crates/ide-host/src/ops/java/cache.rs +++ b/crates/ide-host/src/ops/java/cache.rs @@ -242,6 +242,29 @@ mod tests { assert!(build().is_err()); assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5); } + /// An external tool (e.g. an AI code editor) rewrites a source file with the same byte + /// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as + /// "unchanged" and would reuse the stale snapshot; the content hash must catch it. + #[test] + fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() { + 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(); + assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first"); + + let source = root.join("src/main/kotlin/Main.kt"); + let mtime = fs::metadata(&source).unwrap().modified().unwrap(); + fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first" + fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap(); + let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild"); + assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other"); + } + #[test] fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 7ecd16a3..cf64493e 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -868,17 +868,18 @@ impl Documents { .then(|| st.path.clone()) }) .collect(); - self.reload_from_disk(&paths, force_clean); + self.reload_from_disk(&paths, force_clean, true); } /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. pub fn on_disk_changed(&self, paths: &[PathBuf]) { - self.reload_from_disk(paths, false); + self.reload_from_disk(paths, false, false); } - fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) { + /// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches. + fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { @@ -887,7 +888,23 @@ impl Documents { } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); - if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) { + let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len; + // Blind scans trust the stamp. OS events are authoritative, but they also + // echo our own saves: a clean document whose stamp matches is re-checked by + // bytes — an external write that preserved both length and mtime differs + // from what saving this buffer would write, our own save's echo does not. + // Native-to-ascii properties documents and dirty documents keep the stamp + // dedup: the former's disk bytes legitimately differ from the buffer text, + // the latter warn once per disk state. + if unchanged + && (!force_clean || st.dirty) + && (dedup + || st.dirty + || st.native_to_ascii + || ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom) + .map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default()) + .unwrap_or(true)) + { return Ok(()); } if st.dirty { diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 52c20e95..794af1cf 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -403,6 +403,46 @@ fn external_changes() { f.docs.on_disk_changed(&[f.root.join("other.txt")]); } +/// An IDE operation that rewrites a file with the same length and mtime still refreshes +/// the clean buffer. +#[test] +fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() { + let f = fixture(); + let path = f.root.join("stamp.txt"); + fs::write(&path, "first").unwrap(); + let id = f.docs.open(&path).unwrap().id; + let mtime = fs::metadata(&path).unwrap().modified().unwrap(); + fs::write(&path, "other").unwrap(); + fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap(); + f.docs.sync_after_disk_change(); + assert_eq!(f.docs.snapshot(id).unwrap().text, "other"); +} + +/// An external write that keeps the byte length and the mtime (timestamp-preserving +/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal: +/// the OS event is still authoritative, so a clean document reloads. +#[test] +fn external_change_preserving_length_and_mtime_reloads_a_clean_document() { + let f = fixture(); + let path = f.root.join("stamp.txt"); + fs::write(&path, "first").unwrap(); // 5 bytes + let id = f.docs.open(&path).unwrap().id; + let mtime = fs::metadata(&path).unwrap().modified().unwrap(); + + fs::write(&path, "other").unwrap(); // 5 bytes, different content + fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + let snap = f.docs.snapshot(id).unwrap(); + assert_eq!(snap.text, "other"); + assert!(!snap.dirty); + + // The reload recorded the new stamp: repeating the same event changes nothing. + let before = f.events.lock().len(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.events.lock().len(), before); + assert_eq!(f.docs.snapshot(id).unwrap().text, "other"); +} + #[test] fn encodings_and_read_only() { let f = fixture();