fix: detect external file edits that preserve mtime and length (#1582)

An external write that keeps a file's byte length and mtime was missed twice: the Maven launch fingerprint reused the stale class snapshot, and watcher events for clean open documents were dropped by the (mtime, length) stamp check.

The launch fingerprint now records a blake3 content hash for project files (toolchain inputs such as the JDK's lib/modules keep the stamp only) and bumps its version. Watcher events for clean documents compare the disk bytes with what saving the buffer would write; blind focus scans keep the stamp dedup, and forced refreshes after IDE disk operations still reload clean buffers.

Co-authored-by: qtvbwfn <5848026+qtvbwfn@users.noreply.github.com>
This commit is contained in:
Qing
2026-11-06 00:24:56 +08:00
committed by GitHub
co-authored by qtvbwfn
parent af0ffb809e
commit f3fe837a66
6 changed files with 143 additions and 10 deletions
Generated
+1
View File
@@ -4121,6 +4121,7 @@ dependencies = [
name = "ide-build"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-core",
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+57 -6
View File
@@ -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<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
/// 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<PathBuf, Option<Stamp>>,
}
/// `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<String> {
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<PathBuf, Option<(u64, SystemTime)>>,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
ancestors: &mut HashSet<PathBuf>,
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();
+23
View File
@@ -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();
+21 -4
View File
@@ -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 {
+40
View File
@@ -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();