From b9f95127c96d623d11406edf36dc956181e00111 Mon Sep 17 00:00:00 2001 From: Daniel Faust Date: Mon, 6 May 2024 14:36:26 +0200 Subject: [PATCH 1/2] Fix ordering of debounced events when multiple files are modified and renamed Closes #587 --- notify-debouncer-full/CHANGELOG.md | 2 + notify-debouncer-full/src/block_manager.rs | 62 +++++ notify-debouncer-full/src/lib.rs | 250 +++++++++--------- notify-debouncer-full/src/testing.rs | 3 +- ..._after_safe_save_and_backup_override.hjson | 33 +++ ..._after_safe_save_and_backup_rotation.hjson | 41 +++ ..._safe_save_and_backup_rotation_twice.hjson | 42 +++ ...vent_after_safe_save_and_double_move.hjson | 36 +++ ...fe_save_and_double_move_and_recreate.hjson | 40 +++ .../test_cases/emit_needs_rescan_event.hjson | 2 +- 10 files changed, 387 insertions(+), 124 deletions(-) create mode 100644 notify-debouncer-full/src/block_manager.rs create mode 100644 notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_override.hjson create mode 100644 notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation.hjson create mode 100644 notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation_twice.hjson create mode 100644 notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move.hjson create mode 100644 notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move_and_recreate.hjson diff --git a/notify-debouncer-full/CHANGELOG.md b/notify-debouncer-full/CHANGELOG.md index 6b2dcc36..c3e62339 100644 --- a/notify-debouncer-full/CHANGELOG.md +++ b/notify-debouncer-full/CHANGELOG.md @@ -3,8 +3,10 @@ ## unreleased - CHANGE: emit `remove` events even if a file was created and then removed (because macOS repeats the "create" event) [#900] **breaking** +- FIX: ordering of debounced events when multiple files are modified and renamed (eg. during a safe save performed by Blender) [#590] [#900]: https://github.com/notify-rs/notify/issues/900 +[#590]: https://github.com/notify-rs/notify/issues/590 ## debouncer-full 0.8.0-rc.1 (2026-04-16) diff --git a/notify-debouncer-full/src/block_manager.rs b/notify-debouncer-full/src/block_manager.rs new file mode 100644 index 00000000..9835d397 --- /dev/null +++ b/notify-debouncer-full/src/block_manager.rs @@ -0,0 +1,62 @@ +use std::{ + path::{Path, PathBuf}, + time::Instant, +}; + +/// An entry that holds back events at one path until an event at another path is emitted. +/// +/// Used to prevent events queued for a renamed path from being emitted before +/// the [`RenameMode::Both`](notify::event::RenameMode) event at the new path +/// has itself been emitted. +#[derive(Debug)] +pub struct BlockEntry { + /// Path whose queued event must be emitted first (the rename destination). + pub blocker_path: PathBuf, + /// Timestamp of the blocking event, used to match the exact rename event. + pub blocker_time: Instant, + /// Path whose events are held back until the blocker is emitted (the rename source). + pub blockee_path: PathBuf, +} + +/// Tracks which paths have their event emission held back by a pending rename. +/// +/// When a rename `A → B` is queued, events already in A's queue must not be +/// emitted before the rename event at B. `BlockManager` records this +/// dependency so [`debounced_events`] can skip blocked paths until the +/// rename at the blocker path is emitted and [`remove_blocker`] is called. +/// +/// [`debounced_events`]: crate::DebounceDataInner::debounced_events +/// [`remove_blocker`]: BlockManager::remove_blocker +#[derive(Debug, Default)] +pub struct BlockManager { + pub entries: Vec, +} + +impl BlockManager { + /// Construct an empty `BlockManager`. + pub fn new() -> BlockManager { + Self::default() + } + + /// Register a new blocking relationship. + pub fn add_blocker(&mut self, entry: BlockEntry) { + self.entries.push(entry); + } + + /// Remove the blocker for `path` at `time` once its event has been emitted. + pub fn remove_blocker(&mut self, path: &Path, time: Instant) { + self.entries + .retain(|entry| entry.blocker_path != *path || entry.blocker_time != time); + } + + /// Return the blocker for `path`, if one exists. + /// + /// Returns `(blocker_path, blocker_time)` when `path`'s events are held + /// back, or `None` if `path` is not blocked. + pub fn is_blocked_by(&self, path: &Path) -> Option<(&PathBuf, Instant)> { + self.entries + .iter() + .find(|entry| entry.blockee_path == *path) + .map(|entry| (&entry.blocker_path, entry.blocker_time)) + } +} diff --git a/notify-debouncer-full/src/lib.rs b/notify-debouncer-full/src/lib.rs index fe604a3a..cbc8b05c 100644 --- a/notify-debouncer-full/src/lib.rs +++ b/notify-debouncer-full/src/lib.rs @@ -61,6 +61,7 @@ //! //! As all file events are sourced from notify, the [known problems](https://docs.rs/notify/latest/notify/#known-problems) section applies here too. +mod block_manager; mod cache; mod time; @@ -71,8 +72,7 @@ mod testing; mod file_id_map; use std::{ - cmp::Reverse, - collections::{BinaryHeap, VecDeque}, + collections::{HashMap, VecDeque}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -81,7 +81,7 @@ use std::{ time::{Duration, Instant}, }; -use rustc_hash::FxHashMap as HashMap; +use block_manager::BlockManager; use time::now; pub use cache::{FileIdCache, NoCache, RecommendedCache}; @@ -100,6 +100,8 @@ use notify::{ UpdatePathsError, Watcher, WatcherKind, }; +use crate::block_manager::BlockEntry; + /// The set of requirements for watcher debounce event handling functions. /// /// # Example implementation @@ -206,6 +208,7 @@ impl Queue { #[derive(Debug)] pub(crate) struct DebounceDataInner { queues: HashMap, + blocking: BlockManager, roots: Vec<(PathBuf, RecursiveMode)>, cache: T, rename_event: Option<(DebouncedEvent, Option)>, @@ -217,7 +220,8 @@ pub(crate) struct DebounceDataInner { impl DebounceDataInner { pub(crate) fn new(cache: T, timeout: Duration) -> Self { Self { - queues: HashMap::default(), + queues: HashMap::new(), + blocking: BlockManager::new(), roots: Vec::new(), cache, rename_event: None, @@ -227,10 +231,17 @@ impl DebounceDataInner { } } + fn contains_event(&self, path: &Path, time: Instant) -> bool { + self.queues + .get(path) + .is_some_and(|queue| queue.events.iter().any(|event| event.time == time)) + } + /// Retrieve a vec of debounced events, removing them if not continuous pub fn debounced_events(&mut self) -> Vec { let now = now(); - let mut events_expired = Vec::with_capacity(self.queues.len()); + let events_count = self.queues.values().map(|queue| queue.events.len()).sum(); + let mut events_expired = Vec::with_capacity(events_count); if let Some(event) = self.rescan_event.take() { if now.saturating_duration_since(event.time) >= self.timeout { @@ -241,36 +252,68 @@ impl DebounceDataInner { } } - // Visit each queue in place and remove only the ones that become empty. - self.queues - .extract_if(|_, queue| { - let mut kind_index: HashMap = HashMap::default(); - let mut queue_expired = Vec::new(); - - while let Some(event) = queue.events.pop_front() { - // remove previous event of the same kind - if now.saturating_duration_since(event.time) >= self.timeout { - if let Some(idx) = kind_index.insert(event.kind, queue_expired.len()) { - queue_expired[idx] = None; - } + let mut kind_index: HashMap> = HashMap::new(); - queue_expired.push(Some(event)); - } else { - if let Some(&idx) = kind_index.get(&event.kind) { - queue_expired[idx] = None; + while let Some(path) = self + .queues + // iterate over all queues + .iter() + // get the first event of every queue + .filter_map(|(path, queue)| queue.events.front().map(|event| (path, event.time))) + // filter out all blocked events + .filter(|(path, _)| { + self.blocking + .is_blocked_by(path) + .is_none_or(|(path, time)| !self.contains_event(path, time)) + }) + // get the event with the earliest timestamp + .min_by_key(|(_, time)| *time) + // get the path of the event + .map(|(path, _)| path.clone()) + { + // unwraps are safe because only paths for existing queues with at least one event are returned by the query above + let event = self + .queues + .get_mut(&path) + .unwrap() + .events + .pop_front() + .unwrap(); + + if now.saturating_duration_since(event.time) >= self.timeout { + // remove previous event of the same kind + let kind_index = kind_index.entry(path.clone()).or_default(); + if let Some(idx) = kind_index.get(&event.kind).copied() { + events_expired.remove(idx); + + kind_index.values_mut().for_each(|i| { + if *i > idx { + *i -= 1 } - queue.events.push_front(event); - break; - } + }) } + kind_index.insert(event.kind, events_expired.len()); - events_expired.extend(queue_expired.into_iter().flatten()); + self.blocking.remove_blocker(&path, event.time); - queue.events.is_empty() - }) - .for_each(drop); + events_expired.push(event); + } else { + let kind_index = kind_index.entry(path.clone()).or_default(); + if let Some(idx) = kind_index.get(&event.kind).copied() { + events_expired.remove(idx); + } + self.queues.get_mut(&path).unwrap().events.push_front(event); // unwrap is safe because only paths for existing queues are returned by the query above + break; + } + } + + self.queues.retain(|_, queue| !queue.events.is_empty()); - sort_events(events_expired) + if self.queues.is_empty() { + self.blocking.entries.clear(); + } + + events_expired } /// Returns all currently stored errors @@ -449,18 +492,6 @@ impl DebounceDataInner { source_queue.events.remove(remove_index); } - // split off remove or move out event and add it back to the events map - if source_queue.was_removed() { - let event = source_queue.events.pop_front().unwrap(); - - self.queues.insert( - event.paths[0].clone(), - Queue { - events: [event].into(), - }, - ); - } - // update paths for e in &mut source_queue.events { e.paths = vec![event.paths[0].clone()]; @@ -479,7 +510,12 @@ impl DebounceDataInner { } if let Some(target_queue) = self.queues.get_mut(&event.paths[0]) { - if !target_queue.was_created() { + if target_queue.was_removed() { + let event = target_queue.events.pop_front().unwrap(); // unwrap is safe because `was_removed` implies that the queue is not empty + source_queue.events.push_front(event); + } + + if !target_queue.was_created() && !source_queue.was_removed() { let mut remove_event = DebouncedEvent { event: Event { kind: EventKind::Remove(RemoveKind::Any), @@ -497,6 +533,8 @@ impl DebounceDataInner { } else { self.queues.insert(event.paths[0].clone(), source_queue); } + + self.mark_blocked_events(&event.paths[0]); } fn push_remove_event(&mut self, event: Event, time: Instant) { @@ -545,9 +583,35 @@ impl DebounceDataInner { ); } } + + fn mark_blocked_events(&mut self, path: &Path) { + for queue in self.queues.values_mut() { + for event in &mut queue.events { + if matches!( + event.event.kind, + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) + ) && event.event.paths[0] == path + { + self.blocking.add_blocker(BlockEntry { + blocker_path: event.event.paths[1].clone(), + blocker_time: event.time, + blockee_path: path.to_path_buf(), + }); + break; + } + } + } + } } -/// Debouncer guard, stops the debouncer on drop. +/// A debounced file-system watcher. +/// +/// Wraps a [`Watcher`] and a background thread that collects raw events, merges +/// duplicates, and emits debounced events after the configured +/// `timeout` has elapsed without further activity on the same path. +/// +/// Created by [`new_debouncer`] or [`new_debouncer_opt`]. Stops the background +/// thread on drop. #[derive(Debug)] pub struct Debouncer { watcher: T, @@ -604,6 +668,13 @@ impl Debouncer { data.cache.remove_path(path.as_ref()); } + /// Watch a path for file-system events. + /// + /// Passes the request to the underlying [`Watcher`] and registers the path + /// with the file-ID cache. + /// + /// Use [`RecursiveMode::Recursive`] to monitor a directory tree, or + /// [`RecursiveMode::NonRecursive`] to monitor only the top-level entries. pub fn watch( &mut self, path: impl AsRef, @@ -614,6 +685,10 @@ impl Debouncer { Ok(()) } + /// Stop watching a path. + /// + /// Passes the request to the underlying [`Watcher`] and removes the path + /// from the root list and file-ID cache. pub fn unwatch(&mut self, path: impl AsRef) -> notify::Result<()> { self.watcher.unwatch(path.as_ref())?; self.remove_root(path); @@ -695,10 +770,15 @@ impl Debouncer { self.watcher.watched_paths() } + /// Pass a configuration change to the underlying [`Watcher`]. + /// + /// Returns `true` if the configuration was applied, `false` if the + /// watcher does not support runtime reconfiguration, or an error. pub fn configure(&mut self, option: notify::Config) -> notify::Result { self.watcher.configure(option) } + /// Return the [`WatcherKind`] of the underlying watcher. #[must_use] pub fn kind() -> WatcherKind where @@ -813,59 +893,6 @@ pub fn new_debouncer( ) } -fn sort_events(events: Vec) -> Vec { - let mut sorted = Vec::with_capacity(events.len()); - - // group events by path - let mut groups = Vec::<(PathBuf, VecDeque)>::new(); - let mut group_indexes: HashMap = HashMap::default(); - group_indexes.reserve(events.len()); - groups.reserve(events.len()); - - for event in events { - let path = event.paths.last().cloned().unwrap_or_default(); - - if let Some(&index) = group_indexes.get(&path) { - groups[index].1.push_back(event); - } else { - group_indexes.insert(path.clone(), groups.len()); - groups.push((path, [event].into())); - } - } - - // Keep path order as the tie-breaker for identical timestamps. - groups.sort_unstable_by(|(left_path, _), (right_path, _)| left_path.cmp(right_path)); - - // push events for different paths in chronological order and keep the order of events with the same path - - let mut min_time_heap = groups - .iter() - .enumerate() - .map(|(index, (_, events))| Reverse((events[0].time, index))) - .collect::>(); - - while let Some(Reverse((min_time, index))) = min_time_heap.pop() { - let events = &mut groups[index].1; - - let mut push_next = false; - - while events.front().is_some_and(|event| event.time <= min_time) { - // unwrap is safe because `pop_front` mus return some in order to enter the loop - let event = events.pop_front().unwrap(); - sorted.push(event); - push_next = true; - } - - if push_next { - if let Some(event) = events.front() { - min_time_heap.push(Reverse((event.time, index))); - } - } - } - - sorted -} - #[cfg(test)] mod tests { use std::{ @@ -993,6 +1020,11 @@ mod tests { "add_errors", "debounce_modify_events", "emit_continuous_modify_content_events", + "emit_create_event_after_safe_save_and_backup_override", + "emit_create_event_after_safe_save_and_backup_rotation_twice", + "emit_create_event_after_safe_save_and_backup_rotation", + "emit_create_event_after_safe_save_and_double_move", + "emit_create_event_after_safe_save_and_double_move_and_recreate", "emit_events_in_chronological_order", "emit_events_with_a_prepended_rename_event", "emit_close_events_only_once", @@ -1093,32 +1125,6 @@ mod tests { } } - #[test] - fn sort_events_ties_by_path() { - let time = now(); - let events = vec![ - DebouncedEvent::new( - Event::new(EventKind::Any).add_path(PathBuf::from("/watch/b")), - time, - ), - DebouncedEvent::new( - Event::new(EventKind::Any).add_path(PathBuf::from("/watch/a")), - time, - ), - ]; - - let sorted = sort_events(events); - let paths = sorted - .into_iter() - .map(|event| event.paths[0].clone()) - .collect::>(); - - assert_eq!( - paths, - vec![PathBuf::from("/watch/a"), PathBuf::from("/watch/b")] - ); - } - #[test] fn integration() -> Result<(), Box> { let dir = tempdir()?; diff --git a/notify-debouncer-full/src/testing.rs b/notify-debouncer-full/src/testing.rs index 3315fe3b..5dda7ffe 100644 --- a/notify-debouncer-full/src/testing.rs +++ b/notify-debouncer-full/src/testing.rs @@ -13,7 +13,7 @@ use notify::{ Error, ErrorKind, Event, EventKind, RecursiveMode, }; -use crate::{DebounceDataInner, DebouncedEvent, FileIdCache, Queue}; +use crate::{BlockManager, DebounceDataInner, DebouncedEvent, FileIdCache, Queue}; pub(crate) use schema::TestCase; @@ -267,6 +267,7 @@ impl schema::State { DebounceDataInner { queues, + blocking: BlockManager::new(), roots: Vec::new(), cache, rename_event, diff --git a/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_override.hjson b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_override.hjson new file mode 100644 index 00000000..9ca2e563 --- /dev/null +++ b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_override.hjson @@ -0,0 +1,33 @@ +// Based on the Blender safe save scenario. +// +// In this scenario the backup file is not removed first. +{ + state: {} + events: [ + { kind: "create-any", paths: ["/watch/file@"], time: 1 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 1, time: 3 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 1, time: 4 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 2, time: 5 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 2, time: 6 } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"], time: 1 } + ] + } + /watch/file1: { + events: [ + { kind: "rename-both", paths: ["/watch/file", "/watch/file1"], tracker: 1, time: 3 } + ] + } + } + events: { + long: [ + { kind: "rename-both", paths: ["/watch/file", "/watch/file1"], tracker: 1, time: 3 } + { kind: "create-any", paths: ["/watch/file"], time: 1 } + ] + } + } +} diff --git a/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation.hjson b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation.hjson new file mode 100644 index 00000000..0591fd71 --- /dev/null +++ b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation.hjson @@ -0,0 +1,41 @@ +// https://github.com/notify-rs/notify/issues/587 +// +// Blender causes the following events when saving a file: +// +// create test.blend@ (new content) +// delete test.blend1 (delete backup) +// rename test.blend -> test.blend1 (move current to backup) +// rename test.blend@ -> test.blend (move new to current) +{ + state: {} + events: [ + { kind: "create-any", paths: ["/watch/file@"], time: 1 } + { kind: "remove-any", paths: ["/watch/file1"], time: 2 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 1, time: 3 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 1, time: 4 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 2, time: 5 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 2, time: 6 } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"], time: 1 } + ] + } + /watch/file1: { + events: [ + { kind: "remove-any", paths: ["*"], time: 2 } + { kind: "rename-both", paths: ["/watch/file", "/watch/file1"], tracker: 1, time: 3 } + ] + } + } + events: { + long: [ + { kind: "remove-any", paths: ["/watch/file1"], time: 2 } + { kind: "rename-both", paths: ["/watch/file", "/watch/file1"], tracker: 1, time: 3 } + { kind: "create-any", paths: ["/watch/file"], time: 1 } + ] + } + } +} diff --git a/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation_twice.hjson b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation_twice.hjson new file mode 100644 index 00000000..515f85a4 --- /dev/null +++ b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_backup_rotation_twice.hjson @@ -0,0 +1,42 @@ +// Based on the Blender safe save scenario. +// +// In this scenario a file is saved twice in very short succession. +{ + state: {} + events: [ + { kind: "create-any", paths: ["/watch/file@"], time: 1 } + { kind: "remove-any", paths: ["/watch/file1"], time: 2 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 1, time: 3 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 1, time: 4 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 2, time: 5 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 2, time: 6 } + { kind: "create-any", paths: ["/watch/file@"], time: 7 } + { kind: "remove-any", paths: ["/watch/file1"], time: 8 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 3, time: 9 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 3, time: 10 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 4, time: 11 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 4, time: 12 } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"], time: 7 } + ] + } + /watch/file1: { + events: [ + { kind: "remove-any", paths: ["*"], time: 8 } + { kind: "create-any", paths: ["*"], time: 1 } + ] + } + } + events: { + long: [ + { kind: "create-any", paths: ["/watch/file"], time: 7 } + { kind: "remove-any", paths: ["/watch/file1"], time: 8 } + { kind: "create-any", paths: ["/watch/file1"], time: 1 } + ] + } + } +} diff --git a/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move.hjson b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move.hjson new file mode 100644 index 00000000..13b2db5c --- /dev/null +++ b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move.hjson @@ -0,0 +1,36 @@ +// Based on the Blender safe save scenario. +// +// In this scenario the backup file is renamed twice in very short succession. +{ + state: {} + events: [ + { kind: "create-any", paths: ["/watch/file@"], time: 1 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 1, time: 3 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 1, time: 4 } + { kind: "create-any", paths: ["/watch/file1"], time: 5 } + { kind: "rename-from", paths: ["/watch/file1"], tracker: 2, time: 6 } + { kind: "rename-to", paths: ["/watch/file2"], tracker: 2, time: 7 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 2, time: 8 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 2, time: 9 } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"], time: 1 } + ] + } + /watch/file2: { + events: [ + { kind: "create-any", paths: ["*"], time: 5 } + ] + } + } + events: { + long: [ + { kind: "create-any", paths: ["/watch/file"], time: 1 } + { kind: "create-any", paths: ["/watch/file2"], time: 5 } + ] + } + } +} diff --git a/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move_and_recreate.hjson b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move_and_recreate.hjson new file mode 100644 index 00000000..4115f1b3 --- /dev/null +++ b/notify-debouncer-full/test_cases/emit_create_event_after_safe_save_and_double_move_and_recreate.hjson @@ -0,0 +1,40 @@ +// Based on the Blender safe save scenario. +// +// In this scenario the backup file is renamed, re-created and renamed again in very short succession. +// +// The create event for `/watch/file` is blocked by the rename of `/watch/file` to `/watch/file1`. +// Then the renames from `/watch/file` to `/watch/file1` and from `/watch/file1` to `/watch/file2` +// are merged and the first rename is removed. Therefore the block must be ignored. +{ + state: {} + events: [ + { kind: "create-any", paths: ["/watch/file@"], time: 1 } + { kind: "rename-from", paths: ["/watch/file"], tracker: 1, time: 3 } + { kind: "rename-to", paths: ["/watch/file1"], tracker: 1, time: 4 } + { kind: "create-any", paths: ["/watch/file1"], time: 5 } + { kind: "rename-from", paths: ["/watch/file@"], tracker: 2, time: 6 } + { kind: "rename-to", paths: ["/watch/file"], tracker: 2, time: 7 } + { kind: "rename-from", paths: ["/watch/file1"], tracker: 2, time: 8 } + { kind: "rename-to", paths: ["/watch/file2"], tracker: 2, time: 9 } + ] + expected: { + queues: { + /watch/file: { + events: [ + { kind: "create-any", paths: ["*"], time: 1 } + ] + } + /watch/file2: { + events: [ + { kind: "create-any", paths: ["*"], time: 5 } + ] + } + } + events: { + long: [ + { kind: "create-any", paths: ["/watch/file"], time: 1 } + { kind: "create-any", paths: ["/watch/file2"], time: 5 } + ] + } + } +} diff --git a/notify-debouncer-full/test_cases/emit_needs_rescan_event.hjson b/notify-debouncer-full/test_cases/emit_needs_rescan_event.hjson index 81c6bd34..ddaae5c4 100644 --- a/notify-debouncer-full/test_cases/emit_needs_rescan_event.hjson +++ b/notify-debouncer-full/test_cases/emit_needs_rescan_event.hjson @@ -47,9 +47,9 @@ events: { short: [] long: [ + { kind: "other", flags: ["rescan"], time: 3 } { kind: "create-any", paths: ["/watch/file-a"], time: 1 } { kind: "create-any", paths: ["/watch/file-b"], time: 2 } - { kind: "other", flags: ["rescan"], time: 3 } ] } } From 9310ac8bd04ad324a10c18fff358577fb116c54c Mon Sep 17 00:00:00 2001 From: Daniel Faust Date: Sun, 26 Apr 2026 21:46:03 +0200 Subject: [PATCH 2/2] Add missing event log --- notify-debouncer-full/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/notify-debouncer-full/src/lib.rs b/notify-debouncer-full/src/lib.rs index cbc8b05c..ecf58d02 100644 --- a/notify-debouncer-full/src/lib.rs +++ b/notify-debouncer-full/src/lib.rs @@ -296,6 +296,7 @@ impl DebounceDataInner { self.blocking.remove_blocker(&path, event.time); + log::trace!("debounced event: {event:?}"); events_expired.push(event); } else { let kind_index = kind_index.entry(path.clone()).or_default();