Skip to content

Commit 72a8c48

Browse files
authored
fix(rspack_watcher): keep mtime baseline persistent across watch() cycles (#14077)
1 parent 3b7e8a9 commit 72a8c48

2 files changed

Lines changed: 144 additions & 12 deletions

File tree

crates/rspack_watcher/src/lib.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,20 @@ impl FsWatcher {
180180
) -> Result<()> {
181181
self.path_manager.update(files, directories, missing)?;
182182

183-
// Record baseline mtimes for all registered files BEFORE starting the disk
184-
// watcher. This must be synchronous to avoid a race where FSEvents delivers
185-
// stale events before the baseline is populated (causing has_mtime_changed
186-
// to find no baseline and let the stale event through).
187-
self.record_file_mtimes();
183+
// Record baseline mtimes for files added in this watch() cycle, BEFORE
184+
// starting the disk watcher. Synchronous so a stale FSEvent delivered
185+
// immediately after subscription still finds a baseline to compare
186+
// against.
187+
//
188+
// Only newly-added files are stat'd here. Files carried across cycles
189+
// already have a baseline that `has_mtime_changed` advanced atomically
190+
// on the previous event; re-stat'ing them on every rewatch would
191+
// (a) cost O(N) syscalls per rebuild on large projects, and
192+
// (b) re-introduce the race fixed by this PR — a user write that
193+
// landed in the gap between aggregate and rewatch would have its
194+
// post-write mtime become the new baseline, and the FSEvent still
195+
// in flight for that write would then be suppressed as a duplicate.
196+
self.record_initial_file_mtimes();
188197

189198
self.scanner.scan(start_time);
190199

@@ -194,15 +203,18 @@ impl FsWatcher {
194203
Ok(())
195204
}
196205

197-
fn record_file_mtimes(&self) {
206+
fn record_initial_file_mtimes(&self) {
198207
let accessor = self.path_manager.access();
199-
let paths: Vec<_> = accessor.files().0.iter().map(|p| p.clone()).collect();
208+
// `.1` is `files.added` — the diff set populated by `update()` and
209+
// cleared by `reset()`. In steady state (no add/remove churn) this
210+
// is empty and the loop is a no-op.
211+
let paths: Vec<_> = accessor.files().1.iter().map(|p| p.clone()).collect();
200212
for path in paths {
201213
if let Ok(mtime) = path
202214
.metadata()
203215
.and_then(|m| m.modified().or_else(|_| m.created()))
204216
{
205-
self.path_manager.set_file_mtime(path, mtime);
217+
self.path_manager.set_file_mtime_if_absent(path, mtime);
206218
}
207219
}
208220
}

crates/rspack_watcher/src/paths.rs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,41 @@ impl PathManager {
204204
}
205205
}
206206

207-
/// Reset the state of the `PathManager`, clearing all tracked paths.
207+
/// Reset the per-`watch()`-call diff state (added / removed sets) without
208+
/// touching the long-lived mtime baselines.
209+
///
210+
/// `file_mtimes` is intentionally NOT cleared here: the mtime baselines
211+
/// are tied to the lifetime of the registered files, not to the lifetime
212+
/// of a single `FsWatcher::watch()` invocation. Each call to `watch()`
213+
/// from rspack's rebuild cycle (aggregate → pause → rebuild → rewatch)
214+
/// must NOT re-snapshot the baseline, otherwise the snapshot can capture
215+
/// a post-user-write mtime and then `has_mtime_changed` silently
216+
/// suppresses the very FSEvent that the user is waiting for. Stale
217+
/// entries for files that have actually been unregistered are pruned by
218+
/// `update()` via `remove_file_mtime`.
208219
pub fn reset(&self) {
209220
self.files.reset();
210221
self.directories.reset();
211222
self.missing.reset();
212-
self.file_mtimes.clear();
213223
}
214224

215-
pub fn set_file_mtime(&self, path: ArcPath, mtime: SystemTime) {
216-
self.file_mtimes.insert(path, mtime);
225+
/// Record an initial baseline mtime for `path`, but only if no baseline
226+
/// already exists. This is the "incremental" form used by
227+
/// `FsWatcher::wait_for_event`: on the first `watch()` call we record
228+
/// mtimes for all newly-registered files; on subsequent `watch()` calls
229+
/// we MUST skip files that already have a baseline, otherwise we risk
230+
/// snapshotting a mtime that the user has just bumped via `writeFile`.
231+
///
232+
/// Use `has_mtime_changed` (which atomically reads-and-updates the
233+
/// baseline) to advance the recorded mtime in response to real events.
234+
pub fn set_file_mtime_if_absent(&self, path: ArcPath, mtime: SystemTime) {
235+
self.file_mtimes.entry(path).or_insert(mtime);
236+
}
237+
238+
/// Drop the baseline for a path that is no longer being watched, so the
239+
/// map does not grow unboundedly across watch cycles.
240+
pub fn remove_file_mtime(&self, path: &ArcPath) {
241+
self.file_mtimes.remove(path);
217242
}
218243

219244
/// Check if a file's mtime has changed from the stored baseline.
@@ -260,6 +285,15 @@ impl PathManager {
260285
PathUpdater::from(directories).update(&self.directories, &self.ignored)?;
261286
PathUpdater::from(missing).update(&self.missing, &self.ignored)?;
262287

288+
// Prune mtime baselines for files no longer being watched so the map
289+
// does not grow unboundedly across `watch()` cycles. `reset()` has
290+
// already cleared `self.files.removed` at the start of this `watch()`
291+
// call, so what we see here is only this cycle's removals.
292+
let removed_files: Vec<ArcPath> = self.files.removed.iter().map(|p| p.clone()).collect();
293+
for path in &removed_files {
294+
self.remove_file_mtime(path);
295+
}
296+
263297
Ok(())
264298
}
265299

@@ -384,4 +418,90 @@ mod tests {
384418
assert!(all_paths.iter().any(|p| p.ends_with(path)));
385419
}
386420
}
421+
422+
/// Regression for the FSEvents stale-event race: simulate two consecutive
423+
/// `FsWatcher::watch()` cycles with a real file write landing between
424+
/// them (the slow-runner case where the FSEvent is in the kernel queue
425+
/// but not yet delivered when the second cycle starts). The baseline
426+
/// for the already-registered file must survive the second `reset()`,
427+
/// so the delayed change event isn't suppressed as stale.
428+
#[test]
429+
fn test_baseline_persists_across_consecutive_watch_cycles() {
430+
use std::{thread::sleep, time::Duration};
431+
432+
use tempfile::NamedTempFile;
433+
434+
let tempfile = NamedTempFile::new().expect("create temp file");
435+
let path = ArcPath::from(tempfile.path());
436+
437+
let pm = PathManager::default();
438+
pm.update(
439+
(std::iter::once(path.clone()), std::iter::empty()),
440+
(std::iter::empty(), std::iter::empty()),
441+
(std::iter::empty(), std::iter::empty()),
442+
)
443+
.expect("register file");
444+
445+
// T0 — first `watch()` cycle records the baseline.
446+
let initial_mtime = tempfile
447+
.path()
448+
.metadata()
449+
.and_then(|m| m.modified())
450+
.expect("read initial mtime");
451+
pm.set_file_mtime_if_absent(path.clone(), initial_mtime);
452+
453+
// T3 — rspack starts a rebuild; `reset()` runs ahead of the next `watch()`.
454+
pm.reset();
455+
456+
// Invariant: `file_mtimes` must survive `reset()`. Without this the
457+
// baseline gets wiped on every watch cycle and the next bullet point
458+
// can no longer hold.
459+
assert_eq!(
460+
pm.file_mtimes.get(&path).map(|v| *v),
461+
Some(initial_mtime),
462+
"file_mtimes must persist across reset()",
463+
);
464+
465+
// T4 — a real write lands while no watcher is attached. The sleep
466+
// covers 1s-resolution filesystems (HFS+, FAT); modern APFS / ext4 /
467+
// NTFS would not need it but this regression must hold everywhere.
468+
sleep(Duration::from_millis(1100));
469+
std::fs::write(tempfile.path(), b"v2").expect("rewrite tempfile");
470+
let post_write_mtime = tempfile
471+
.path()
472+
.metadata()
473+
.and_then(|m| m.modified())
474+
.expect("read post-write mtime");
475+
assert_ne!(
476+
post_write_mtime, initial_mtime,
477+
"test sanity: file mtime must advance after the write",
478+
);
479+
480+
// T5 — second `watch()` cycle reaches `record_initial_file_mtimes`,
481+
// which delegates here. For an already-baselined path it must be a
482+
// no-op so the post-write mtime does NOT overwrite the original.
483+
pm.set_file_mtime_if_absent(path.clone(), post_write_mtime);
484+
assert_eq!(
485+
pm.file_mtimes.get(&path).map(|v| *v),
486+
Some(initial_mtime),
487+
"set_file_mtime_if_absent must not overwrite an existing baseline",
488+
);
489+
490+
// T6 — the delayed FSEvent finally reaches `Trigger::on_event`, which
491+
// calls `has_mtime_changed`. Current disk mtime now differs from the
492+
// preserved baseline, so the event must NOT be suppressed.
493+
assert!(
494+
pm.has_mtime_changed(&path),
495+
"delayed change event must not be suppressed as stale",
496+
);
497+
498+
// `has_mtime_changed` also atomically advances the baseline so a
499+
// subsequent duplicate FSEvent (e.g. re-delivery during rewatch)
500+
// can still be filtered correctly.
501+
assert_eq!(
502+
pm.file_mtimes.get(&path).map(|v| *v),
503+
Some(post_write_mtime),
504+
"has_mtime_changed should advance the baseline on a real change",
505+
);
506+
}
387507
}

0 commit comments

Comments
 (0)