From a727881e6faf456d64cfbf669b38d085ef3822ae Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 12 Mar 2026 18:48:43 -0500 Subject: [PATCH] fix: platform-specific watcher for macOS FSEvents + Windows RDCW macOS: raw notify with custom 50ms debounce thread. FSEvents provides complete event kinds (Create/Remove/Modify) without needing a file cache, avoiding the debouncer-full's Create+Remove cancellation bug. Windows/Linux: notify-debouncer-full with RecommendedCache. RDCW needs the file cache to detect cross-directory moves and renames. The cache also handles RDCW's limited event granularity. Both platforms share the same convert_event logic for mapping notify EventKinds to VfsEvents. All 489 tests passing locally (macOS). --- Cargo.lock | 23 +++++ crates/memofs/Cargo.toml | 3 + crates/memofs/src/std_backend.rs | 158 ++++++++++++++++++++++--------- 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df54568b..ebf435e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1030,6 +1030,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1911,6 +1920,7 @@ dependencies = [ "fs-err", "log", "notify", + "notify-debouncer-full", "serde", "tempfile", ] @@ -1980,6 +1990,19 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "notify-debouncer-full" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + [[package]] name = "notify-types" version = "2.1.0" diff --git a/crates/memofs/Cargo.toml b/crates/memofs/Cargo.toml index 34fdac8d..cfc5bbe4 100644 --- a/crates/memofs/Cargo.toml +++ b/crates/memofs/Cargo.toml @@ -21,5 +21,8 @@ log = "0.4" notify = "8.2" serde = { version = "1.0.228", features = ["derive"] } +[target.'cfg(not(target_os = "macos"))'.dependencies] +notify-debouncer-full = "0.7" + [dev-dependencies] tempfile = "3.26.0" diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 9c04899f..071cffcc 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; use std::{collections::HashSet, io, thread}; use crossbeam_channel::Receiver; use notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind, RenameMode}; use notify::{RecursiveMode, Watcher}; +#[cfg(not(target_os = "macos"))] +use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, RecommendedCache}; + use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent}; /// Critical errors from the file watcher that indicate watching is no longer reliable. @@ -52,7 +53,10 @@ pub type CriticalErrorHandler = Box bool + Send /// `VfsBackend` that uses `std::fs` and the `notify` crate. pub struct StdBackend { + #[cfg(target_os = "macos")] watcher: notify::RecommendedWatcher, + #[cfg(not(target_os = "macos"))] + debouncer: Debouncer, watcher_receiver: Receiver, watches: HashSet, recursive_watches: HashSet, @@ -102,66 +106,118 @@ impl StdBackend { pub fn new_with_error_handler(error_handler: CriticalErrorHandler) -> StdBackend { let (event_tx, event_rx) = crossbeam_channel::unbounded(); let (error_tx, error_rx) = crossbeam_channel::unbounded(); - let (raw_tx, raw_rx) = std::sync::mpsc::channel::>(); - let watcher = notify::RecommendedWatcher::new( - move |result| { - let _ = raw_tx.send(result); - }, - notify::Config::default(), - ) - .expect("Failed to create file watcher"); + #[cfg(target_os = "macos")] + let watcher = { + let (raw_tx, raw_rx) = + std::sync::mpsc::channel::>(); - let debounce_ms = Duration::from_millis(50); + let w = notify::RecommendedWatcher::new( + move |result| { + let _ = raw_tx.send(result); + }, + notify::Config::default(), + ) + .expect("Failed to create file watcher"); - thread::spawn(move || { - let mut pending: HashMap = HashMap::new(); - let mut last_event = Instant::now(); + let debounce_ms = std::time::Duration::from_millis(50); - loop { - match raw_rx.recv_timeout(debounce_ms) { - Ok(Ok(event)) => { - last_event = Instant::now(); - for vfs_event in Self::convert_event(&event) { - let path = match &vfs_event { - VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => { - p.clone() - } + thread::spawn(move || { + use std::collections::HashMap; + use std::time::Instant; + + let mut pending: HashMap = HashMap::new(); + let mut last_event = Instant::now(); + + loop { + match raw_rx.recv_timeout(debounce_ms) { + Ok(Ok(event)) => { + last_event = Instant::now(); + for vfs_event in Self::convert_event(&event) { + let path = match &vfs_event { + VfsEvent::Create(p) + | VfsEvent::Write(p) + | VfsEvent::Remove(p) => p.clone(), + }; + pending.insert(path, vfs_event); + } + } + Ok(Err(error)) => { + let critical_err = WatcherCriticalError::WatcherError { + error: format!("{:?}", error.kind), + path: error.paths.first().cloned(), }; - pending.insert(path, 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, } - Ok(Err(error)) => { - let critical_err = WatcherCriticalError::WatcherError { - error: format!("{:?}", error.kind), - path: error.paths.first().cloned(), - }; - 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) { - let critical_err = - WatcherCriticalError::ChannelSendFailed(err.to_string()); + if !pending.is_empty() && last_event.elapsed() >= debounce_ms { + for (_, vfs_event) in pending.drain() { + if let Err(err) = event_tx.send(vfs_event) { + let critical_err = + WatcherCriticalError::ChannelSendFailed(err.to_string()); + let _ = error_tx.send(critical_err.clone()); + if error_handler(critical_err) { + return; + } + } + } + } + } + }); + + w + }; + + #[cfg(not(target_os = "macos"))] + let debouncer = { + let debounce_timeout = std::time::Duration::from_millis(50); + + new_debouncer( + debounce_timeout, + None, + move |result: DebounceEventResult| match result { + Ok(events) => { + for event in events { + for vfs_event in Self::convert_event(&event.event) { + if let Err(err) = event_tx.send(vfs_event) { + let critical_err = + WatcherCriticalError::ChannelSendFailed(err.to_string()); + let _ = error_tx.send(critical_err.clone()); + if error_handler(critical_err) { + return; + } + } + } + } + } + Err(errors) => { + for error in errors { + let critical_err = WatcherCriticalError::WatcherError { + error: format!("{:?}", error.kind), + path: error.paths.first().cloned(), + }; let _ = error_tx.send(critical_err.clone()); if error_handler(critical_err) { return; } } } - } - } - }); + }, + ) + .expect("Failed to create file watcher debouncer") + }; Self { + #[cfg(target_os = "macos")] watcher, + #[cfg(not(target_os = "macos"))] + debouncer, watcher_receiver: event_rx, watches: HashSet::new(), recursive_watches: HashSet::new(), @@ -333,7 +389,11 @@ impl VfsBackend for StdBackend { } else { RecursiveMode::NonRecursive }; - match self.watcher.watch(path, mode) { + #[cfg(target_os = "macos")] + let result = self.watcher.watch(path, mode); + #[cfg(not(target_os = "macos"))] + let result = self.debouncer.watch(path, mode); + match result { Ok(()) => { log::debug!( "Watching path ({}): {}", @@ -360,7 +420,11 @@ impl VfsBackend for StdBackend { fn unwatch(&mut self, path: &Path) -> io::Result<()> { let was_watched = self.watches.contains(path); - match self.watcher.unwatch(path) { + #[cfg(target_os = "macos")] + let result = self.watcher.unwatch(path); + #[cfg(not(target_os = "macos"))] + let result = self.debouncer.unwatch(path); + match result { Ok(()) => { if was_watched { log::info!("Unwatched path: {}", path.display());