@@ -13,12 +13,14 @@ use anyhow::{Context, Result};
1313use futures_timer:: Delay ;
1414use notify:: event:: ModifyKind ;
1515use notify:: { Config , Error , Event , EventKind , RecommendedWatcher , RecursiveMode , Watcher } ;
16+ use std:: collections:: HashMap ;
17+ use std:: hash:: { DefaultHasher , Hasher } ;
1618use std:: io:: { IsTerminal , Read } ;
1719use std:: path:: { Path , PathBuf } ;
1820use std:: sync:: Arc ;
1921use std:: sync:: Mutex ;
2022use std:: sync:: atomic:: { AtomicBool , Ordering } ;
21- use std:: time:: { Duration , Instant } ;
23+ use std:: time:: { Duration , Instant , SystemTime } ;
2224
2325#[ derive( Debug , Clone , PartialEq , Eq , Copy ) ]
2426enum CompileType {
@@ -60,6 +62,42 @@ fn matches_filter(path_buf: &Path, filter: &Option<regex::Regex>) -> bool {
6062 filter. as_ref ( ) . map ( |re| !re. is_match ( & name) ) . unwrap_or ( true )
6163}
6264
65+ /// Compute a hash of a file's content for deduplication purposes.
66+ /// Returns None if the file cannot be read.
67+ fn hash_file_content ( path : & Path ) -> Option < u64 > {
68+ let content = std:: fs:: read ( path) . ok ( ) ?;
69+ let mut hasher = DefaultHasher :: new ( ) ;
70+ hasher. write ( & content) ;
71+ Some ( hasher. finish ( ) )
72+ }
73+
74+ /// Populate the mtime cache with all known source files from the build state.
75+ /// This is called after builds complete to ensure we track all source files,
76+ /// so that subsequent Create events (from atomic writes) can be correctly
77+ /// identified as modifications to existing files.
78+ fn populate_mtime_cache ( build_state : & BuildCommandState , last_mtime : & mut HashMap < PathBuf , SystemTime > ) {
79+ for ( _, module) in build_state. build_state . modules . iter ( ) {
80+ if let SourceType :: SourceFile ( ref source_file) = module. source_type {
81+ // Get the package to resolve the full path
82+ if let Some ( package) = build_state. build_state . packages . get ( & module. package_name ) {
83+ // Track implementation file
84+ let impl_path = package. path . join ( & source_file. implementation . path ) ;
85+ if let Ok ( mtime) = impl_path. metadata ( ) . and_then ( |m| m. modified ( ) ) {
86+ last_mtime. insert ( impl_path, mtime) ;
87+ }
88+
89+ // Track interface file if present
90+ if let Some ( ref interface) = source_file. interface {
91+ let iface_path = package. path . join ( & interface. path ) ;
92+ if let Ok ( mtime) = iface_path. metadata ( ) . and_then ( |m| m. modified ( ) ) {
93+ last_mtime. insert ( iface_path, mtime) ;
94+ }
95+ }
96+ }
97+ }
98+ }
99+ }
100+
63101/// Computes the list of paths to watch based on the build state.
64102/// Returns tuples of (path, recursive_mode) for each watch target.
65103fn compute_watch_paths ( build_state : & BuildCommandState , root : & Path ) -> Vec < ( PathBuf , RecursiveMode ) > {
@@ -197,6 +235,17 @@ async fn async_watch(
197235
198236 let mut initial_build = true ;
199237
238+ // Track file mtimes to deduplicate events.
239+ // On macOS, atomic writes (e.g., Node.js writeFile) show up as Create events
240+ // even though the file already existed. We use mtime to detect this and treat
241+ // it as a Modify instead.
242+ let mut last_mtime: HashMap < PathBuf , SystemTime > = HashMap :: new ( ) ;
243+
244+ // Track file content hashes to deduplicate events where mtime changed but
245+ // content didn't. This catches duplicate inotify events on Linux where a
246+ // single file write can generate multiple IN_MODIFY events.
247+ let mut last_content_hash: HashMap < PathBuf , u64 > = HashMap :: new ( ) ;
248+
200249 loop {
201250 if * ctrlc_pressed_clone. lock ( ) . unwrap ( ) || stdin_closed_clone. load ( Ordering :: Relaxed ) {
202251 if show_progress {
@@ -258,7 +307,75 @@ async fn async_watch(
258307 for path in paths {
259308 let path_buf = path. to_path_buf ( ) ;
260309
261- match ( needs_compile_type, event. kind ) {
310+ // Get the current mtime to check if file actually changed
311+ let current_mtime = path_buf. metadata ( ) . ok ( ) . and_then ( |m| m. modified ( ) . ok ( ) ) ;
312+
313+ // Skip events where mtime hasn't changed (phantom events)
314+ if let Some ( mtime) = current_mtime
315+ && last_mtime. get ( & path_buf) == Some ( & mtime)
316+ {
317+ log:: debug!(
318+ "File change (skipped, same mtime): {:?} {:?}" ,
319+ event. kind,
320+ path_buf
321+ ) ;
322+ continue ;
323+ }
324+
325+ // Skip events where mtime changed but file content is identical.
326+ // This catches duplicate inotify events on Linux where a single
327+ // file write can generate multiple IN_MODIFY events that escape
328+ // the debounce window (e.g., one arrives during an ongoing build).
329+ if let Some ( content_hash) = hash_file_content ( & path_buf) {
330+ if last_content_hash. get ( & path_buf) == Some ( & content_hash) {
331+ log:: debug!(
332+ "File change (skipped, same content): {:?} {:?}" ,
333+ event. kind,
334+ path_buf
335+ ) ;
336+ // Update mtime cache to prevent future mtime-based false positives
337+ if let Some ( mtime) = current_mtime {
338+ last_mtime. insert ( path_buf. clone ( ) , mtime) ;
339+ }
340+ continue ;
341+ }
342+ last_content_hash. insert ( path_buf. clone ( ) , content_hash) ;
343+ }
344+
345+ // Normalize event kinds that result from atomic writes
346+ // (temp file + rename) so they are treated as content modifications.
347+ let effective_kind = match event. kind {
348+ // Atomic writes (e.g. Node.js writeFile) show up as Create even
349+ // though the file already existed.
350+ EventKind :: Create ( _) if last_mtime. contains_key ( & path_buf) => {
351+ EventKind :: Modify ( ModifyKind :: Data ( notify:: event:: DataChange :: Content ) )
352+ }
353+ // Rename-based atomic writes show up as Modify(Name).
354+ EventKind :: Modify ( ModifyKind :: Name ( _) ) if last_mtime. contains_key ( & path_buf) => {
355+ EventKind :: Modify ( ModifyKind :: Data ( notify:: event:: DataChange :: Content ) )
356+ }
357+ // On Windows, atomic writes generate Remove(Any) followed by
358+ // Modify(Name(To)). If the file still exists the rename already
359+ // completed — not a real deletion.
360+ EventKind :: Remove ( _) if last_mtime. contains_key ( & path_buf) && current_mtime. is_some ( ) => {
361+ EventKind :: Modify ( ModifyKind :: Data ( notify:: event:: DataChange :: Content ) )
362+ }
363+ _ => event. kind ,
364+ } ;
365+
366+ tracing:: debug!(
367+ original_kind = ?event. kind,
368+ effective_kind = ?effective_kind,
369+ path = %path_buf. display( ) ,
370+ "watcher.file_change"
371+ ) ;
372+
373+ // Update mtime cache
374+ if let Some ( mtime) = current_mtime {
375+ last_mtime. insert ( path_buf. clone ( ) , mtime) ;
376+ }
377+
378+ match ( needs_compile_type, effective_kind) {
262379 (
263380 CompileType :: Incremental | CompileType :: None ,
264381 // when we have a name change, create or remove event we need to do a full compile
@@ -267,9 +384,22 @@ async fn async_watch(
267384 | EventKind :: Create ( _)
268385 | EventKind :: Modify ( ModifyKind :: Name ( _) ) ,
269386 ) => {
387+ // For Remove events, clear from caches
388+ if matches ! ( effective_kind, EventKind :: Remove ( _) ) {
389+ last_mtime. remove ( & path_buf) ;
390+ last_content_hash. remove ( & path_buf) ;
391+ }
270392 // if we are going to do a full compile, we don't need to bother marking
271393 // files dirty because we do a full scan anyway
272- log:: debug!( "received {:?} while needs_compile_type was {needs_compile_type:?} -> full compile" , event. kind) ;
394+ log:: debug!( "received {:?} while needs_compile_type was {needs_compile_type:?} -> full compile" , effective_kind) ;
395+ tracing:: debug!(
396+ reason = "file event requires full compile" ,
397+ effective_kind = ?effective_kind,
398+ original_kind = ?event. kind,
399+ path = ?path_buf,
400+ in_mtime_cache = last_mtime. contains_key( & path_buf) ,
401+ "watcher.full_compile_triggered"
402+ ) ;
273403 needs_compile_type = CompileType :: Full ;
274404 }
275405
@@ -282,7 +412,7 @@ async fn async_watch(
282412 ) => {
283413 // if we are going to compile incrementally, we need to mark the exact files
284414 // dirty
285- log:: debug!( "received {:?} while needs_compile_type was {needs_compile_type:?} -> incremental compile" , event . kind ) ;
415+ log:: debug!( "received {:?} while needs_compile_type was {needs_compile_type:?} -> incremental compile" , effective_kind ) ;
286416 if let Ok ( canonicalized_path_buf) = path_buf
287417 . canonicalize ( )
288418 . map ( StrippedVerbatimPath :: to_stripped_verbatim_path)
@@ -386,6 +516,9 @@ async fn async_watch(
386516 ) ;
387517 }
388518 }
519+
520+ // Populate mtime cache after build completes
521+ populate_mtime_cache ( & build_state, & mut last_mtime) ;
389522 }
390523 needs_compile_type = CompileType :: None ;
391524 initial_build = false ;
@@ -435,6 +568,10 @@ async fn async_watch(
435568 ) ;
436569 }
437570 }
571+
572+ // Populate mtime cache after build completes
573+ populate_mtime_cache ( & build_state, & mut last_mtime) ;
574+
438575 needs_compile_type = CompileType :: None ;
439576 initial_build = false ;
440577 }
0 commit comments