Skip to content

Commit ac11621

Browse files
committed
Deduplicate and normalize file watch events
Add mtime and content-hash based deduplication to filter out phantom and duplicate file system events. Normalize event kinds from atomic writes (temp file + rename) so they are treated as content modifications rather than create/remove cycles that trigger unnecessary full rebuilds. This fixes issues on macOS (Create events from atomic writes), Linux (duplicate inotify IN_MODIFY events), and Windows (Remove+Rename sequences from atomic writes).
1 parent b4a1e47 commit ac11621

1 file changed

Lines changed: 141 additions & 4 deletions

File tree

rewatch/src/watcher.rs

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ use anyhow::{Context, Result};
1313
use futures_timer::Delay;
1414
use notify::event::ModifyKind;
1515
use notify::{Config, Error, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
16+
use std::collections::HashMap;
17+
use std::hash::{DefaultHasher, Hasher};
1618
use std::io::{IsTerminal, Read};
1719
use std::path::{Path, PathBuf};
1820
use std::sync::Arc;
1921
use std::sync::Mutex;
2022
use 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)]
2426
enum 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.
65103
fn 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

Comments
 (0)