Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion notify/src/fsevent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ unsafe fn callback_impl(

let mut handle_event = false;
for (p, (r, filt)) in &(*info).recursive_info {
if path.starts_with(p) && filt.should_watch(&path) {
if path.starts_with(p) && filt.should_emit_event(&path) {
if *r || &path == p {
handle_event = true;
break;
Expand Down
83 changes: 52 additions & 31 deletions notify/src/inotify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ impl EventLoop {
None => self.paths.get(&event.wd).cloned(),
};

// Look up the filter for the watch descriptor that produced this event.
// For child events, the descriptor belongs to the containing directory,
// while `event.name` identifies the child path.
let watch_filter = self
.paths
.get(&event.wd)
.and_then(|watch_path| self.watches.get(watch_path))
.map(|(_, _, _, _, watch_filter)| watch_filter);
let should_emit_path = match (&path, watch_filter) {
(Some(path), Some(watch_filter)) => {
watch_filter.should_emit_event(path)
}
_ => true,
};

let mut evs = Vec::new();

if event.mask.contains(EventMask::MOVED_FROM) {
Expand Down Expand Up @@ -371,8 +386,12 @@ impl EventLoop {
);
}

for ev in evs {
self.event_handler.handle_event(Ok(ev));
// Apply the emit filter only at dispatch time. The event translation above also
// updates recursive watch bookkeeping, so it must still run for suppressed paths
if should_emit_path {
for ev in evs {
self.event_handler.handle_event(Ok(ev));
}
}
}

Expand Down Expand Up @@ -408,29 +427,43 @@ impl EventLoop {
mut watch_self: bool,
watch_filter: WatchFilter,
) -> Result<()> {
if !watch_filter.should_watch(&path) {
return Ok(());
}

// If the watch is not recursive, or if we determine (by stat'ing the path to get its
// metadata) that the watched path is not a directory, add a single path watch.
if !is_recursive || !metadata(&path).map_err(Error::io_watch)?.is_dir() {
return self.add_single_watch(path, false, true, WatchFilter::accept_all());
return self.add_single_watch(path, false, watch_self, watch_filter);
}

// Recursive directory walk. Short-circuit if the root is pruned.
if !watch_filter.should_watch_directory(&path) {
return Ok(());
}

for entry in WalkDir::new(path)
// We use the raw `WalkDir::IntoIter` so we can call `skip_current_dir()` for pruned entries.
// Non-directory entries are skipped because watches are only registered on directories
// (file-level events arrive through their parent's watch via `event.name`).
let mut iter = WalkDir::new(path)
.follow_links(self.follow_links)
.into_iter()
.filter_map(filter_dir)
.filter(|e| watch_filter.should_watch(e.path()))
{
self.add_single_watch(
entry.path().to_path_buf(),
is_recursive,
watch_self,
watch_filter.clone(),
)?;
watch_self = false;
.into_iter();
while let Some(entry_result) = iter.next() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooc why do we need to rewrite the for iterator into while let?

My understanding is we should just change should_watch in the original filter to should_watch_directory right?

Copy link
Copy Markdown
Author

@MaggieShan MaggieShan May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiuc the previous filter_map and filter would evaluate the entry/directory after it's already been explored, whereas WalkDir::skip_current_dir ensures that we're skipping entries before exploring them

I believe we can similarly use WalkDir::filter_entry to be slightly more ergonomic but I don't have a strong opinion

let entry = match entry_result {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_dir() {
continue;
}

if watch_filter.should_watch_directory(entry.path()) {
self.add_single_watch(
entry.path().to_path_buf(),
true,
watch_self,
watch_filter.clone(),
)?;
watch_self = false;
} else {
iter.skip_current_dir();
}
}

Ok(())
Expand Down Expand Up @@ -546,18 +579,6 @@ impl EventLoop {
}
}

/// return `DirEntry` when it is a directory
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above but I think we should be able to keep the original logic here

fn filter_dir(e: walkdir::Result<walkdir::DirEntry>) -> Option<walkdir::DirEntry> {
if let Ok(e) = e {
if let Ok(metadata) = e.metadata() {
if metadata.is_dir() {
return Some(e);
}
}
}
None
}

impl INotifyWatcher {
fn from_event_handler(
event_handler: Box<dyn EventHandler>,
Expand Down
137 changes: 125 additions & 12 deletions notify/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,16 +287,40 @@ pub enum WatcherKind {
NullWatcher,
}

type FilterFn = dyn Fn(&Path) -> bool + Send + Sync;
/// Path filter to limit what gets watched.
type ShouldEmitEventFn = dyn Fn(&Path) -> bool + Send + Sync;
type ShouldWatchDirectoryFn = dyn Fn(&Path) -> bool + Send + Sync;

#[derive(Clone)]
pub struct WatchFilter(Option<Arc<FilterFn>>);
struct Filters {
should_watch_directory: Arc<ShouldWatchDirectoryFn>,
should_emit_event: Arc<ShouldEmitEventFn>,
}

/// Path filter for [`Watcher::watch_filtered`].
///
/// Either accepts everything ([`WatchFilter::accept_all`]) or carries a
/// pair of predicates ([`WatchFilter::with_filter`]):
///
/// * `should_watch_directory` — per-directory, consulted at recursive
/// watch-registration time. Returning `false` means that directory and
/// its descendants are not watched. Currently only consulted by the
/// inotify backend (Linux/Android); other backends have no recursive
/// registration step and ignore it.
///
/// * `should_emit_event` — per-event predicate. Asked for every event path
/// on the inotify (Linux/Android), FSEvents (macOS), and
/// ReadDirectoryChangesW (Windows) backends. Returning `false` suppresses
/// the event. The kqueue and polling backends currently ignore this
/// predicate.
#[derive(Clone)]
pub struct WatchFilter(Option<Filters>);

impl std::fmt::Debug for WatchFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("WatchFilterFn")
.field(&self.0.as_ref().map_or("no filter", |_| "filter fn"))
.finish()
match &self.0 {
None => f.write_str("WatchFilter(accept_all)"),
Some(_) => f.write_str("WatchFilter(custom)"),
}
}
}

Expand All @@ -306,15 +330,51 @@ impl WatchFilter {
WatchFilter(None)
}

/// A fitler to limit the paths that get watched.
/// Construct a filter from a directory-watch predicate and an event-emit predicate.
///
/// * `should_watch_directory` — per-directory, consulted at recursive watch-registration time.
/// Only directories for which this returns `true` will be watched.
/// * `should_emit_event` — per-event predicate.
/// Only paths for which this returns `true` will emit events.
///
/// Only paths for which `filter` returns `true` will be watched.
pub fn with_filter(filter: Arc<FilterFn>) -> WatchFilter {
WatchFilter(Some(filter))
/// # Invariant: monotonicity over path containment
///
/// If `should_watch_directory(child)` returns `true`, every ancestor of
/// `child` up to the watched root must also return `true`.
/// The registration walk visits ancestors before descendants.
/// A rejected ancestor unreachably prunes everything beneath it.
pub fn with_filter(
should_watch_directory: Arc<ShouldWatchDirectoryFn>,
should_emit_event: Arc<ShouldEmitEventFn>,
) -> WatchFilter {
WatchFilter(Some(Filters {
should_watch_directory,
should_emit_event,
}))
}

fn should_watch(&self, path: &Path) -> bool {
self.0.as_ref().map_or(true, |f| f(path))
/// Returns `true` when recursive registration should watch `path`.
#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))]
pub(crate) fn should_watch_directory(&self, path: &Path) -> bool {
self.0
.as_ref()
.map_or(true, |f| (f.should_watch_directory)(path))
}

/// Returns `true` if an event for `path` should be emitted to the caller.
#[cfg_attr(
not(any(
target_os = "linux",
target_os = "android",
all(target_os = "macos", not(feature = "macos_kqueue")),
target_os = "windows"
)),
allow(dead_code)
)]
pub(crate) fn should_emit_event(&self, path: &Path) -> bool {
self.0
.as_ref()
.map_or(true, |f| (f.should_emit_event)(path))
}
}

Expand Down Expand Up @@ -492,4 +552,57 @@ mod tests {

Ok(())
}

#[test]
fn accept_all_watches_directories_and_emits_events_everywhere() {
let filter = WatchFilter::accept_all();
let path = Path::new("/some/path");
assert!(filter.should_watch_directory(path));
assert!(filter.should_emit_event(path));
}

#[test]
fn with_filter_plumbs_directory_watch_and_event_emit_predicates() {
let should_watch_directory: Arc<ShouldWatchDirectoryFn> = Arc::new(|p: &Path| {
matches!(
p.file_name().and_then(|n| n.to_str()),
Some("both" | "descend_only")
)
});
let should_emit_event: Arc<ShouldEmitEventFn> =
Arc::new(|p: &Path| p.file_name().and_then(|n| n.to_str()) == Some("both"));
let filter = WatchFilter::with_filter(should_watch_directory, should_emit_event);

assert!(filter.should_watch_directory(Path::new("/x/both")));
assert!(filter.should_watch_directory(Path::new("/x/descend_only")));
assert!(!filter.should_watch_directory(Path::new("/x/emit_only")));
assert!(!filter.should_watch_directory(Path::new("/x/other")));

assert!(filter.should_emit_event(Path::new("/x/both")));
assert!(!filter.should_emit_event(Path::new("/x/descend_only")));
assert!(!filter.should_emit_event(Path::new("/x/emit_only")));
assert!(!filter.should_emit_event(Path::new("/x/other")));
}

#[test]
fn with_filter_directory_watch_and_event_emit_are_independent() {
let filter = WatchFilter::with_filter(
Arc::new(|p: &Path| p.to_string_lossy().contains("keep")),
Arc::new(|p: &Path| p.file_name().and_then(|n| n.to_str()) == Some("HEAD")),
);
// Emit-only: directory watch says no, but event emit says yes.
let head = Path::new("/repo/.git/HEAD");
assert!(filter.should_emit_event(head));
assert!(!filter.should_watch_directory(head));

// Directory-watch-only: directory watch says yes, but event emit says no.
let keep = Path::new("/repo/keep/subdir");
assert!(!filter.should_emit_event(keep));
assert!(filter.should_watch_directory(keep));

// Neither: both gates reject.
let other = Path::new("/other");
assert!(!filter.should_emit_event(other));
assert!(!filter.should_watch_directory(other));
}
}
2 changes: 1 addition & 1 deletion notify/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ unsafe extern "system" fn handle_event(
Some(ref watch_path) => *watch_path != path,
};

skip = skip || !request.watch_filter.should_watch(&path);
skip = skip || !request.watch_filter.should_emit_event(&path);

if !skip {
log::trace!(
Expand Down
Loading