Repository: notify-rs/notify (2025-07-16T08:58:41Z)

Issue:
notify-debouncer-full panic on Linux with inotify
If no paths are included on an `Event`, `notify-debouncer-full` panics due to this line.  https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/src/lib.rs#L274

It appear that `INotifyWatcher` on Linux sometimes includes events with no paths.

This is rare, but I'm seeing it happen in telemetry 50 times a day.

I could take a crack at addressing this issue, but I'm not sure whether the fix would be to:

- Suppress all events from `INotifyWatcher` that do not include paths. OR
- Make `notify-debouncer-full` handle events safely that don't include paths.


Discussion:
Thanks for filing this issue. Do you happen to know what kind of events these are?
I don't know if we should just ignore them, but the issue you found in notify-debouncer-full is definitely a bug. I assumed that all events had a path. I guess we should just ignore those without paths then.
I'm sorry to say that I don't know what kind of events these are.

Looking at the watcher code, it looks like it could be almost any kind of event if the working directory was not tracked by the watcher.

https://github.com/notify-rs/notify/blob/main/notify/src/inotify.rs#L221

I'll create a first draft for a PR to ignore these events.
I think ignoring is a mistake (IMHO).

There is one case when event without paths is expected - when `inotify` queue is overflowed. User should be able to handle this, because it means some events might be lost. 

[There is a good explanation on `Flag` enum](https://github.com/notify-rs/notify/blob/04473dea90a88c3444ff1a2876c4fbeda479441d/notify-types/src/event.rs#L501).

[There is `inotify` code, which triggers this event](https://github.com/notify-rs/notify/blob/04473dea90a88c3444ff1a2876c4fbeda479441d/notify/src/inotify.rs#L216) 
Oh, I was wrong, sorry about that.

The checking of `need_rescan` is before the ignoring an event in the PR.

But, after some investigation, I think it might happen because `path` doesn't have the taken descriptor.
Some code is related to [that](https://github.com/notify-rs/notify/blob/394ef18bebf0063c717ff7ea7387d36dacaf2c1c/notify/src/inotify.rs#L221)

I'm going to try reproducing it tomorrow because my task is strongly related to the `INotifyWatcher` and I think the problem either the race condition (descriptor was deleted from paths but event has already been got) or some event types from `inotify` was ignored but related. 

It looks like the first is likely to be true.

I do not know, if I'll be success or not, because the only way to reproduce I can imagine is
* Watch a dir
* -- Race condition start
* Unwatch the dir
* Got an event
* -- Race condition end

If `unwatch` is performed before handling inotify event, the behavior described in the issue will happen.

Anyway, I think it is a bug in `notify_debouncer_full` AND in `inotify`. 
* `Debouncer` should ignore that kind of events, because it doesn't know what should be done with them.
* `INotifyWatcher` should ignore that events if it is definitely race condition.

If I am able to understand what the problem exactly is I'll create a PR that fixes `notify_debouncer_full` and `inotify`
OK, it was races

[This test](https://github.com/notify-rs/notify/pull/700/files#diff-9663de7aadbfa93d9f87e8c0dc5c94855e59fff314c091caa5478b666e404983R709) did not pass before the fix


Thanks for the fix.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f568a47f..2d06d074 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,10 @@
 
 ## notify 8.2.0 (unreleased)
 - FEATURE: notify a user if the `max_user_watches` has been reached implicitly (`INotifyWatcher`) [#698]
+- FIX: `INotifyWatcher` ignores events with unknown watch descriptors (instead of `EventMask::Q_OVERFLOW`) [#700]
 
 [#698]: https://github.com/notify-rs/notify/pull/698
+[#700]: https://github.com/notify-rs/notify/pull/700
 
 ## notify 8.1.0 (2025-07-03)
 - FEATURE: added support for the [`flume`](https://docs.rs/flume) crate
diff --git a/notify-debouncer-full/src/lib.rs b/notify-debouncer-full/src/lib.rs
index 8c1f9d37..8c8c8a0e 100644
--- a/notify-debouncer-full/src/lib.rs
+++ b/notify-debouncer-full/src/lib.rs
@@ -279,7 +279,13 @@ impl<T: FileIdCache> DebounceDataInner<T> {
             return;
         }
 
-        let path = &event.paths[0];
+        let path = match event.paths.first() {
+            Some(path) => path,
+            None => {
+                log::info!("skipping event with no paths: {event:?}");
+                return;
+            }
+        };
 
         match &event.kind {
             EventKind::Create(_) => {
@@ -775,6 +781,7 @@ mod tests {
             "add_create_event",
             "add_create_event_after_remove_event",
             "add_create_dir_event_twice",
+            "add_event_with_no_paths_is_ok",
             "add_modify_content_event_after_create_event",
             "add_rename_from_event",
             "add_rename_from_event_after_create_event",
diff --git a/notify/src/inotify.rs b/notify/src/inotify.rs
index f2b804e3..fd27a39a 100644
--- a/notify/src/inotify.rs
+++ b/notify/src/inotify.rs
@@ -59,18 +59,16 @@ enum EventLoopMsg {
 
 #[inline]
 fn add_watch_by_event(
-    path: &Option<PathBuf>,
+    path: &PathBuf,
     event: &inotify_sys::Event<&OsStr>,
     watches: &HashMap<PathBuf, (WatchDescriptor, WatchMask, bool, bool)>,
     add_watches: &mut Vec<PathBuf>,
 ) {
-    if let Some(ref path) = *path {
-        if event.mask.contains(EventMask::ISDIR) {
-            if let Some(parent_path) = path.parent() {
-                if let Some(&(_, _, is_recursive, _)) = watches.get(parent_path) {
-                    if is_recursive {
-                        add_watches.push(path.to_owned());
-                    }
+    if event.mask.contains(EventMask::ISDIR) {
+        if let Some(parent_path) = path.parent() {
+            if let Some(&(_, _, is_recursive, _)) = watches.get(parent_path) {
+                if is_recursive {
+                    add_watches.push(path.to_owned());
                 }
             }
         }
@@ -79,14 +77,12 @@ fn add_watch_by_event(
 
 #[inline]
 fn remove_watch_by_event(
-    path: &Option<PathBuf>,
+    path: &PathBuf,
     watches: &HashMap<PathBuf, (WatchDescriptor, WatchMask, bool, bool)>,
     remove_watches: &mut Vec<PathBuf>,
 ) {
-    if let Some(ref path) = *path {
-        if watches.contains_key(path) {
-            remove_watches.push(path.to_owned());
-        }
+    if watches.contains_key(path) {
+        remove_watches.push(path.to_owned());
     }
 }
 
@@ -223,6 +219,14 @@ impl EventLoop {
                                 None => self.paths.get(&event.wd).cloned(),
                             };
 
+                            let path = match path {
+                                Some(path) => path,
+                                None => {
+                                    log::debug!("inotify event with unknown descriptor: {event:?}");
+                                    continue;
+                                }
+                            };
+
                             let mut evs = Vec::new();
 
                             if event.mask.contains(EventMask::MOVED_FROM) {
@@ -231,7 +235,7 @@ impl EventLoop {
                                 let event = Event::new(EventKind::Modify(ModifyKind::Name(
                                     RenameMode::From,
                                 )))
-                                .add_some_path(path.clone())
+                                .add_path(path.clone())
                                 .set_tracker(event.cookie as usize);
 
                                 self.rename_event = Some(event.clone());
@@ -241,7 +245,7 @@ impl EventLoop {
                                 evs.push(
                                     Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::To)))
                                         .set_tracker(event.cookie as usize)
-                                        .add_some_path(path.clone()),
+                                        .add_path(path.clone()),
                                 );
 
                                 let trackers_match =
@@ -256,7 +260,7 @@ impl EventLoop {
                                         )))
                                         .set_tracker(event.cookie as usize)
                                         .add_some_path(rename_event.paths.first().cloned())
-                                        .add_some_path(path.clone()),
+                                        .add_path(path.clone()),
                                     );
                                 }
                                 add_watch_by_event(&path, &event, &self.watches, &mut add_watches);
@@ -266,7 +270,7 @@ impl EventLoop {
                                     Event::new(EventKind::Modify(ModifyKind::Name(
                                         RenameMode::From,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                                 // TODO stat the path and get to new path
                                 // - emit To and Both events
@@ -281,7 +285,7 @@ impl EventLoop {
                                             CreateKind::File
                                         },
                                     ))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                                 add_watch_by_event(&path, &event, &self.watches, &mut add_watches);
                             }
@@ -294,30 +298,19 @@ impl EventLoop {
                                             RemoveKind::File
                                         },
                                     ))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                                 remove_watch_by_event(&path, &self.watches, &mut remove_watches);
                             }
                             if event.mask.contains(EventMask::DELETE_SELF) {
-                                let remove_kind = match &path {
-                                    Some(watched_path) => {
-                                        let current_watch = self.watches.get(watched_path);
-                                        match current_watch {
-                                            Some(&(_, _, _, true)) => RemoveKind::Folder,
-                                            Some(&(_, _, _, false)) => RemoveKind::File,
-                                            None => RemoveKind::Other,
-                                        }
-                                    }
-                                    None => {
-                                        log::trace!(
-                                            "No patch for DELETE_SELF event, may be a bug?"
-                                        );
-                                        RemoveKind::Other
-                                    }
+                                let remove_kind = match self.watches.get(&path) {
+                                    Some(&(_, _, _, true)) => RemoveKind::Folder,
+                                    Some(&(_, _, _, false)) => RemoveKind::File,
+                                    None => RemoveKind::Other,
                                 };
                                 evs.push(
                                     Event::new(EventKind::Remove(remove_kind))
-                                        .add_some_path(path.clone()),
+                                        .add_path(path.clone()),
                                 );
                                 remove_watch_by_event(&path, &self.watches, &mut remove_watches);
                             }
@@ -326,7 +319,7 @@ impl EventLoop {
                                     Event::new(EventKind::Modify(ModifyKind::Data(
                                         DataChange::Any,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                             }
                             if event.mask.contains(EventMask::CLOSE_WRITE) {
@@ -334,7 +327,7 @@ impl EventLoop {
                                     Event::new(EventKind::Access(AccessKind::Close(
                                         AccessMode::Write,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                             }
                             if event.mask.contains(EventMask::CLOSE_NOWRITE) {
@@ -342,7 +335,7 @@ impl EventLoop {
                                     Event::new(EventKind::Access(AccessKind::Close(
                                         AccessMode::Read,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                             }
                             if event.mask.contains(EventMask::ATTRIB) {
@@ -350,7 +343,7 @@ impl EventLoop {
                                     Event::new(EventKind::Modify(ModifyKind::Metadata(
                                         MetadataKind::Any,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                             }
                             if event.mask.contains(EventMask::OPEN) {
@@ -358,7 +351,7 @@ impl EventLoop {
                                     Event::new(EventKind::Access(AccessKind::Open(
                                         AccessMode::Any,
                                     )))
-                                    .add_some_path(path.clone()),
+                                    .add_path(path.clone()),
                                 );
                             }
 
@@ -618,86 +611,155 @@ impl Drop for INotifyWatcher {
     }
 }
 
-#[test]
-fn inotify_watcher_is_send_and_sync() {
-    fn check<T: Send + Sync>() {}
-    check::<INotifyWatcher>();
-}
+#[cfg(test)]
+mod tests {
+    use std::{
+        sync::{atomic::AtomicBool, mpsc},
+        thread::available_parallelism,
+    };
 
-#[test]
-fn native_error_type_on_missing_path() {
-    let mut watcher = INotifyWatcher::new(|_| {}, Config::default()).unwrap();
-
-    let result = watcher.watch(
-        &PathBuf::from("/some/non/existant/path"),
-        RecursiveMode::NonRecursive,
-    );
-
-    assert!(matches!(
-        result,
-        Err(Error {
-            paths: _,
-            kind: ErrorKind::PathNotFound
-        })
-    ))
-}
+    use super::*;
+
+    #[test]
+    fn inotify_watcher_is_send_and_sync() {
+        fn check<T: Send + Sync>() {}
+        check::<INotifyWatcher>();
+    }
+
+    #[test]
+    fn native_error_type_on_missing_path() {
+        let mut watcher = INotifyWatcher::new(|_| {}, Config::default()).unwrap();
+
+        let result = watcher.watch(
+            &PathBuf::from("/some/non/existant/path"),
+            RecursiveMode::NonRecursive,
+        );
+
+        assert!(matches!(
+            result,
+            Err(Error {
+                paths: _,
+                kind: ErrorKind::PathNotFound
+            })
+        ))
+    }
 
-/// Runs manually.
-///
-/// * Save actual value of the limit: `MAX_USER_WATCHES=$(sysctl -n fs.inotify.max_user_watches)`
-/// * Run the test.
-/// * Set the limit to 0: `sudo sysctl fs.inotify.max_user_watches=0` while test is running
-/// * Wait for the test to complete
-/// * Restore the limit `sudo sysctl fs.inotify.max_user_watches=$MAX_USER_WATCHES`
-#[test]
-#[ignore = "requires changing sysctl fs.inotify.max_user_watches while test is running"]
-fn recurcive_watch_calls_handler_if_creating_a_file_raises_max_files_watch() {
-    use std::time::Duration;
-
-    let tmpdir = tempfile::tempdir().unwrap();
-    let (tx, rx) = std::sync::mpsc::channel();
-    let (proc_changed_tx, proc_changed_rx) = std::sync::mpsc::channel();
-    let proc_path = Path::new("/proc/sys/fs/inotify/max_user_watches");
-    let mut watcher = INotifyWatcher::new(
-        move |result: Result<Event>| match result {
-            Ok(event) => {
-                if event.paths.first().is_some_and(|path| path == proc_path) {
-                    proc_changed_tx.send(()).unwrap();
+    /// Runs manually.
+    ///
+    /// * Save actual value of the limit: `MAX_USER_WATCHES=$(sysctl -n fs.inotify.max_user_watches)`
+    /// * Run the test.
+    /// * Set the limit to 0: `sudo sysctl fs.inotify.max_user_watches=0` while test is running
+    /// * Wait for the test to complete
+    /// * Restore the limit `sudo sysctl fs.inotify.max_user_watches=$MAX_USER_WATCHES`
+    #[test]
+    #[ignore = "requires changing sysctl fs.inotify.max_user_watches while test is running"]
+    fn recursive_watch_calls_handler_if_creating_a_file_raises_max_files_watch() {
+        use std::time::Duration;
+
+        let tmpdir = tempfile::tempdir().unwrap();
+        let (tx, rx) = std::sync::mpsc::channel();
+        let (proc_changed_tx, proc_changed_rx) = std::sync::mpsc::channel();
+        let proc_path = Path::new("/proc/sys/fs/inotify/max_user_watches");
+        let mut watcher = INotifyWatcher::new(
+            move |result: Result<Event>| match result {
+                Ok(event) => {
+                    if event.paths.first().is_some_and(|path| path == proc_path) {
+                        proc_changed_tx.send(()).unwrap();
+                    }
                 }
-            }
-            Err(e) => tx.send(e).unwrap(),
-        },
-        Config::default(),
-    )
-    .unwrap();
-
-    watcher
-        .watch(tmpdir.path(), RecursiveMode::Recursive)
-        .unwrap();
-    watcher
-        .watch(proc_path, RecursiveMode::NonRecursive)
+                Err(e) => tx.send(e).unwrap(),
+            },
+            Config::default(),
+        )
         .unwrap();
 
-    // give the time to set the limit
-    proc_changed_rx
-        .recv_timeout(Duration::from_secs(30))
-        .unwrap();
+        watcher
+            .watch(tmpdir.path(), RecursiveMode::Recursive)
+            .unwrap();
+        watcher
+            .watch(proc_path, RecursiveMode::NonRecursive)
+            .unwrap();
+
+        // give the time to set the limit
+        proc_changed_rx
+            .recv_timeout(Duration::from_secs(30))
+            .unwrap();
+
+        let child_dir = tmpdir.path().join("child");
+        std::fs::create_dir(child_dir).unwrap();
+
+        let result = rx.recv_timeout(Duration::from_millis(500));
+
+        assert!(
+            matches!(
+                &result,
+                Ok(Error {
+                    kind: ErrorKind::MaxFilesWatch,
+                    paths: _,
+                })
+            ),
+            "expected {:?}, found: {:#?}",
+            ErrorKind::MaxFilesWatch,
+            result
+        );
+    }
 
-    let child_dir = tmpdir.path().join("child");
-    std::fs::create_dir(child_dir).unwrap();
+    /// https://github.com/notify-rs/notify/issues/678
+    #[test]
+    fn race_condition_on_unwatch_and_pending_events_with_deleted_descriptor() {
+        let tmpdir = tempfile::tempdir().expect("tmpdir");
+        let (tx, rx) = mpsc::channel();
+        let mut inotify = INotifyWatcher::new(
+            move |e: Result<Event>| {
+                let e = match e {
+                    Ok(e) if e.paths.is_empty() => e,
+                    Ok(_) | Err(_) => return,
+                };
+                let _ = tx.send(e);
+            },
+            Config::default(),
+        )
+        .expect("inotify creation");
+
+        let dir_path = tmpdir.path();
+        let file_path = dir_path.join("foo");
+        std::fs::File::create(&file_path).unwrap();
+
+        let stop = Arc::new(AtomicBool::new(false));
+
+        let handles: Vec<_> = (0..available_parallelism().unwrap().get().max(4))
+            .map(|_| {
+                let file_path = file_path.clone();
+                let stop = stop.clone();
+                thread::spawn(move || {
+                    while !stop.load(std::sync::atomic::Ordering::Relaxed) {
+                        let _ = std::fs::File::open(&file_path).unwrap();
+                    }
+                })
+            })
+            .collect();
 
-    let result = rx.recv_timeout(Duration::from_millis(500));
+        let non_recursive = RecursiveMode::NonRecursive;
+        for _ in 0..(handles.len() * 4) {
+            inotify.watch(dir_path, non_recursive).unwrap();
+            inotify.unwatch(dir_path).unwrap();
+        }
 
-    assert!(
-        matches!(
-            &result,
-            Ok(Error {
-                kind: ErrorKind::MaxFilesWatch,
-                paths: _,
-            })
-        ),
-        "expected {:?}, found: {:#?}",
-        ErrorKind::MaxFilesWatch,
-        result
-    );
+        stop.store(true, std::sync::atomic::Ordering::Relaxed);
+        handles
+            .into_iter()
+            .for_each(|handle| handle.join().ok().unwrap_or_default());
+
+        drop(inotify);
+
+        let events: Vec<_> = rx.into_iter().map(|e| format!("{e:?}")).collect();
+
+        const LOG_LEN: usize = 10;
+        let events_len = events.len();
+        assert!(
+            events.is_empty(),
+            "expected no events without path, but got {events_len}. first 10: {:#?}",
+            &events[..LOG_LEN.min(events_len)]
+        );
+    }
 }


Test patch:
diff --git a/notify-debouncer-full/test_cases/add_event_with_no_paths_is_ok.hjson b/notify-debouncer-full/test_cases/add_event_with_no_paths_is_ok.hjson
new file mode 100644
index 00000000..608865a7
--- /dev/null
+++ b/notify-debouncer-full/test_cases/add_event_with_no_paths_is_ok.hjson
@@ -0,0 +1,13 @@
+// https://github.com/notify-rs/notify/issues/678
+//
+// There was a bug in INotifyWatcher that causes producing events with no paths.
+// Debouncer should ignore this kind of events
+{
+    state: {}
+    events: [
+        { kind: "create-folder", paths: [] }
+    ]
+    expected: {
+        queues: {}
+    }
+}