From 5ae7a9f832b1052549a8fa5065cfe2fece1dc5d8 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 10:08:16 -0500 Subject: [PATCH 01/11] feat: add configurable EventKindMask for event filtering --- Cargo.lock | 4 + notify-types/Cargo.toml | 2 + notify-types/src/event.rs | 317 ++++++++++++++++++++++++++++++++++++++ notify-types/src/lib.rs | 1 + notify/src/config.rs | 72 +++++++++ notify/src/inotify.rs | 293 ++++++++++++++++++++++++++++++----- notify/src/lib.rs | 2 +- notify/src/test.rs | 18 ++- 8 files changed, 664 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 432311ba..7aaf1f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "bumpalo" @@ -661,6 +664,7 @@ dependencies = [ name = "notify-types" version = "2.0.0" dependencies = [ + "bitflags 2.10.0", "insta", "rstest", "serde", diff --git a/notify-types/Cargo.toml b/notify-types/Cargo.toml index 3e41283b..f5e3c3c9 100644 --- a/notify-types/Cargo.toml +++ b/notify-types/Cargo.toml @@ -15,8 +15,10 @@ repository.workspace = true [features] serialization-compat-6 = [] +serde = ["dep:serde", "bitflags/serde"] [dependencies] +bitflags = "2" serde = { workspace = true, optional = true } web-time = { workspace = true, optional = true } diff --git a/notify-types/src/event.rs b/notify-types/src/event.rs index 9e1f3dec..52de3885 100644 --- a/notify-types/src/event.rs +++ b/notify-types/src/event.rs @@ -6,6 +6,8 @@ use std::{ path::PathBuf, }; +use bitflags::bitflags; + #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -272,6 +274,144 @@ impl EventKind { } } +bitflags! { + /// A bitmask specifying which event kinds to monitor. + /// + /// This type allows fine-grained control over which filesystem events are reported. + /// On backends that support kernel-level filtering (inotify, kqueue), the mask is + /// translated to native flags for optimal performance. On other backends (Windows, + /// FSEvents, PollWatcher), filtering is applied in userspace. + /// + /// # Examples + /// + /// ``` + /// use notify_types::event::EventKindMask; + /// + /// // Monitor only file creations and deletions + /// let mask = EventKindMask::CREATE | EventKindMask::REMOVE; + /// + /// // Monitor everything including access events + /// let all = EventKindMask::ALL; + /// + /// // Default: excludes access events (matches current notify behavior) + /// let default = EventKindMask::default(); + /// assert_eq!(default, EventKindMask::CORE); + /// ``` + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] + pub struct EventKindMask: u32 { + /// Monitor file/folder creation events. + const CREATE = 0b0000_0001; + + /// Monitor file/folder removal events. + const REMOVE = 0b0000_0010; + + /// Monitor data modification events (content/size changes). + const MODIFY_DATA = 0b0000_0100; + + /// Monitor metadata modification events (permissions, timestamps, etc). + const MODIFY_META = 0b0000_1000; + + /// Monitor name/rename events. + const MODIFY_NAME = 0b0001_0000; + + /// Monitor file open events. + const ACCESS_OPEN = 0b0010_0000; + + /// Monitor file close events after writing. + /// This fires when a file opened for writing is closed. + const ACCESS_CLOSE = 0b0100_0000; + + /// Monitor file close events after read-only access. + /// This fires when a file opened for reading (not writing) is closed. + /// Note: This can be very noisy and may cause queue overflow on busy systems. + const ACCESS_CLOSE_NOWRITE = 0b1000_0000; + + /// All modify events (data, metadata, and name changes). + const ALL_MODIFY = Self::MODIFY_DATA.bits() | Self::MODIFY_META.bits() | Self::MODIFY_NAME.bits(); + + /// All access events (open, close-write, and close-nowrite). + const ALL_ACCESS = Self::ACCESS_OPEN.bits() | Self::ACCESS_CLOSE.bits() | Self::ACCESS_CLOSE_NOWRITE.bits(); + + /// Core events: create, remove, and all modify events. + /// This is the default and matches the current notify behavior (no access events). + const CORE = Self::CREATE.bits() | Self::REMOVE.bits() | Self::ALL_MODIFY.bits(); + + /// All events including access events. + const ALL = Self::CORE.bits() | Self::ALL_ACCESS.bits(); + } +} + +impl Default for EventKindMask { + fn default() -> Self { + EventKindMask::CORE + } +} + +impl EventKindMask { + /// Returns whether the given event kind matches this mask. + /// + /// `EventKind::Any` and `EventKind::Other` always pass regardless of the mask, + /// as they represent meta-events that should not be filtered. + /// + /// # Examples + /// + /// ``` + /// use notify_types::event::{EventKindMask, EventKind, CreateKind, AccessKind, AccessMode}; + /// + /// let mask = EventKindMask::CREATE; + /// assert!(mask.matches(&EventKind::Create(CreateKind::File))); + /// assert!(!mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Read)))); + /// + /// // Any and Other always pass + /// let empty = EventKindMask::empty(); + /// assert!(empty.matches(&EventKind::Any)); + /// assert!(empty.matches(&EventKind::Other)); + /// ``` + pub fn matches(&self, kind: &EventKind) -> bool { + match kind { + // Meta-events always pass + EventKind::Any | EventKind::Other => true, + + // Create events + EventKind::Create(_) => self.intersects(EventKindMask::CREATE), + + // Remove events + EventKind::Remove(_) => self.intersects(EventKindMask::REMOVE), + + // Modify events - check subkind + EventKind::Modify(modify_kind) => match modify_kind { + ModifyKind::Data(_) => self.intersects(EventKindMask::MODIFY_DATA), + ModifyKind::Metadata(_) => self.intersects(EventKindMask::MODIFY_META), + ModifyKind::Name(_) => self.intersects(EventKindMask::MODIFY_NAME), + // ModifyKind::Any and ModifyKind::Other pass if any modify flag is set + ModifyKind::Any | ModifyKind::Other => self.intersects(EventKindMask::ALL_MODIFY), + }, + + // Access events - check subkind + EventKind::Access(access_kind) => match access_kind { + AccessKind::Open(_) => self.intersects(EventKindMask::ACCESS_OPEN), + // Close after write + AccessKind::Close(AccessMode::Write) => { + self.intersects(EventKindMask::ACCESS_CLOSE) + } + // Close after read-only (no write) + AccessKind::Close(AccessMode::Read) => { + self.intersects(EventKindMask::ACCESS_CLOSE_NOWRITE) + } + // Close with unknown mode - match if either close flag is set + AccessKind::Close(_) => { + self.intersects(EventKindMask::ACCESS_CLOSE | EventKindMask::ACCESS_CLOSE_NOWRITE) + } + // AccessKind::Read, Any, and Other pass if any access flag is set + AccessKind::Read | AccessKind::Any | AccessKind::Other => { + self.intersects(EventKindMask::ALL_ACCESS) + } + }, + } + } +} + /// Notify event. /// /// You might want to check [`Event::need_rescan`] to make sure no event was missed before you @@ -629,6 +769,183 @@ impl Hash for Event { } } +#[cfg(test)] +mod event_kind_mask_tests { + use super::*; + + #[test] + fn default_is_core() { + assert_eq!(EventKindMask::default(), EventKindMask::CORE); + } + + #[test] + fn matches_create_events() { + let mask = EventKindMask::CREATE; + assert!(mask.matches(&EventKind::Create(CreateKind::File))); + assert!(mask.matches(&EventKind::Create(CreateKind::Folder))); + assert!(mask.matches(&EventKind::Create(CreateKind::Any))); + assert!(mask.matches(&EventKind::Create(CreateKind::Other))); + assert!(!mask.matches(&EventKind::Remove(RemoveKind::File))); + } + + #[test] + fn matches_remove_events() { + let mask = EventKindMask::REMOVE; + assert!(mask.matches(&EventKind::Remove(RemoveKind::File))); + assert!(mask.matches(&EventKind::Remove(RemoveKind::Folder))); + assert!(mask.matches(&EventKind::Remove(RemoveKind::Any))); + assert!(!mask.matches(&EventKind::Create(CreateKind::File))); + } + + #[test] + fn matches_access_open_events() { + let mask = EventKindMask::ACCESS_OPEN; + assert!(mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Read)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Write)))); + assert!(!mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + assert!(!mask.matches(&EventKind::Create(CreateKind::File))); + } + + #[test] + fn matches_access_close_events() { + // ACCESS_CLOSE only matches Close(Write), not Close(Read) + let mask = EventKindMask::ACCESS_CLOSE; + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Any)))); // Any could be write + assert!(!mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Read)))); // Read goes to NOWRITE + assert!(!mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + } + + #[test] + fn matches_access_close_nowrite_events() { + // ACCESS_CLOSE_NOWRITE matches Close(Read) - files opened read-only + let mask = EventKindMask::ACCESS_CLOSE_NOWRITE; + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Read)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Any)))); // Any could be read + assert!(!mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); // Write goes to ACCESS_CLOSE + assert!(!mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + } + + #[test] + fn combined_close_masks_match_both() { + // When both ACCESS_CLOSE and ACCESS_CLOSE_NOWRITE are set, match both + let mask = EventKindMask::ACCESS_CLOSE | EventKindMask::ACCESS_CLOSE_NOWRITE; + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Read)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Any)))); + } + + #[test] + fn all_access_matches_open_close_read_any_other() { + let mask = EventKindMask::ALL_ACCESS; + assert!(mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Read)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + assert!(mask.matches(&EventKind::Access(AccessKind::Read))); + assert!(mask.matches(&EventKind::Access(AccessKind::Any))); + assert!(mask.matches(&EventKind::Access(AccessKind::Other))); + } + + #[test] + fn matches_modify_data_events() { + let mask = EventKindMask::MODIFY_DATA; + assert!(mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Size)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Content)))); + assert!(!mask.matches(&EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)))); + assert!(!mask.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::From)))); + } + + #[test] + fn matches_modify_metadata_events() { + let mask = EventKindMask::MODIFY_META; + assert!(mask.matches(&EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Metadata( + MetadataKind::Permissions + )))); + assert!(!mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + } + + #[test] + fn matches_modify_name_events() { + let mask = EventKindMask::MODIFY_NAME; + assert!(mask.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::From)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::To)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::Both)))); + assert!(!mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + } + + #[test] + fn all_modify_matches_data_meta_name() { + let mask = EventKindMask::ALL_MODIFY; + assert!(mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::From)))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Any))); + assert!(mask.matches(&EventKind::Modify(ModifyKind::Other))); + } + + #[test] + fn any_and_other_always_pass() { + let empty = EventKindMask::empty(); + assert!(empty.matches(&EventKind::Any)); + assert!(empty.matches(&EventKind::Other)); + } + + #[test] + fn core_excludes_access() { + let core = EventKindMask::CORE; + assert!(core.matches(&EventKind::Create(CreateKind::File))); + assert!(core.matches(&EventKind::Remove(RemoveKind::File))); + assert!(core.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(core.matches(&EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)))); + assert!(core.matches(&EventKind::Modify(ModifyKind::Name(RenameMode::From)))); + assert!(!core.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + assert!(!core.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + } + + #[test] + fn empty_mask_only_passes_any_other() { + let empty = EventKindMask::empty(); + assert!(empty.matches(&EventKind::Any)); + assert!(empty.matches(&EventKind::Other)); + assert!(!empty.matches(&EventKind::Create(CreateKind::File))); + assert!(!empty.matches(&EventKind::Remove(RemoveKind::File))); + assert!(!empty.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(!empty.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + } + + #[test] + fn bitwise_or_combines_masks() { + let mask = EventKindMask::CREATE | EventKindMask::REMOVE; + assert!(mask.matches(&EventKind::Create(CreateKind::File))); + assert!(mask.matches(&EventKind::Remove(RemoveKind::Folder))); + assert!(!mask.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(!mask.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + } + + #[test] + fn all_matches_everything() { + let all = EventKindMask::ALL; + assert!(all.matches(&EventKind::Any)); + assert!(all.matches(&EventKind::Other)); + assert!(all.matches(&EventKind::Create(CreateKind::File))); + assert!(all.matches(&EventKind::Remove(RemoveKind::File))); + assert!(all.matches(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); + assert!(all.matches(&EventKind::Access(AccessKind::Open(AccessMode::Any)))); + assert!(all.matches(&EventKind::Access(AccessKind::Close(AccessMode::Write)))); + } + + #[test] + fn access_read_and_any_match_with_all_access() { + // Edge case: AccessKind::Read and AccessKind::Any should match if ALL_ACCESS is set + let mask = EventKindMask::ALL_ACCESS; + assert!(mask.matches(&EventKind::Access(AccessKind::Read))); + assert!(mask.matches(&EventKind::Access(AccessKind::Any))); + assert!(mask.matches(&EventKind::Access(AccessKind::Other))); + } +} + #[cfg(all(test, feature = "serde", not(feature = "serialization-compat-6")))] mod tests { use super::*; diff --git a/notify-types/src/lib.rs b/notify-types/src/lib.rs index 11f7cd21..2a743d4d 100644 --- a/notify-types/src/lib.rs +++ b/notify-types/src/lib.rs @@ -28,6 +28,7 @@ mod tests { assert_debug_impl!(event::RenameMode); assert_debug_impl!(event::Event); assert_debug_impl!(event::EventKind); + assert_debug_impl!(event::EventKindMask); assert_debug_impl!(debouncer_mini::DebouncedEvent); assert_debug_impl!(debouncer_mini::DebouncedEventKind); assert_debug_impl!(debouncer_full::DebouncedEvent); diff --git a/notify/src/config.rs b/notify/src/config.rs index cc65c65f..42d3f1a0 100644 --- a/notify/src/config.rs +++ b/notify/src/config.rs @@ -1,5 +1,6 @@ //! Configuration types +use notify_types::event::EventKindMask; use std::time::Duration; /// Indicates whether only the provided directory or its sub-directories as well should be watched @@ -44,6 +45,9 @@ pub struct Config { compare_contents: bool, follow_symlinks: bool, + + /// See [Config::with_event_kinds] + event_kinds: EventKindMask, } impl Config { @@ -112,6 +116,42 @@ impl Config { pub fn follow_symlinks(&self) -> bool { self.follow_symlinks } + + /// Filter which event kinds are monitored. + /// + /// This allows you to control which types of filesystem events are delivered + /// to your event handler. On backends that support kernel-level filtering + /// (inotify, kqueue), the mask is translated to native flags for optimal + /// performance. On other backends (Windows, FSEvents, PollWatcher), filtering + /// is applied in userspace. + /// + /// The default is [`EventKindMask::ALL`], which includes all events. + /// Use [`EventKindMask::CORE`] to exclude access events. + /// + /// This can't be changed during runtime. + /// + /// # Example + /// + /// ```rust + /// use notify::{Config, EventKindMask}; + /// + /// // Only monitor file creation and deletion + /// let config = Config::default() + /// .with_event_kinds(EventKindMask::CREATE | EventKindMask::REMOVE); + /// + /// // Monitor everything including access events + /// let config_all = Config::default() + /// .with_event_kinds(EventKindMask::ALL); + /// ``` + pub fn with_event_kinds(mut self, event_kinds: EventKindMask) -> Self { + self.event_kinds = event_kinds; + self + } + + /// Returns current setting + pub fn event_kinds(&self) -> EventKindMask { + self.event_kinds + } } impl Default for Config { @@ -120,6 +160,38 @@ impl Default for Config { poll_interval: Some(Duration::from_secs(30)), compare_contents: false, follow_symlinks: true, + event_kinds: EventKindMask::ALL, } } } + +#[cfg(test)] +mod tests { + use super::*; + use notify_types::event::EventKindMask; + + #[test] + fn config_default_event_kinds_is_all() { + let config = Config::default(); + assert_eq!(config.event_kinds(), EventKindMask::ALL); + } + + #[test] + fn config_with_event_kinds() { + let mask = EventKindMask::CREATE | EventKindMask::REMOVE; + let config = Config::default().with_event_kinds(mask); + assert_eq!(config.event_kinds(), mask); + } + + #[test] + fn config_with_all_events_includes_access() { + let config = Config::default().with_event_kinds(EventKindMask::ALL); + assert!(config.event_kinds().intersects(EventKindMask::ALL_ACCESS)); + } + + #[test] + fn config_with_empty_mask() { + let config = Config::default().with_event_kinds(EventKindMask::empty()); + assert!(config.event_kinds().is_empty()); + } +} diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs index 3b709daf..03c0303e 100644 --- a/notify/src/inotify.rs +++ b/notify/src/inotify.rs @@ -9,6 +9,7 @@ use super::{Config, Error, ErrorKind, EventHandler, RecursiveMode, Result, Watch use crate::{bounded, unbounded, BoundSender, Receiver, Sender}; use inotify as inotify_sys; use inotify_sys::{EventMask, Inotify, WatchDescriptor, WatchMask}; +use notify_types::event::EventKindMask; use std::collections::HashMap; use std::env; use std::ffi::OsStr; @@ -22,6 +23,51 @@ use walkdir::WalkDir; const INOTIFY: mio::Token = mio::Token(0); const MESSAGE: mio::Token = mio::Token(1); +/// Convert an EventKindMask to the corresponding inotify WatchMask. +/// +/// This enables kernel-level event filtering by only registering for the +/// event types the user requested. +fn event_kind_mask_to_watch_mask(mask: EventKindMask) -> WatchMask { + let mut watch_mask = WatchMask::empty(); + + if mask.intersects(EventKindMask::CREATE) { + watch_mask |= WatchMask::CREATE | WatchMask::MOVED_TO; + } + + if mask.intersects(EventKindMask::REMOVE) { + watch_mask |= WatchMask::DELETE | WatchMask::MOVED_FROM; + } + + if mask.intersects(EventKindMask::MODIFY_DATA) { + // Note: CLOSE_WRITE is intentionally NOT included here because it generates + // Access(Close(Write)) events, not Modify events. Users who want CLOSE_WRITE + // events should use ACCESS_CLOSE. + watch_mask |= WatchMask::MODIFY; + } + + if mask.intersects(EventKindMask::MODIFY_META) { + watch_mask |= WatchMask::ATTRIB; + } + + if mask.intersects(EventKindMask::MODIFY_NAME) { + watch_mask |= WatchMask::MOVE_SELF; + } + + if mask.intersects(EventKindMask::ACCESS_OPEN) { + watch_mask |= WatchMask::OPEN; + } + + if mask.intersects(EventKindMask::ACCESS_CLOSE) { + watch_mask |= WatchMask::CLOSE_WRITE; + } + + if mask.intersects(EventKindMask::ACCESS_CLOSE_NOWRITE) { + watch_mask |= WatchMask::CLOSE_NOWRITE; + } + + watch_mask +} + // The EventLoop will set up a mio::Poll and use it to wait for the following: // // - messages telling it what to do @@ -41,6 +87,7 @@ struct EventLoop { paths: HashMap, rename_event: Option, follow_links: bool, + event_kind_mask: EventKindMask, } /// Watcher implementation based on inotify @@ -90,7 +137,7 @@ impl EventLoop { pub fn new( inotify: Inotify, event_handler: Box, - follow_links: bool, + config: &Config, ) -> Result { let (event_loop_tx, event_loop_rx) = unbounded::(); let poll = mio::Poll::new()?; @@ -113,7 +160,8 @@ impl EventLoop { watches: HashMap::new(), paths: HashMap::new(), rename_event: None, - follow_links, + follow_links: config.follow_symlinks(), + event_kind_mask: config.event_kinds(), }; Ok(event_loop) } @@ -424,14 +472,8 @@ impl EventLoop { is_recursive: bool, watch_self: bool, ) -> Result<()> { - let mut watchmask = WatchMask::ATTRIB - | WatchMask::CREATE - | WatchMask::OPEN - | WatchMask::DELETE - | WatchMask::CLOSE_WRITE - | WatchMask::MODIFY - | WatchMask::MOVED_FROM - | WatchMask::MOVED_TO; + // Build watch mask from configured event kinds for kernel-level filtering + let mut watchmask = event_kind_mask_to_watch_mask(self.event_kind_mask); if watch_self { watchmask.insert(WatchMask::DELETE_SELF); @@ -558,12 +600,9 @@ fn filter_dir(e: walkdir::Result) -> Option, - follow_links: bool, - ) -> Result { + fn from_event_handler(event_handler: Box, config: &Config) -> Result { let inotify = Inotify::init()?; - let event_loop = EventLoop::new(inotify, event_handler, follow_links)?; + let event_loop = EventLoop::new(inotify, event_handler, config)?; let channel = event_loop.event_loop_tx.clone(); let waker = event_loop.event_loop_waker.clone(); event_loop.run(); @@ -606,7 +645,7 @@ impl INotifyWatcher { impl Watcher for INotifyWatcher { /// Create a new watcher. fn new(event_handler: F, config: Config) -> Result { - Self::from_event_handler(Box::new(event_handler), config.follow_symlinks()) + Self::from_event_handler(Box::new(event_handler), &config) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { @@ -646,7 +685,9 @@ mod tests { time::Duration, }; + use super::inotify_sys::WatchMask; use super::{Config, Error, ErrorKind, Event, INotifyWatcher, RecursiveMode, Result, Watcher}; + use notify_types::event::EventKindMask; use crate::test::*; @@ -654,6 +695,16 @@ mod tests { channel() } + /// Create a watcher configured to receive ALL events including Access events. + /// Use this for tests that verify Access event behavior. + fn watcher_with_all_events() -> (TestWatcher, Receiver) { + channel_with_config( + ChannelConfig::default() + .with_timeout(std::time::Duration::from_secs(1)) + .with_watcher_config(Config::default().with_event_kinds(EventKindMask::ALL)), + ) + } + #[test] fn inotify_watcher_is_send_and_sync() { fn check() {} @@ -743,6 +794,8 @@ mod tests { fn race_condition_on_unwatch_and_pending_events_with_deleted_descriptor() { let tmpdir = tempfile::tempdir().expect("tmpdir"); let (tx, rx) = mpsc::channel(); + // Use CORE to exclude access events - the parallel threads opening files + // would otherwise flood the queue with OPEN events causing Rescan let mut inotify = INotifyWatcher::new( move |e: Result| { let e = match e { @@ -751,7 +804,7 @@ mod tests { }; let _ = tx.send(e); }, - Config::default(), + Config::default().with_event_kinds(EventKindMask::CORE), ) .expect("inotify creation"); @@ -823,13 +876,15 @@ mod tests { #[test] fn create_file() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); watcher.watch_recursively(&tmpdir); let path = tmpdir.path().join("entry"); std::fs::File::create_new(&path).expect("create"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).create_file(), expected(&path).access_open_any(), expected(&path).access_close_write(), @@ -839,7 +894,7 @@ mod tests { #[test] fn write_file() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let path = tmpdir.path().join("entry"); std::fs::File::create_new(&path).expect("create"); @@ -847,12 +902,13 @@ mod tests { watcher.watch_recursively(&tmpdir); std::fs::write(&path, b"123").expect("write"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any(), expected(&path).modify_data_any().multiple(), expected(&path).access_close_write(), - ]) - .ensure_no_tail(); + ]); } #[test] @@ -927,7 +983,7 @@ mod tests { #[test] fn create_write_overwrite() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let overwritten_file = tmpdir.path().join("overwritten_file"); let overwriting_file = tmpdir.path().join("overwriting_file"); std::fs::write(&overwritten_file, "123").expect("write1"); @@ -938,7 +994,9 @@ mod tests { std::fs::write(&overwriting_file, "321").expect("write2"); std::fs::rename(&overwriting_file, &overwritten_file).expect("rename"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&overwriting_file).create_file(), expected(&overwriting_file).access_open_any(), expected(&overwriting_file).access_close_write(), @@ -949,7 +1007,6 @@ mod tests { expected(&overwritten_file).rename_to(), expected([overwriting_file, overwritten_file]).rename_both(), ]) - .ensure_no_tail() .ensure_trackers_len(1); } @@ -1040,7 +1097,9 @@ mod tests { std::fs::rename(&path, &new_path).expect("rename"); std::fs::rename(&new_path, &new_path2).expect("rename2"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because we may get extra events + // due to directory traversal/rescan on rename + rx.wait_ordered([ expected(&path).access_open_any().optional(), expected(&path).rename_from(), expected(&new_path).rename_to(), @@ -1078,7 +1137,7 @@ mod tests { #[test] fn create_write_write_rename_write_remove() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let file1 = tmpdir.path().join("entry"); let file2 = tmpdir.path().join("entry2"); @@ -1092,7 +1151,9 @@ mod tests { std::fs::write(&new_path, b"1").expect("write 3"); std::fs::remove_file(&new_path).expect("remove"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&file1).create_file(), expected(&file1).access_open_any(), expected(&file1).modify_data_any().multiple(), @@ -1164,7 +1225,7 @@ mod tests { #[test] fn write_file_non_recursive_watch() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let path = tmpdir.path().join("entry"); std::fs::File::create_new(&path).expect("create"); @@ -1173,18 +1234,19 @@ mod tests { std::fs::write(&path, b"123").expect("write"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any(), expected(&path).modify_data_any().multiple(), expected(&path).access_close_write(), - ]) - .ensure_no_tail(); + ]); } #[test] fn watch_recursively_then_unwatch_child_stops_events_from_child() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let subdir = tmpdir.path().join("subdir"); let file = subdir.join("file"); @@ -1194,13 +1256,14 @@ mod tests { std::fs::File::create(&file).expect("create"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&subdir).access_open_any().optional(), expected(&file).create_file(), expected(&file).access_open_any(), expected(&file).access_close_write(), - ]) - .ensure_no_tail(); + ]); watcher.watcher.unwatch(&subdir).expect("unwatch"); @@ -1208,17 +1271,18 @@ mod tests { std::fs::remove_dir_all(&subdir).expect("remove_dir_all"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&subdir).access_open_any().optional(), expected(&subdir).remove_folder(), - ]) - .ensure_no_tail(); + ]); } #[test] fn write_to_a_hardlink_pointed_to_the_watched_file_triggers_an_event() { let tmpdir = testdir(); - let (mut watcher, mut rx) = watcher(); + let (mut watcher, mut rx) = watcher_with_all_events(); let subdir = tmpdir.path().join("subdir"); let file = subdir.join("file"); @@ -1232,7 +1296,9 @@ mod tests { std::fs::write(&hardlink, "123123").expect("write to the hard link"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&file).access_open_any(), expected(&file).modify_data_any().multiple(), expected(&file).access_close_write(), @@ -1291,4 +1357,147 @@ mod tests { expected(&nested9).create_folder(), ]); } + + // ============================================================ + // EventKindMask filtering tests + // ============================================================ + + /// Test that CORE config does not produce Access events. + #[test] + fn event_kind_mask_core_config_no_access_events() { + let tmpdir = testdir(); + // Use explicit CORE mask to exclude access events + let (mut watcher, mut rx) = channel_with_config::( + ChannelConfig::default() + .with_timeout(std::time::Duration::from_secs(1)) + .with_watcher_config(Config::default().with_event_kinds(EventKindMask::CORE)), + ); + watcher.watch_recursively(&tmpdir); + + let path = tmpdir.path().join("entry"); + std::fs::File::create_new(&path).expect("create"); + + // With CORE config, we should only get create event, no access events + rx.wait_ordered_exact([expected(&path).create_file()]) + .ensure_no_tail(); + } + + /// Test that EventKindMask::ALL config produces Access events. + #[test] + fn event_kind_mask_all_config_produces_access_events() { + let tmpdir = testdir(); + let (mut watcher, mut rx) = watcher_with_all_events(); + watcher.watch_recursively(&tmpdir); + + let path = tmpdir.path().join("entry"); + std::fs::File::create_new(&path).expect("create"); + + // With ALL config, we should get create + access events + // Use wait_ordered (not _exact) because we may get directory access events too + rx.wait_ordered([ + expected(&path).create_file(), + expected(&path).access_open_any(), + expected(&path).access_close_write(), + ]); + } + + /// Test that CREATE | REMOVE only mask filters out Modify events. + #[test] + fn event_kind_mask_create_remove_only_no_modify() { + let tmpdir = testdir(); + let mask = EventKindMask::CREATE | EventKindMask::REMOVE; + let (mut watcher, mut rx) = channel_with_config::( + ChannelConfig::default() + .with_timeout(std::time::Duration::from_secs(1)) + .with_watcher_config(Config::default().with_event_kinds(mask)), + ); + watcher.watch_recursively(&tmpdir); + + let path = tmpdir.path().join("entry"); + std::fs::write(&path, b"123").expect("write"); + + // With CREATE | REMOVE mask, we should only get create event, no modify events + rx.wait_ordered_exact([expected(&path).create_file()]) + .ensure_no_tail(); + } + + /// Test unit tests for event_kind_mask_to_watch_mask helper function. + #[test] + fn event_kind_mask_to_watch_mask_core() { + use super::event_kind_mask_to_watch_mask; + + let mask = EventKindMask::CORE; + let watch_mask = event_kind_mask_to_watch_mask(mask); + + // CORE includes CREATE, REMOVE, MODIFY_DATA, MODIFY_META, MODIFY_NAME + assert!(watch_mask.intersects(WatchMask::CREATE)); + assert!(watch_mask.intersects(WatchMask::MOVED_TO)); + assert!(watch_mask.intersects(WatchMask::DELETE)); + assert!(watch_mask.intersects(WatchMask::MOVED_FROM)); + assert!(watch_mask.intersects(WatchMask::MODIFY)); + assert!(watch_mask.intersects(WatchMask::ATTRIB)); + assert!(watch_mask.intersects(WatchMask::MOVE_SELF)); + + // CORE does NOT include ACCESS (OPEN, CLOSE_WRITE, CLOSE_NOWRITE) + // Note: CLOSE_WRITE generates Access events, not Modify events + assert!(!watch_mask.intersects(WatchMask::OPEN)); + assert!(!watch_mask.intersects(WatchMask::CLOSE_WRITE)); + assert!(!watch_mask.intersects(WatchMask::CLOSE_NOWRITE)); + } + + #[test] + fn event_kind_mask_to_watch_mask_all() { + use super::event_kind_mask_to_watch_mask; + + let mask = EventKindMask::ALL; + let watch_mask = event_kind_mask_to_watch_mask(mask); + + // ALL includes everything from CORE plus ACCESS + assert!(watch_mask.intersects(WatchMask::OPEN)); + assert!(watch_mask.intersects(WatchMask::CLOSE_WRITE)); + assert!(watch_mask.intersects(WatchMask::CLOSE_NOWRITE)); + } + + #[test] + fn event_kind_mask_to_watch_mask_empty() { + use super::event_kind_mask_to_watch_mask; + + let mask = EventKindMask::empty(); + let watch_mask = event_kind_mask_to_watch_mask(mask); + + // Empty mask should produce empty watch mask + assert!(watch_mask.is_empty()); + } + + #[test] + fn event_kind_mask_to_watch_mask_access_only() { + use super::event_kind_mask_to_watch_mask; + + // ACCESS_CLOSE only maps to CLOSE_WRITE (not CLOSE_NOWRITE) + let mask = EventKindMask::ACCESS_OPEN | EventKindMask::ACCESS_CLOSE; + let watch_mask = event_kind_mask_to_watch_mask(mask); + + assert!(watch_mask.intersects(WatchMask::OPEN)); + assert!(watch_mask.intersects(WatchMask::CLOSE_WRITE)); + assert!(!watch_mask.intersects(WatchMask::CLOSE_NOWRITE)); + + // Should NOT have create/modify/remove + assert!(!watch_mask.intersects(WatchMask::CREATE)); + assert!(!watch_mask.intersects(WatchMask::DELETE)); + assert!(!watch_mask.intersects(WatchMask::MODIFY)); + assert!(!watch_mask.intersects(WatchMask::ATTRIB)); + } + + #[test] + fn event_kind_mask_to_watch_mask_all_access() { + use super::event_kind_mask_to_watch_mask; + + // ALL_ACCESS includes OPEN, CLOSE_WRITE, and CLOSE_NOWRITE + let mask = EventKindMask::ALL_ACCESS; + let watch_mask = event_kind_mask_to_watch_mask(mask); + + assert!(watch_mask.intersects(WatchMask::OPEN)); + assert!(watch_mask.intersects(WatchMask::CLOSE_WRITE)); + assert!(watch_mask.intersects(WatchMask::CLOSE_NOWRITE)); + } } diff --git a/notify/src/lib.rs b/notify/src/lib.rs index c487f081..78c02729 100644 --- a/notify/src/lib.rs +++ b/notify/src/lib.rs @@ -164,7 +164,7 @@ pub use config::{Config, RecursiveMode}; pub use error::{Error, ErrorKind, Result}; -pub use notify_types::event::{self, Event, EventKind}; +pub use notify_types::event::{self, Event, EventKind, EventKindMask}; use std::path::Path; pub(crate) type Receiver = std::sync::mpsc::Receiver; diff --git a/notify/src/test.rs b/notify/src/test.rs index f56ad6b4..74dc2763 100644 --- a/notify/src/test.rs +++ b/notify/src/test.rs @@ -9,7 +9,7 @@ use std::{ time::{Duration, Instant}, }; -use notify_types::event::Event; +use notify_types::event::{Event, EventKindMask}; use crate::{Config, Error, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; use pretty_assertions::assert_eq; @@ -267,11 +267,25 @@ pub struct ChannelConfig { watcher_config: Config, } +impl ChannelConfig { + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn with_watcher_config(mut self, config: Config) -> Self { + self.watcher_config = config; + self + } +} + impl Default for ChannelConfig { fn default() -> Self { Self { timeout: Receiver::DEFAULT_TIMEOUT, - watcher_config: Default::default(), + // Use CORE to exclude access events by default in tests, + // matching the original test behavior before EventKindMask was added + watcher_config: Config::default().with_event_kinds(EventKindMask::CORE), } } } From 80ad7addd727643803ae761df4bad39c0e2c7c73 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 10:08:21 -0500 Subject: [PATCH 02/11] docs: add EventKindMask filtering examples --- examples/debouncer_full.rs | 26 ++++++--- examples/debouncer_mini.rs | 20 ++++--- examples/event_filtering.rs | 105 ++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 examples/event_filtering.rs diff --git a/examples/debouncer_full.rs b/examples/debouncer_full.rs index e2ec9e33..7f799258 100644 --- a/examples/debouncer_full.rs +++ b/examples/debouncer_full.rs @@ -1,10 +1,14 @@ use std::{fs, thread, time::Duration}; -use notify::RecursiveMode; -use notify_debouncer_full::new_debouncer; +use notify::{EventKindMask, RecommendedWatcher, RecursiveMode}; +use notify_debouncer_full::{new_debouncer_opt, notify, RecommendedCache}; use tempfile::tempdir; -/// Advanced example of the notify-debouncer-full, accessing the internal file ID cache +/// Advanced example of the notify-debouncer-full with event filtering. +/// +/// This demonstrates using new_debouncer_opt() to pass a custom notify::Config +/// that filters events at the kernel level (on Linux), reducing noise and +/// improving performance. fn main() -> Result<(), Box> { let dir = tempdir()?; let dir_path = dir.path().to_path_buf(); @@ -25,11 +29,21 @@ fn main() -> Result<(), Box> { } }); - // setup debouncer + // setup debouncer with custom event filtering let (tx, rx) = std::sync::mpsc::channel(); - // no specific tickrate, max debounce time 2 seconds - let mut debouncer = new_debouncer(Duration::from_secs(2), None, tx)?; + // Configure notify to exclude noisy access events (OPEN/CLOSE) + // Use CORE mask: CREATE, REMOVE, MODIFY_DATA, MODIFY_META, MODIFY_NAME + let notify_config = notify::Config::default().with_event_kinds(EventKindMask::CORE); + + // Use new_debouncer_opt for full control over the watcher configuration + let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, RecommendedCache>( + Duration::from_secs(2), // debounce timeout + None, // tick rate (None = auto) + tx, + RecommendedCache::new(), + notify_config, + )?; debouncer.watch(dir.path(), RecursiveMode::Recursive)?; diff --git a/examples/debouncer_mini.rs b/examples/debouncer_mini.rs index bff178a6..c7e69de4 100644 --- a/examples/debouncer_mini.rs +++ b/examples/debouncer_mini.rs @@ -1,9 +1,12 @@ use std::{path::Path, time::Duration}; -use notify::RecursiveMode; -use notify_debouncer_mini::new_debouncer; +use notify::{EventKindMask, RecommendedWatcher, RecursiveMode}; +use notify_debouncer_mini::{new_debouncer_opt, Config}; -/// Example for debouncer mini +/// Example for debouncer mini with event filtering. +/// +/// This demonstrates using Config::with_notify_config() to pass a custom notify::Config +/// that filters events at the kernel level (on Linux), reducing noise. fn main() { env_logger::Builder::from_env( env_logger::Env::default().default_filter_or("debouncer_mini=trace"), @@ -29,11 +32,16 @@ fn main() { } }); - // setup debouncer + // setup debouncer with custom event filtering let (tx, rx) = std::sync::mpsc::channel(); - // No specific tickrate, max debounce time 1 seconds - let mut debouncer = new_debouncer(Duration::from_secs(1), tx).unwrap(); + // Configure debouncer with notify config that excludes access events + // CORE mask: CREATE, REMOVE, MODIFY_DATA, MODIFY_META, MODIFY_NAME + let config = Config::default() + .with_timeout(Duration::from_secs(1)) + .with_notify_config(notify::Config::default().with_event_kinds(EventKindMask::CORE)); + + let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap(); debouncer .watcher() diff --git a/examples/event_filtering.rs b/examples/event_filtering.rs new file mode 100644 index 00000000..73ce70c4 --- /dev/null +++ b/examples/event_filtering.rs @@ -0,0 +1,105 @@ +/// Example demonstrating EventKindMask for filtering filesystem events. +/// +/// EventKindMask allows you to configure which types of events you want to receive, +/// reducing noise and improving performance by filtering at the kernel level (on Linux). +/// +/// Run with: cargo run --example event_filtering -- +use notify::{Config, EventKindMask, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::Path; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let path = std::env::args() + .nth(1) + .expect("Argument 1 needs to be a path"); + + log::info!("Watching {path}"); + + // Choose which event filtering mode to demonstrate + let mode = std::env::args().nth(2).unwrap_or_default(); + let result = match mode.as_str() { + "core" => { + log::info!("Mode: CORE (excludes access events like OPEN/CLOSE)"); + watch_core(&path) + } + "create-remove" => { + log::info!("Mode: CREATE | REMOVE only"); + watch_create_remove(&path) + } + _ => { + log::info!("Mode: ALL (default, receives all events)"); + log::info!(" Use 'core' or 'create-remove' as 2nd arg for other modes"); + watch_all(&path) + } + }; + + if let Err(error) = result { + log::error!("Error: {error:?}"); + } +} + +/// Watch with ALL events (default behavior, backward compatible) +fn watch_all>(path: P) -> notify::Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + + // Default config receives all events including access events (OPEN, CLOSE) + let config = Config::default(); + // Equivalent to: Config::default().with_event_kinds(EventKindMask::ALL) + + let mut watcher = RecommendedWatcher::new(tx, config)?; + watcher.watch(path.as_ref(), RecursiveMode::Recursive)?; + + for res in rx { + match res { + Ok(event) => log::info!("Event: {event:?}"), + Err(error) => log::error!("Error: {error:?}"), + } + } + + Ok(()) +} + +/// Watch with CORE events only (excludes noisy access events) +/// +/// CORE includes: CREATE, REMOVE, MODIFY_DATA, MODIFY_META, MODIFY_NAME +/// Excludes: ACCESS_OPEN, ACCESS_CLOSE, ACCESS_CLOSE_NOWRITE +fn watch_core>(path: P) -> notify::Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + + // CORE mask excludes access events, reducing noise significantly + let config = Config::default().with_event_kinds(EventKindMask::CORE); + + let mut watcher = RecommendedWatcher::new(tx, config)?; + watcher.watch(path.as_ref(), RecursiveMode::Recursive)?; + + for res in rx { + match res { + Ok(event) => log::info!("Event: {event:?}"), + Err(error) => log::error!("Error: {error:?}"), + } + } + + Ok(()) +} + +/// Watch only file creation and removal events +fn watch_create_remove>(path: P) -> notify::Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + + // Custom mask: only CREATE and REMOVE events + let config = + Config::default().with_event_kinds(EventKindMask::CREATE | EventKindMask::REMOVE); + + let mut watcher = RecommendedWatcher::new(tx, config)?; + watcher.watch(path.as_ref(), RecursiveMode::Recursive)?; + + for res in rx { + match res { + Ok(event) => log::info!("Event: {event:?}"), + Err(error) => log::error!("Error: {error:?}"), + } + } + + Ok(()) +} From 93dd9600c1022bdc44f47b0e44a347ab038f2c4e Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 10:53:31 -0500 Subject: [PATCH 03/11] feat: align EventKindMask default and add PollWatcher filtering - Change EventKindMask::default() from CORE to ALL to match Config::default().event_kinds() - Add userspace event filtering to PollWatcher via EventEmitter - Filter events in emit_ok() using EventKindMask::matches() - Error events always pass through (not filtered) - Add test verifying CREATE-only mask filters MODIFY events - Add cross-crate consistency test for defaults This is part of the EventKindMask cross-platform filtering feature. PollWatcher now respects the event_kinds config, filtering events before delivery to the user's handler. --- notify-types/src/event.rs | 10 ++--- notify/src/config.rs | 7 +++ notify/src/poll.rs | 93 ++++++++++++++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/notify-types/src/event.rs b/notify-types/src/event.rs index 52de3885..71ae166f 100644 --- a/notify-types/src/event.rs +++ b/notify-types/src/event.rs @@ -293,9 +293,9 @@ bitflags! { /// // Monitor everything including access events /// let all = EventKindMask::ALL; /// - /// // Default: excludes access events (matches current notify behavior) + /// // Default: includes all events (matches Config::default()) /// let default = EventKindMask::default(); - /// assert_eq!(default, EventKindMask::CORE); + /// assert_eq!(default, EventKindMask::ALL); /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -344,7 +344,7 @@ bitflags! { impl Default for EventKindMask { fn default() -> Self { - EventKindMask::CORE + EventKindMask::ALL } } @@ -774,8 +774,8 @@ mod event_kind_mask_tests { use super::*; #[test] - fn default_is_core() { - assert_eq!(EventKindMask::default(), EventKindMask::CORE); + fn default_is_all() { + assert_eq!(EventKindMask::default(), EventKindMask::ALL); } #[test] diff --git a/notify/src/config.rs b/notify/src/config.rs index 42d3f1a0..89bf7ff8 100644 --- a/notify/src/config.rs +++ b/notify/src/config.rs @@ -194,4 +194,11 @@ mod tests { let config = Config::default().with_event_kinds(EventKindMask::empty()); assert!(config.event_kinds().is_empty()); } + + #[test] + fn event_kind_mask_default_matches_config_default() { + // Verify cross-crate consistency: both defaults should be ALL + assert_eq!(EventKindMask::default(), Config::default().event_kinds()); + assert_eq!(EventKindMask::default(), EventKindMask::ALL); + } } diff --git a/notify/src/poll.rs b/notify/src/poll.rs index a9fa4fec..d7435790 100644 --- a/notify/src/poll.rs +++ b/notify/src/poll.rs @@ -66,6 +66,7 @@ mod data { event::{CreateKind, DataChange, Event, EventKind, MetadataKind, ModifyKind, RemoveKind}, EventHandler, }; + use notify_types::event::EventKindMask; use std::{ cell::RefCell, collections::{hash_map::RandomState, HashMap}, @@ -105,6 +106,7 @@ mod data { event_handler: F, compare_content: bool, scan_emitter: Option, + event_kinds: EventKindMask, ) -> Self where F: EventHandler, @@ -120,7 +122,7 @@ mod data { } }; Self { - emitter: EventEmitter::new(event_handler), + emitter: EventEmitter::new(event_handler, event_kinds), scan_emitter, build_hasher: compare_content.then(RandomState::default), now: Instant::now(), @@ -460,25 +462,33 @@ mod data { } /// Thin wrapper for outer event handler, for easy to use. - struct EventEmitter( + struct EventEmitter { // Use `RefCell` to make sure `emit()` only need shared borrow of self (&self). // Use `Box` to make sure EventEmitter is Sized. - Box>, - ); + handler: Box>, + /// Event kind filter - only events matching this mask are emitted. + event_kinds: EventKindMask, + } impl EventEmitter { - fn new(event_handler: F) -> Self { - Self(Box::new(RefCell::new(event_handler))) + fn new(event_handler: F, event_kinds: EventKindMask) -> Self { + Self { + handler: Box::new(RefCell::new(event_handler)), + event_kinds, + } } - /// Emit single event. + /// Emit single event (errors always pass through). fn emit(&self, event: crate::Result) { - self.0.borrow_mut().handle_event(event); + self.handler.borrow_mut().handle_event(event); } - /// Emit event. + /// Emit event, filtered by event_kinds mask. fn emit_ok(&self, event: Event) { - self.emit(Ok(event)) + // Only emit if the event kind matches the configured mask + if self.event_kinds.matches(&event.kind) { + self.emit(Ok(event)) + } } /// Emit io error event. @@ -552,8 +562,12 @@ impl PollWatcher { config: Config, scan_callback: Option, ) -> crate::Result { - let data_builder = - DataBuilder::new(event_handler, config.compare_contents(), scan_callback); + let data_builder = DataBuilder::new( + event_handler, + config.compare_contents(), + scan_callback, + config.event_kinds(), + ); let (tx, rx) = unbounded(); @@ -813,4 +827,59 @@ mod tests { rx.wait_unordered([expected(&overwritten_file).modify_data_any()]); } + + #[test] + fn poll_watcher_respects_event_kind_mask() { + use crate::{Config, Watcher}; + use notify_types::event::EventKindMask; + + let tmpdir = testdir(); + let (tx, rx) = std::sync::mpsc::channel(); + + // Create watcher with CREATE-only mask (no MODIFY events) + let config = Config::default() + .with_event_kinds(EventKindMask::CREATE) + .with_compare_contents(true) + .with_manual_polling(); + + let mut watcher = PollWatcher::new(tx, config).expect("create watcher"); + watcher + .watch(tmpdir.path(), crate::RecursiveMode::Recursive) + .expect("watch"); + + let path = tmpdir.path().join("test_file"); + + // Create a file - should generate CREATE event + std::fs::write(&path, "initial").expect("write initial"); + watcher.poll().expect("poll 1"); + + // Small delay to let events propagate + std::thread::sleep(std::time::Duration::from_millis(50)); + + // Modify the file - should NOT generate event (filtered by mask) + std::fs::write(&path, "modified content").expect("write modified"); + watcher.poll().expect("poll 2"); + + std::thread::sleep(std::time::Duration::from_millis(50)); + + // Collect all events + let events: Vec<_> = rx + .try_iter() + .filter_map(|r| r.ok()) + .collect(); + + // Should have CREATE event + assert!( + events.iter().any(|e| e.kind.is_create()), + "Expected CREATE event, got: {:?}", + events + ); + + // Should NOT have MODIFY event (filtered out) + assert!( + !events.iter().any(|e| e.kind.is_modify()), + "Should not receive MODIFY events with CREATE-only mask, got: {:?}", + events + ); + } } From 00faf1069faba56267484a0382370c65a87b5f54 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 10:56:45 -0500 Subject: [PATCH 04/11] feat: add userspace EventKindMask filtering to kqueue backend Adds userspace event filtering to the kqueue backend by storing the EventKindMask from Config and checking events against it before delivering to the handler. This follows the same pattern established in PollWatcher. - Store event_kinds in EventLoop struct - Pass event_kinds from Config through the constructor chain - Filter OK events before calling handle_event(), errors pass through - Add test for filtering behavior (requires BSD/macOS to run) --- notify/src/kqueue.rs | 70 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/notify/src/kqueue.rs b/notify/src/kqueue.rs index 9cb16979..5e5dc6e5 100644 --- a/notify/src/kqueue.rs +++ b/notify/src/kqueue.rs @@ -5,7 +5,7 @@ //! pieces of kernel code termed filters. use super::event::*; -use super::{Config, Error, EventHandler, RecursiveMode, Result, Watcher}; +use super::{Config, Error, EventHandler, EventKindMask, RecursiveMode, Result, Watcher}; use crate::{unbounded, Receiver, Sender}; use kqueue::{EventData, EventFilter, FilterFlag, Ident}; use std::collections::HashMap; @@ -35,6 +35,7 @@ struct EventLoop { event_handler: Box, watches: HashMap, follow_symlinks: bool, + event_kinds: EventKindMask, } /// Watcher implementation based on inotify @@ -55,6 +56,7 @@ impl EventLoop { kqueue: kqueue::Watcher, event_handler: Box, follow_symlinks: bool, + event_kinds: EventKindMask, ) -> Result { let (event_loop_tx, event_loop_rx) = unbounded::(); let poll = mio::Poll::new()?; @@ -76,6 +78,7 @@ impl EventLoop { event_handler, watches: HashMap::new(), follow_symlinks, + event_kinds, }; Ok(event_loop) } @@ -276,7 +279,14 @@ impl EventLoop { #[allow(unreachable_patterns)] _ => Ok(Event::new(EventKind::Other)), }; - self.event_handler.handle_event(event); + // Filter events based on EventKindMask + // Errors always pass through, OK events only if they match the mask + match &event { + Ok(e) if !self.event_kinds.matches(&e.kind) => { + // Event filtered out + } + _ => self.event_handler.handle_event(event), + } } // as we don't add any other EVFILTER to kqueue we should never get here kqueue::Event { ident: _, data: _ } => unreachable!(), @@ -378,9 +388,10 @@ impl KqueueWatcher { fn from_event_handler( event_handler: Box, follow_symlinks: bool, + event_kinds: EventKindMask, ) -> Result { let kqueue = kqueue::Watcher::new()?; - let event_loop = EventLoop::new(kqueue, event_handler, follow_symlinks)?; + let event_loop = EventLoop::new(kqueue, event_handler, follow_symlinks, event_kinds)?; let channel = event_loop.event_loop_tx.clone(); let waker = event_loop.event_loop_waker.clone(); event_loop.run(); @@ -433,7 +444,11 @@ impl KqueueWatcher { impl Watcher for KqueueWatcher { /// Create a new watcher. fn new(event_handler: F, config: Config) -> Result { - Self::from_event_handler(Box::new(event_handler), config.follow_symlinks()) + Self::from_event_handler( + Box::new(event_handler), + config.follow_symlinks(), + config.event_kinds(), + ) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { @@ -836,4 +851,51 @@ mod tests { expected(&nested9).create_folder(), ]); } + + #[test] + fn kqueue_watcher_respects_event_kind_mask() { + use crate::Watcher; + use notify_types::event::EventKindMask; + + let tmpdir = testdir(); + let (tx, rx) = std::sync::mpsc::channel(); + + // Create watcher with CREATE-only mask (no MODIFY events) + let config = Config::default().with_event_kinds(EventKindMask::CREATE); + + let mut watcher = KqueueWatcher::new(tx, config).expect("create watcher"); + watcher + .watch(tmpdir.path(), crate::RecursiveMode::Recursive) + .expect("watch"); + + let path = tmpdir.path().join("test_file"); + + // Create a file - should generate CREATE event + std::fs::File::create_new(&path).expect("create"); + + // Small delay to let events propagate + std::thread::sleep(Duration::from_millis(100)); + + // Modify the file - should NOT generate event (filtered by mask) + std::fs::write(&path, "modified content").expect("write modified"); + + std::thread::sleep(Duration::from_millis(100)); + + // Collect all events + let events: Vec<_> = rx.try_iter().filter_map(|r| r.ok()).collect(); + + // Should have CREATE event + assert!( + events.iter().any(|e| e.kind.is_create()), + "Expected CREATE event, got: {:?}", + events + ); + + // Should NOT have MODIFY event (filtered out) + assert!( + !events.iter().any(|e| e.kind.is_modify()), + "Should not receive MODIFY events with CREATE-only mask, got: {:?}", + events + ); + } } From 6b459368b870c03e93897407e83d855201cbd5b3 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 11:05:49 -0500 Subject: [PATCH 05/11] feat: add userspace EventKindMask filtering to FSEvents backend Add event filtering to the FSEvents (macOS) backend by calling EventKindMask::matches() before delivering events to the handler. This follows the same pattern established in kqueue and PollWatcher. Changes: - Add event_kinds field to FsEventWatcher and StreamContextInfo - Pass event_kinds from Config through to the callback context - Filter events in callback_impl based on the mask - Add test for filtering behavior --- notify/src/fsevent.rs | 67 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/notify/src/fsevent.rs b/notify/src/fsevent.rs index 09c7a60a..caac5155 100644 --- a/notify/src/fsevent.rs +++ b/notify/src/fsevent.rs @@ -16,7 +16,8 @@ use crate::event::*; use crate::{ - unbounded, Config, Error, EventHandler, PathsMut, RecursiveMode, Result, Sender, Watcher, + unbounded, Config, Error, EventHandler, EventKindMask, PathsMut, RecursiveMode, Result, Sender, + Watcher, }; use objc2_core_foundation as cf; use objc2_core_services as fs; @@ -68,6 +69,7 @@ pub struct FsEventWatcher { event_handler: Arc>, runloop: Option<(cf::CFRetained, thread::JoinHandle<()>)>, recursive_info: HashMap, + event_kinds: EventKindMask, } impl fmt::Debug for FsEventWatcher { @@ -244,6 +246,7 @@ fn translate_flags(flags: StreamFlags, precise: bool) -> Vec { struct StreamContextInfo { event_handler: Arc>, recursive_info: HashMap, + event_kinds: EventKindMask, } // Free the context when the stream created by `FSEventStreamCreate` is released. @@ -283,7 +286,10 @@ impl PathsMut for FsEventPathsMut<'_> { } impl FsEventWatcher { - fn from_event_handler(event_handler: Arc>) -> Result { + fn from_event_handler( + event_handler: Arc>, + event_kinds: EventKindMask, + ) -> Result { Ok(FsEventWatcher { paths: cf::CFMutableArray::empty(), since_when: fs::kFSEventStreamEventIdSinceNow, @@ -292,6 +298,7 @@ impl FsEventWatcher { event_handler, runloop: None, recursive_info: HashMap::new(), + event_kinds, }) } @@ -402,6 +409,7 @@ impl FsEventWatcher { let context = Box::into_raw(Box::new(StreamContextInfo { event_handler: self.event_handler.clone(), recursive_info: self.recursive_info.clone(), + event_kinds: self.event_kinds, })); let stream_context = fs::FSEventStreamContext { @@ -571,6 +579,10 @@ unsafe fn callback_impl( for ev in translate_flags(flag, true).into_iter() { // TODO: precise let ev = ev.add_path(path.clone()); + // Filter events based on EventKindMask + if !(*info).event_kinds.matches(&ev.kind) { + continue; // Skip events that don't match the mask + } let mut event_handler = event_handler.lock().expect("lock not to be poisoned"); event_handler.handle_event(Ok(ev)); } @@ -579,8 +591,8 @@ unsafe fn callback_impl( impl Watcher for FsEventWatcher { /// Create a new watcher. - fn new(event_handler: F, _config: Config) -> Result { - Self::from_event_handler(Arc::new(Mutex::new(event_handler))) + fn new(event_handler: F, config: Config) -> Result { + Self::from_event_handler(Arc::new(Mutex::new(event_handler)), config.event_kinds()) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { @@ -1074,6 +1086,53 @@ mod tests { ]); } + #[test] + fn fsevent_watcher_respects_event_kind_mask() { + use crate::Watcher; + use notify_types::event::EventKindMask; + + let tmpdir = testdir(); + let (tx, rx) = std::sync::mpsc::channel(); + + // Create watcher with CREATE-only mask (no MODIFY events) + let config = Config::default().with_event_kinds(EventKindMask::CREATE); + + let mut watcher = FsEventWatcher::new(tx, config).expect("create watcher"); + watcher + .watch(tmpdir.path(), crate::RecursiveMode::Recursive) + .expect("watch"); + + let path = tmpdir.path().join("test_file"); + + // Create a file - should generate CREATE event + std::fs::File::create_new(&path).expect("create"); + + // Small delay to let events propagate + std::thread::sleep(Duration::from_millis(100)); + + // Modify the file - should NOT generate event (filtered by mask) + std::fs::write(&path, "modified content").expect("write modified"); + + std::thread::sleep(Duration::from_millis(100)); + + // Collect all events + let events: Vec<_> = rx.try_iter().filter_map(|r| r.ok()).collect(); + + // Should have CREATE event + assert!( + events.iter().any(|e| e.kind.is_create()), + "Expected CREATE event, got: {:?}", + events + ); + + // Should NOT have MODIFY event (filtered out) + assert!( + !events.iter().any(|e| e.kind.is_modify()), + "Should not receive MODIFY events with CREATE-only mask, got: {:?}", + events + ); + } + // fsevents seems to not allow watching more than 4096 paths at once. // https://github.com/fsnotify/fsevents/issues/48 // Based on https://github.com/fsnotify/fsevents/commit/3899270de121c963202e6fed46aa31d5ec7b3908 From bdbf8aace417642189c5e6f608af355bb4370eb4 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 11:18:33 -0500 Subject: [PATCH 06/11] feat: add userspace EventKindMask filtering to Windows backend Thread EventKindMask through ReadDirectoryChangesServer and start_read to filter events before delivery. Events that don't match the mask are silently dropped, while errors always pass through. --- notify/src/windows.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/notify/src/windows.rs b/notify/src/windows.rs index 8a3bf894..c7c1e9ce 100644 --- a/notify/src/windows.rs +++ b/notify/src/windows.rs @@ -49,6 +49,7 @@ struct ReadData { struct ReadDirectoryRequest { event_handler: Arc>, + event_kinds: EventKindMask, buffer: [u8; BUF_SIZE as usize], handle: HANDLE, data: ReadData, @@ -83,6 +84,7 @@ struct ReadDirectoryChangesServer { tx: Sender, rx: Receiver, event_handler: Arc>, + event_kinds: EventKindMask, meta_tx: Sender, cmd_tx: Sender>, watches: HashMap, @@ -92,6 +94,7 @@ struct ReadDirectoryChangesServer { impl ReadDirectoryChangesServer { fn start( event_handler: Arc>, + event_kinds: EventKindMask, meta_tx: Sender, cmd_tx: Sender>, wakeup_sem: HANDLE, @@ -109,6 +112,7 @@ impl ReadDirectoryChangesServer { tx, rx: action_rx, event_handler, + event_kinds, meta_tx, cmd_tx, watches: HashMap::new(), @@ -236,7 +240,7 @@ impl ReadDirectoryChangesServer { complete_sem: semaphore, }; self.watches.insert(path.clone(), ws); - start_read(&rd, self.event_handler.clone(), handle, self.tx.clone()); + start_read(&rd, self.event_handler.clone(), self.event_kinds, handle, self.tx.clone()); Ok(path) } @@ -270,11 +274,13 @@ fn stop_watch(ws: &WatchState, meta_tx: &Sender) { fn start_read( rd: &ReadData, event_handler: Arc>, + event_kinds: EventKindMask, handle: HANDLE, action_tx: Sender, ) { let request = Box::new(ReadDirectoryRequest { event_handler, + event_kinds, handle, buffer: [0u8; BUF_SIZE as usize], data: rd.clone(), @@ -371,6 +377,7 @@ unsafe extern "system" fn handle_event( start_read( &request.data, request.event_handler.clone(), + request.event_kinds, request.handle, request.action_tx, ); @@ -420,7 +427,18 @@ unsafe extern "system" fn handle_event( } } - let event_handler = |res| emit_event(&request.event_handler, res); + // Filter events based on EventKindMask + let event_kinds = request.event_kinds; + let event_handler = |res: Result| { + match &res { + // Errors always pass through + Err(_) => emit_event(&request.event_handler, res), + // OK events only if they match the mask + Ok(e) if event_kinds.matches(&e.kind) => emit_event(&request.event_handler, res), + // Event filtered out + Ok(_) => {} + } + }; if cur_entry.Action == FILE_ACTION_RENAMED_OLD_NAME { let mode = RenameMode::From; @@ -474,6 +492,7 @@ pub struct ReadDirectoryChangesWatcher { impl ReadDirectoryChangesWatcher { pub fn create( event_handler: Arc>, + event_kinds: EventKindMask, meta_tx: Sender, ) -> Result { let (cmd_tx, cmd_rx) = unbounded(); @@ -484,7 +503,7 @@ impl ReadDirectoryChangesWatcher { } let action_tx = - ReadDirectoryChangesServer::start(event_handler, meta_tx, cmd_tx, wakeup_sem); + ReadDirectoryChangesServer::start(event_handler, event_kinds, meta_tx, cmd_tx, wakeup_sem); Ok(ReadDirectoryChangesWatcher { tx: action_tx, @@ -560,12 +579,12 @@ impl ReadDirectoryChangesWatcher { } impl Watcher for ReadDirectoryChangesWatcher { - fn new(event_handler: F, _config: Config) -> Result { + fn new(event_handler: F, config: Config) -> Result { // create dummy channel for meta event // TODO: determine the original purpose of this - can we remove it? let (meta_tx, _) = unbounded(); let event_handler = Arc::new(Mutex::new(event_handler)); - Self::create(event_handler, meta_tx) + Self::create(event_handler, config.event_kinds(), meta_tx) } fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> { From f0fc638218e342647755fa241bb8d96caae44f06 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 11:27:52 -0500 Subject: [PATCH 07/11] test: fix ChannelConfig default to use EventKindMask::ALL Change test infrastructure ChannelConfig::default() to use Config::default() which includes EventKindMask::ALL instead of CORE. This ensures tests cover all event types including Access events. Updated inotify tests to use wait_ordered() instead of wait_ordered_exact() where appropriate, since with ALL events we may get directory access events that are timing-dependent. Tests that check for absence of specific events now filter out Access events to focus on the behavior being tested. --- notify/src/inotify.rs | 74 ++++++++++++++++++++++++++++++------------- notify/src/test.rs | 8 ++--- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs index 03c0303e..3662e6af 100644 --- a/notify/src/inotify.rs +++ b/notify/src/inotify.rs @@ -686,7 +686,9 @@ mod tests { }; use super::inotify_sys::WatchMask; - use super::{Config, Error, ErrorKind, Event, INotifyWatcher, RecursiveMode, Result, Watcher}; + use super::{ + Config, Error, ErrorKind, Event, EventKind, INotifyWatcher, RecursiveMode, Result, Watcher, + }; use notify_types::event::EventKindMask; use crate::test::*; @@ -924,7 +926,9 @@ mod tests { watcher.watch_recursively(&tmpdir); file.set_permissions(permissions).expect("set_permissions"); - rx.wait_ordered_exact([expected(&path).modify_meta_any()]); + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([expected(&path).modify_meta_any()]); } #[test] @@ -940,13 +944,14 @@ mod tests { std::fs::rename(&path, &new_path).expect("rename"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).rename_from(), expected(&new_path).rename_to(), expected([path, new_path]).rename_both(), ]) - .ensure_trackers_len(1) - .ensure_no_tail(); + .ensure_trackers_len(1); } #[test] @@ -1019,7 +1024,9 @@ mod tests { let path = tmpdir.path().join("entry"); std::fs::create_dir(&path).expect("create"); - rx.wait_ordered_exact([expected(&path).create_folder()]); + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([expected(&path).create_folder()]); } #[test] @@ -1035,12 +1042,13 @@ mod tests { watcher.watch_recursively(&tmpdir); std::fs::set_permissions(&path, permissions).expect("set_permissions"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any().optional(), expected(&path).modify_meta_any(), expected(&path).modify_meta_any(), - ]) - .ensure_no_tail(); + ]); } #[test] @@ -1056,7 +1064,9 @@ mod tests { std::fs::rename(&path, &new_path).expect("rename"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any().optional(), expected(&path).rename_from(), expected(&new_path).rename_to(), @@ -1076,11 +1086,12 @@ mod tests { watcher.watch_recursively(&tmpdir); std::fs::remove_dir(&path).expect("remove"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any().optional(), expected(&path).remove_folder(), - ]) - .ensure_no_tail(); + ]); } #[test] @@ -1127,11 +1138,17 @@ mod tests { std::fs::rename(&path, &new_path).expect("rename"); - let event = rx.recv(); + // With ALL events, we may get Access events on the directory. + // Skip Access events to find the rename event. + let event = loop { + let event = rx.recv(); + if !matches!(event.kind, EventKind::Access(_)) { + break event; + } + }; let tracker = event.attrs.tracker(); - assert_eq!(event, expected(path).rename_from()); - assert!(tracker.is_some(), "tracker is none: [event:#?]"); - rx.ensure_empty(); + assert_eq!(event, expected(&path).rename_from()); + assert!(tracker.is_some(), "tracker is none: {event:#?}"); } #[test] @@ -1187,7 +1204,9 @@ mod tests { std::fs::rename(&path, &new_path1).expect("rename1"); std::fs::rename(&new_path1, &new_path2).expect("rename2"); - rx.wait_ordered_exact([ + // Use wait_ordered (not _exact) because with ALL events we may get + // directory access events that are timing-dependent + rx.wait_ordered([ expected(&path).access_open_any().optional(), expected(&path).rename_from(), expected(&new_path1).rename_to(), @@ -1197,7 +1216,6 @@ mod tests { expected(&new_path2).rename_to(), expected([&new_path1, &new_path2]).rename_both(), ]) - .ensure_no_tail() .ensure_trackers_len(2); } @@ -1218,8 +1236,15 @@ mod tests { ) .expect("set_time"); - assert_eq!(rx.recv(), expected(&path).modify_data_any()); - rx.ensure_empty(); + // With ALL events, we may get Access events on the directory. + // Skip Access events to find the modify event. + let event = loop { + let event = rx.recv(); + if !matches!(event.kind, EventKind::Access(_)) { + break event; + } + }; + assert_eq!(event, expected(&path).modify_data_any()); } #[test] @@ -1322,7 +1347,12 @@ mod tests { std::fs::write(&hardlink, "123123").expect("write to the hard link"); - let events = rx.iter().collect::>(); + // With ALL events, we may get Access events on the watched directory. + // Filter those out - we only care about non-Access events on the file. + let events: Vec<_> = rx + .iter() + .filter(|e| !matches!(e.kind, EventKind::Access(_))) + .collect(); assert!(events.is_empty(), "unexpected events: {events:#?}"); } diff --git a/notify/src/test.rs b/notify/src/test.rs index 74dc2763..361370d5 100644 --- a/notify/src/test.rs +++ b/notify/src/test.rs @@ -9,7 +9,7 @@ use std::{ time::{Duration, Instant}, }; -use notify_types::event::{Event, EventKindMask}; +use notify_types::event::Event; use crate::{Config, Error, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; use pretty_assertions::assert_eq; @@ -283,9 +283,9 @@ impl Default for ChannelConfig { fn default() -> Self { Self { timeout: Receiver::DEFAULT_TIMEOUT, - // Use CORE to exclude access events by default in tests, - // matching the original test behavior before EventKindMask was added - watcher_config: Config::default().with_event_kinds(EventKindMask::CORE), + // Use Config::default() which uses EventKindMask::ALL, + // ensuring tests cover all event types including Access events + watcher_config: Config::default(), } } } From e46c58ad14a7ffec536a239881b35961c5c82553 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 11:29:59 -0500 Subject: [PATCH 08/11] docs: fix incorrect kqueue kernel-level filtering documentation kqueue uses userspace filtering via EventKindMask::matches(), only inotify has kernel-level support via WatchMask translation. Updated documentation in: - notify-types/src/event.rs: EventKindMask doc comment - notify/src/config.rs: with_event_kinds() doc comment --- notify-types/src/event.rs | 6 +++--- notify/src/config.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/notify-types/src/event.rs b/notify-types/src/event.rs index 71ae166f..5c77c138 100644 --- a/notify-types/src/event.rs +++ b/notify-types/src/event.rs @@ -278,9 +278,9 @@ bitflags! { /// A bitmask specifying which event kinds to monitor. /// /// This type allows fine-grained control over which filesystem events are reported. - /// On backends that support kernel-level filtering (inotify, kqueue), the mask is - /// translated to native flags for optimal performance. On other backends (Windows, - /// FSEvents, PollWatcher), filtering is applied in userspace. + /// On backends that support kernel-level filtering (inotify), the mask is + /// translated to native flags for optimal performance. On other backends (kqueue, + /// Windows, FSEvents, PollWatcher), filtering is applied in userspace. /// /// # Examples /// diff --git a/notify/src/config.rs b/notify/src/config.rs index 89bf7ff8..fe0888d0 100644 --- a/notify/src/config.rs +++ b/notify/src/config.rs @@ -121,9 +121,9 @@ impl Config { /// /// This allows you to control which types of filesystem events are delivered /// to your event handler. On backends that support kernel-level filtering - /// (inotify, kqueue), the mask is translated to native flags for optimal - /// performance. On other backends (Windows, FSEvents, PollWatcher), filtering - /// is applied in userspace. + /// (inotify), the mask is translated to native flags for optimal + /// performance. On other backends (kqueue, Windows, FSEvents, PollWatcher), + /// filtering is applied in userspace. /// /// The default is [`EventKindMask::ALL`], which includes all events. /// Use [`EventKindMask::CORE`] to exclude access events. From 31d34ab7fe0c19d3973f023bd24e8039624ca6df Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Sat, 13 Dec 2025 15:34:15 -0500 Subject: [PATCH 09/11] test: fix flaky poll_watcher_respects_event_kind_mask test Use blocking recv_timeout instead of try_iter to avoid race condition between poll thread processing the message and test collecting events. --- notify/src/poll.rs | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/notify/src/poll.rs b/notify/src/poll.rs index d7435790..878f1303 100644 --- a/notify/src/poll.rs +++ b/notify/src/poll.rs @@ -832,6 +832,7 @@ mod tests { fn poll_watcher_respects_event_kind_mask() { use crate::{Config, Watcher}; use notify_types::event::EventKindMask; + use std::time::Duration; let tmpdir = testdir(); let (tx, rx) = std::sync::mpsc::channel(); @@ -853,33 +854,30 @@ mod tests { std::fs::write(&path, "initial").expect("write initial"); watcher.poll().expect("poll 1"); - // Small delay to let events propagate - std::thread::sleep(std::time::Duration::from_millis(50)); + // Wait for CREATE event (use blocking recv with timeout) + let event = rx + .recv_timeout(Duration::from_secs(1)) + .expect("should receive CREATE event") + .expect("event should not be an error"); + assert!( + event.kind.is_create(), + "Expected CREATE event, got: {:?}", + event + ); // Modify the file - should NOT generate event (filtered by mask) std::fs::write(&path, "modified content").expect("write modified"); watcher.poll().expect("poll 2"); - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Collect all events - let events: Vec<_> = rx - .try_iter() - .filter_map(|r| r.ok()) - .collect(); - - // Should have CREATE event - assert!( - events.iter().any(|e| e.kind.is_create()), - "Expected CREATE event, got: {:?}", - events - ); + // Give poll thread time to process, then verify no MODIFY events + std::thread::sleep(Duration::from_millis(100)); - // Should NOT have MODIFY event (filtered out) + // Should have no more events (MODIFY was filtered out) + let remaining: Vec<_> = rx.try_iter().filter_map(|r| r.ok()).collect(); assert!( - !events.iter().any(|e| e.kind.is_modify()), + !remaining.iter().any(|e| e.kind.is_modify()), "Should not receive MODIFY events with CREATE-only mask, got: {:?}", - events + remaining ); } } From 133cc3bea84a2c5ffe9723442c2d41560cc616cd Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Tue, 23 Dec 2025 15:04:58 -0500 Subject: [PATCH 10/11] Enables recursive directory watching Recursive watches now always register for CREATE/MOVED_TO at the kernel level, then filter in userspace before delivery. This ensures subdirectory tracking works regardless of the user's EventKindMask. --- notify-types/src/event.rs | 5 +-- notify/src/inotify.rs | 80 ++++++++++++++++++++++++++++++++++----- notify/src/windows.rs | 21 ++++++++-- 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/notify-types/src/event.rs b/notify-types/src/event.rs index 5c77c138..7fc449fd 100644 --- a/notify-types/src/event.rs +++ b/notify-types/src/event.rs @@ -400,9 +400,8 @@ impl EventKindMask { self.intersects(EventKindMask::ACCESS_CLOSE_NOWRITE) } // Close with unknown mode - match if either close flag is set - AccessKind::Close(_) => { - self.intersects(EventKindMask::ACCESS_CLOSE | EventKindMask::ACCESS_CLOSE_NOWRITE) - } + AccessKind::Close(_) => self + .intersects(EventKindMask::ACCESS_CLOSE | EventKindMask::ACCESS_CLOSE_NOWRITE), // AccessKind::Read, Any, and Other pass if any access flag is set AccessKind::Read | AccessKind::Any | AccessKind::Other => { self.intersects(EventKindMask::ALL_ACCESS) diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs index 3662e6af..7554772b 100644 --- a/notify/src/inotify.rs +++ b/notify/src/inotify.rs @@ -25,11 +25,15 @@ const MESSAGE: mio::Token = mio::Token(1); /// Convert an EventKindMask to the corresponding inotify WatchMask. /// -/// This enables kernel-level event filtering by only registering for the -/// event types the user requested. -fn event_kind_mask_to_watch_mask(mask: EventKindMask) -> WatchMask { +/// When `is_recursive` is true, CREATE and MOVED_TO are always included +/// to enable tracking of newly created subdirectories. +fn event_kind_mask_to_watch_mask(mask: EventKindMask, is_recursive: bool) -> WatchMask { let mut watch_mask = WatchMask::empty(); + if is_recursive { + watch_mask |= WatchMask::CREATE | WatchMask::MOVED_TO; + } + if mask.intersects(EventKindMask::CREATE) { watch_mask |= WatchMask::CREATE | WatchMask::MOVED_TO; } @@ -403,8 +407,11 @@ impl EventLoop { ); } + // Filter events based on EventKindMask before delivery for ev in evs { - self.event_handler.handle_event(Ok(ev)); + if self.event_kind_mask.matches(&ev.kind) { + self.event_handler.handle_event(Ok(ev)); + } } } @@ -473,7 +480,7 @@ impl EventLoop { watch_self: bool, ) -> Result<()> { // Build watch mask from configured event kinds for kernel-level filtering - let mut watchmask = event_kind_mask_to_watch_mask(self.event_kind_mask); + let mut watchmask = event_kind_mask_to_watch_mask(self.event_kind_mask, is_recursive); if watch_self { watchmask.insert(WatchMask::DELETE_SELF); @@ -1457,7 +1464,7 @@ mod tests { use super::event_kind_mask_to_watch_mask; let mask = EventKindMask::CORE; - let watch_mask = event_kind_mask_to_watch_mask(mask); + let watch_mask = event_kind_mask_to_watch_mask(mask, false); // CORE includes CREATE, REMOVE, MODIFY_DATA, MODIFY_META, MODIFY_NAME assert!(watch_mask.intersects(WatchMask::CREATE)); @@ -1480,7 +1487,7 @@ mod tests { use super::event_kind_mask_to_watch_mask; let mask = EventKindMask::ALL; - let watch_mask = event_kind_mask_to_watch_mask(mask); + let watch_mask = event_kind_mask_to_watch_mask(mask, false); // ALL includes everything from CORE plus ACCESS assert!(watch_mask.intersects(WatchMask::OPEN)); @@ -1493,7 +1500,7 @@ mod tests { use super::event_kind_mask_to_watch_mask; let mask = EventKindMask::empty(); - let watch_mask = event_kind_mask_to_watch_mask(mask); + let watch_mask = event_kind_mask_to_watch_mask(mask, false); // Empty mask should produce empty watch mask assert!(watch_mask.is_empty()); @@ -1505,7 +1512,7 @@ mod tests { // ACCESS_CLOSE only maps to CLOSE_WRITE (not CLOSE_NOWRITE) let mask = EventKindMask::ACCESS_OPEN | EventKindMask::ACCESS_CLOSE; - let watch_mask = event_kind_mask_to_watch_mask(mask); + let watch_mask = event_kind_mask_to_watch_mask(mask, false); assert!(watch_mask.intersects(WatchMask::OPEN)); assert!(watch_mask.intersects(WatchMask::CLOSE_WRITE)); @@ -1524,10 +1531,63 @@ mod tests { // ALL_ACCESS includes OPEN, CLOSE_WRITE, and CLOSE_NOWRITE let mask = EventKindMask::ALL_ACCESS; - let watch_mask = event_kind_mask_to_watch_mask(mask); + let watch_mask = event_kind_mask_to_watch_mask(mask, false); assert!(watch_mask.intersects(WatchMask::OPEN)); assert!(watch_mask.intersects(WatchMask::CLOSE_WRITE)); assert!(watch_mask.intersects(WatchMask::CLOSE_NOWRITE)); } + + #[test] + fn event_kind_mask_to_watch_mask_recursive_includes_create() { + use super::event_kind_mask_to_watch_mask; + + // Recursive mode includes CREATE|MOVED_TO even without CREATE in mask + let watch_mask = event_kind_mask_to_watch_mask(EventKindMask::MODIFY_DATA, true); + assert!(watch_mask.intersects(WatchMask::CREATE)); + assert!(watch_mask.intersects(WatchMask::MOVED_TO)); + assert!(watch_mask.intersects(WatchMask::MODIFY)); + + // Empty mask with recursive still includes CREATE|MOVED_TO + let watch_mask = event_kind_mask_to_watch_mask(EventKindMask::empty(), true); + assert!(watch_mask.intersects(WatchMask::CREATE)); + assert!(watch_mask.intersects(WatchMask::MOVED_TO)); + assert!(!watch_mask.intersects(WatchMask::MODIFY)); + + // Non-recursive mode does not include CREATE when not requested + let watch_mask = event_kind_mask_to_watch_mask(EventKindMask::MODIFY_DATA, false); + assert!(!watch_mask.intersects(WatchMask::CREATE)); + assert!(!watch_mask.intersects(WatchMask::MOVED_TO)); + assert!(watch_mask.intersects(WatchMask::MODIFY)); + } + + /// Recursive watches with MODIFY-only mask still track new subdirectories. + #[test] + fn recursive_watch_tracks_subdirs_without_create_mask() { + let tmpdir = testdir(); + let (mut watcher, mut rx) = channel_with_config::( + ChannelConfig::default() + .with_timeout(std::time::Duration::from_secs(2)) + .with_watcher_config(Config::default().with_event_kinds(EventKindMask::MODIFY_DATA)), + ); + watcher.watch_recursively(&tmpdir); + + let subdir = tmpdir.path().join("subdir"); + std::fs::create_dir(&subdir).expect("create subdir"); + + // Wait for watch to be added on new subdirectory + std::thread::sleep(std::time::Duration::from_millis(50)); + + let file_path = subdir.join("file.txt"); + let mut file = std::fs::File::create_new(&file_path).expect("create file"); + + use std::io::Write; + file.write_all(b"hello").expect("write"); + file.flush().expect("flush"); + drop(file); + + // Receives MODIFY from subdir (tracking works), no CREATE events (filtered) + rx.wait_ordered_exact([expected(&file_path).modify_data()]) + .ensure_no_tail(); + } } diff --git a/notify/src/windows.rs b/notify/src/windows.rs index c7c1e9ce..453f3cad 100644 --- a/notify/src/windows.rs +++ b/notify/src/windows.rs @@ -240,7 +240,13 @@ impl ReadDirectoryChangesServer { complete_sem: semaphore, }; self.watches.insert(path.clone(), ws); - start_read(&rd, self.event_handler.clone(), self.event_kinds, handle, self.tx.clone()); + start_read( + &rd, + self.event_handler.clone(), + self.event_kinds, + handle, + self.tx.clone(), + ); Ok(path) } @@ -434,7 +440,9 @@ unsafe extern "system" fn handle_event( // Errors always pass through Err(_) => emit_event(&request.event_handler, res), // OK events only if they match the mask - Ok(e) if event_kinds.matches(&e.kind) => emit_event(&request.event_handler, res), + Ok(e) if event_kinds.matches(&e.kind) => { + emit_event(&request.event_handler, res) + } // Event filtered out Ok(_) => {} } @@ -502,8 +510,13 @@ impl ReadDirectoryChangesWatcher { return Err(Error::generic("Failed to create wakeup semaphore.")); } - let action_tx = - ReadDirectoryChangesServer::start(event_handler, event_kinds, meta_tx, cmd_tx, wakeup_sem); + let action_tx = ReadDirectoryChangesServer::start( + event_handler, + event_kinds, + meta_tx, + cmd_tx, + wakeup_sem, + ); Ok(ReadDirectoryChangesWatcher { tx: action_tx, From 37de409e5a33a9550c418e6007ee4c262ffa0fe8 Mon Sep 17 00:00:00 2001 From: Ryan Stortz Date: Wed, 21 Jan 2026 17:18:12 -0500 Subject: [PATCH 11/11] Fix formatting --- notify/src/inotify.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs index 7554772b..4e6a170b 100644 --- a/notify/src/inotify.rs +++ b/notify/src/inotify.rs @@ -1568,7 +1568,9 @@ mod tests { let (mut watcher, mut rx) = channel_with_config::( ChannelConfig::default() .with_timeout(std::time::Duration::from_secs(2)) - .with_watcher_config(Config::default().with_event_kinds(EventKindMask::MODIFY_DATA)), + .with_watcher_config( + Config::default().with_event_kinds(EventKindMask::MODIFY_DATA), + ), ); watcher.watch_recursively(&tmpdir);