mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
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:
Generated
+1
@@ -4121,6 +4121,7 @@ dependencies = [
|
||||
name = "ide-build"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user