fix: replace notify-debouncer-full/mini with raw notify + custom debounce

notify-debouncer-full cancelled FSEvents Create+Remove pairs (breaking
macOS file deletion detection). notify-debouncer-mini lacked event kind
information (breaking Windows rename detection).

Use raw notify::RecommendedWatcher with a simple 50ms debounce thread
that preserves full event kinds (Create/Write/Remove/Rename). This
matches upstream Rojo's approach (notify v4 built-in debouncer) while
staying on notify v8.

All 489 tests passing on macOS. No debouncer dependencies needed.
This commit is contained in:
Joe 2026-03-12 18:28:37 -05:00
parent 9cf4cea936
commit b8bc7dda5b
3 changed files with 127 additions and 63 deletions

13
Cargo.lock generated
View file

@ -1911,7 +1911,6 @@ dependencies = [
"fs-err", "fs-err",
"log", "log",
"notify", "notify",
"notify-debouncer-mini",
"serde", "serde",
"tempfile", "tempfile",
] ]
@ -1981,18 +1980,6 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "notify-debouncer-mini"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2"
dependencies = [
"log",
"notify",
"notify-types",
"tempfile",
]
[[package]] [[package]]
name = "notify-types" name = "notify-types"
version = "2.1.0" version = "2.1.0"

View file

@ -19,7 +19,6 @@ crossbeam-channel = "0.5.15"
fs-err = "3" fs-err = "3"
log = "0.4" log = "0.4"
notify = "8.2" notify = "8.2"
notify-debouncer-mini = "0.7"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
[dev-dependencies] [dev-dependencies]

View file

@ -1,10 +1,11 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::{Duration, Instant};
use std::{collections::HashSet, io}; use std::{collections::HashSet, io, thread};
use crossbeam_channel::{Receiver, Sender}; use crossbeam_channel::Receiver;
use notify::RecursiveMode; use notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind, RenameMode};
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, DebouncedEventKind, Debouncer}; use notify::{RecursiveMode, Watcher};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
@ -51,12 +52,10 @@ pub type CriticalErrorHandler = Box<dyn Fn(WatcherCriticalError) -> bool + Send
/// `VfsBackend` that uses `std::fs` and the `notify` crate. /// `VfsBackend` that uses `std::fs` and the `notify` crate.
pub struct StdBackend { pub struct StdBackend {
debouncer: Debouncer<notify::RecommendedWatcher>, watcher: notify::RecommendedWatcher,
watcher_receiver: Receiver<VfsEvent>, watcher_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>, watches: HashSet<PathBuf>,
recursive_watches: HashSet<PathBuf>, recursive_watches: HashSet<PathBuf>,
/// Receiver for critical errors from the watcher thread.
/// Callers should poll this to detect when watching becomes unreliable.
critical_error_receiver: Receiver<WatcherCriticalError>, critical_error_receiver: Receiver<WatcherCriticalError>,
} }
@ -103,41 +102,51 @@ impl StdBackend {
pub fn new_with_error_handler(error_handler: CriticalErrorHandler) -> StdBackend { pub fn new_with_error_handler(error_handler: CriticalErrorHandler) -> StdBackend {
let (event_tx, event_rx) = crossbeam_channel::unbounded(); let (event_tx, event_rx) = crossbeam_channel::unbounded();
let (error_tx, error_rx) = crossbeam_channel::unbounded(); let (error_tx, error_rx) = crossbeam_channel::unbounded();
let (raw_tx, raw_rx) = std::sync::mpsc::channel::<Result<notify::Event, notify::Error>>();
let debouncer = Self::create_debouncer(event_tx, error_tx, error_handler); let watcher = notify::RecommendedWatcher::new(
move |result| {
let _ = raw_tx.send(result);
},
notify::Config::default(),
)
.expect("Failed to create file watcher");
Self { let debounce_ms = Duration::from_millis(50);
debouncer,
watcher_receiver: event_rx,
watches: HashSet::new(),
recursive_watches: HashSet::new(),
critical_error_receiver: error_rx,
}
}
fn create_debouncer( thread::spawn(move || {
event_tx: Sender<VfsEvent>, let mut pending: HashMap<PathBuf, VfsEvent> = HashMap::new();
error_tx: Sender<WatcherCriticalError>, let mut last_event = Instant::now();
error_handler: CriticalErrorHandler,
) -> Debouncer<notify::RecommendedWatcher> {
let debounce_timeout = Duration::from_millis(50);
new_debouncer( loop {
debounce_timeout, match raw_rx.recv_timeout(debounce_ms) {
move |result: DebounceEventResult| match result { Ok(Ok(event)) => {
Ok(events) => { last_event = Instant::now();
for event in events { for vfs_event in Self::convert_event(&event) {
let vfs_event = match event.kind { let path = match &vfs_event {
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous => { VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => {
if event.path.exists() { p.clone()
VfsEvent::Write(event.path)
} else {
VfsEvent::Remove(event.path)
} }
} };
_ => continue, pending.insert(path, vfs_event);
}
}
Ok(Err(error)) => {
let critical_err = WatcherCriticalError::WatcherError {
error: format!("{:?}", error.kind),
path: error.paths.first().cloned(),
}; };
log::trace!("VFS debounced event: {:?}", &vfs_event); let _ = error_tx.send(critical_err.clone());
if error_handler(critical_err) {
return;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
}
if !pending.is_empty() && last_event.elapsed() >= debounce_ms {
for (_, vfs_event) in pending.drain() {
if let Err(err) = event_tx.send(vfs_event) { if let Err(err) = event_tx.send(vfs_event) {
let critical_err = let critical_err =
WatcherCriticalError::ChannelSendFailed(err.to_string()); WatcherCriticalError::ChannelSendFailed(err.to_string());
@ -148,17 +157,86 @@ impl StdBackend {
} }
} }
} }
Err(error) => { }
let critical_err = WatcherCriticalError::WatcherError { });
error: format!("{:?}", error.kind),
path: error.paths.first().cloned(), Self {
}; watcher,
let _ = error_tx.send(critical_err.clone()); watcher_receiver: event_rx,
error_handler(critical_err); watches: HashSet::new(),
recursive_watches: HashSet::new(),
critical_error_receiver: error_rx,
}
}
fn convert_event(event: &notify::Event) -> Vec<VfsEvent> {
let mut vfs_events = Vec::new();
match &event.kind {
EventKind::Create(CreateKind::File)
| EventKind::Create(CreateKind::Folder)
| EventKind::Create(CreateKind::Any)
| EventKind::Create(CreateKind::Other) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Create(path.clone()));
} }
}, }
)
.expect("Failed to create file watcher debouncer") EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Modify(ModifyKind::Other)
| EventKind::Modify(ModifyKind::Metadata(_)) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
if event.paths.len() >= 2 {
vfs_events.push(VfsEvent::Remove(event.paths[0].clone()));
vfs_events.push(VfsEvent::Create(event.paths[1].clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Remove(path.clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Create(path.clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::Any))
| EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
EventKind::Remove(RemoveKind::File)
| EventKind::Remove(RemoveKind::Folder)
| EventKind::Remove(RemoveKind::Any)
| EventKind::Remove(RemoveKind::Other) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Remove(path.clone()));
}
}
EventKind::Access(_) => {}
EventKind::Other | EventKind::Any => {
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
}
for event in &vfs_events {
log::trace!("VFS event: {:?}", event);
}
vfs_events
} }
/// Returns a receiver for critical errors from the watcher thread. /// Returns a receiver for critical errors from the watcher thread.
@ -255,7 +333,7 @@ impl VfsBackend for StdBackend {
} else { } else {
RecursiveMode::NonRecursive RecursiveMode::NonRecursive
}; };
match self.debouncer.watcher().watch(path, mode) { match self.watcher.watch(path, mode) {
Ok(()) => { Ok(()) => {
log::debug!( log::debug!(
"Watching path ({}): {}", "Watching path ({}): {}",
@ -282,7 +360,7 @@ impl VfsBackend for StdBackend {
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
let was_watched = self.watches.contains(path); let was_watched = self.watches.contains(path);
match self.debouncer.watcher().unwatch(path) { match self.watcher.unwatch(path) {
Ok(()) => { Ok(()) => {
if was_watched { if was_watched {
log::info!("Unwatched path: {}", path.display()); log::info!("Unwatched path: {}", path.display());