@@ -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