From a4156d291066fe1f6e4a1ffd83dd92ae02f0e714 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 08:11:20 +0200 Subject: [PATCH] M3: re-read a library file whose stamp is as recent as the read A library file is cached by its length and modification time. File systems keep the time in coarse ticks, so a same-length rewrite right after a read can keep the stamp, and library.updates then reports no change. A stamp now proves the file unchanged only once its modification time lies at least two seconds before the read; otherwise the file is read again. --- crates/engine/src/library.rs | 33 +++++++++++++++++++++++++++--- crates/engine/src/library_tests.rs | 18 ++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a4..8d3b90fd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -449,9 +449,25 @@ struct Stamp { #[derive(Clone, Debug)] struct Entry { stamp: Option, + /// When the file was read: a stamp only proves the file unchanged once its modification time + /// is safely older than that, since file systems keep the time in coarse ticks. + read_at: Option, result: std::result::Result, String>, } +/// How far a file's modification time must lie before its read for the stamp to be trusted. +const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2); + +impl Entry { + fn still_valid(&self, now: &Stamp) -> bool { + let settled = match (now.modified, self.read_at) { + (Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at), + _ => false, + }; + settled && self.stamp.as_ref() == Some(now) + } +} + /// Library files read by a session, by path (re-read when they change on disk). #[derive(Clone, Debug, Default)] pub struct Cache { @@ -481,10 +497,11 @@ impl Cache { return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024))); } if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp) - && e.stamp.as_ref() == Some(st) + && e.still_valid(st) { return e.result.clone().map_err(EngineError::Other); } + let read_at = now(); let result = match &stamp { Ok(_) => read_file(path).map(Arc::new), Err(e) => Err(e.clone()), @@ -492,7 +509,7 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() }); + self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() }); result.map_err(EngineError::Other) } @@ -506,10 +523,20 @@ impl Cache { if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) { self.entries.clear(); } - self.entries.insert(path.to_string(), Entry { stamp: None, result }); + self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result }); } } +#[cfg(not(target_arch = "wasm32"))] +fn now() -> Option { + Some(std::time::SystemTime::now()) +} + +#[cfg(target_arch = "wasm32")] +fn now() -> Option { + None +} + #[cfg(not(target_arch = "wasm32"))] fn stat(path: &str) -> std::result::Result { let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..77880ecd 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -399,3 +399,21 @@ fn library_survives_saving_with_versions() { healthy(&user); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() { + use std::time::{Duration, SystemTime}; + let read_at = SystemTime::now(); + let entry = |modified: SystemTime| Entry { + stamp: Some(Stamp { len: 10, modified: Some(modified) }), + read_at: Some(read_at), + result: Err("unused".into()), + }; + // Written in the same coarse tick as the read: a same-length rewrite would keep this stamp. + let fresh = entry(read_at); + assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) })); + // Written well before the read: an unchanged stamp means an unchanged file. + let old = read_at - Duration::from_secs(10); + assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) })); + assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) })); +}