//! Filesystem watcher with per-directory debouncing. //! //! Wraps the [`notify`] crate with a throttling pipeline patterned after //! ffb-server's `ingest::pipeline`: events are bucketed by directory, same- //! path events within a window are coalesced, and a tick loop flushes ready //! buckets. The caller provides an [`EventSink`] callback that applies //! emitted [`FsEvent`]s — typically to the QuickSearch database via the //! [`crate::db::repo`] helpers. //! //! # Two registration strategies //! //! Which one applies is [`crate::platform::WATCH_ROOTS_RECURSIVELY`]. //! //! **Per directory (inotify).** inotify has no recursive watch: one watch //! descriptor covers exactly one directory's entries. //! `RecursiveMode::Recursive` is emulated inside `notify` by walking the tree //! and adding one watch per directory — with no way to skip subtrees. That //! spent descriptors on `.git`, `node_modules`, and hidden directories whose //! events the indexer then discarded, and exhausted //! `fs.inotify.max_user_watches` on large roots. So this module walks the //! roots itself through [`crate::file_handling::filtered_dirs`] — the same //! pruning the indexer uses — and registers each surviving directory //! `NonRecursive`. Taking over recursion means also registering directories //! created later, which [`register_tree`] does from the event loop. //! //! **Per root (`ReadDirectoryChangesW`).** One handle covers the whole //! subtree, including directories created later, so the walk and the //! per-directory bookkeeping are skipped entirely. Registering per directory //! here would be actively harmful rather than merely wasteful: `notify` //! allocates a 16 KiB buffer inline per watch plus a directory handle, so a //! large tree would ask for gigabytes of buffers and tens of thousands of //! handles. The saving that pruning bought on inotify is unavailable — the //! events arrive whether or not we want them — so the same filters run on the //! event path instead, in [`is_event_interesting`]. //! //! # The cap //! //! Registration stops at [`WatcherConfig::max_watched_dirs`] and reports //! [`WatchError::TooManyDirectories`]; a kernel refusal reports //! [`WatchError::KernelLimit`]. Both are all-or-nothing — the whole //! watcher fails and no root gets live updates, leaving the coordinator's //! periodic reindex as the only refresh path. Partial registration is //! worse than none, because a half-watched root looks live while going //! silently stale. //! //! Only those two are fatal. A single directory the kernel refuses for its //! own reasons — an unreadable folder, one deleted mid-walk — is logged and //! skipped: it costs its own events, not the tree's, and letting it abort //! registration would report that incidental failure as the reason live //! updates are off. See [`add_watch`]. //! //! Neither limit is reachable under the per-root strategy, where the watch //! count is the number of configured roots. //! //! This module deliberately stays sync (std::thread + crossbeam-style //! channels via `std::sync::mpsc`) so it integrates cleanly with the //! existing indexer which is not async. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use notify::{Config as NotifyConfig, ErrorKind as NotifyErrorKind, Event as NotifyEvent, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; use crate::config::IgnoreSet; use crate::file_handling::{filtered_dirs, UnreadableDirs}; use crate::platform::path_has_hidden_component_under; /// Directory budget for live updates. Past this, watching costs more than /// it returns: the kernel's per-user `max_user_watches` is a shared /// resource, and a tree this size is cheaper to rescan on a timer than to /// track. Roughly 1 KiB of unswappable kernel memory per watch. /// /// Applies only where watches are taken per directory. Under a per-root /// recursive watch the count is the number of configured roots, so the cap is /// unreachable by design rather than by generosity. pub const DEFAULT_MAX_WATCHED_DIRS: usize = 128_000; /// An event surfaced to the caller after debouncing. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FsEvent { Create(PathBuf), Modify(PathBuf), Remove(PathBuf), /// Rename where both endpoints arrived in the same notify event. For /// split rename halves (From or To only) the watcher emits Remove/Create /// instead. Rename { from: PathBuf, to: PathBuf }, } /// Sink callback. Called on the watcher thread; implementors should keep /// work short and push heavier operations to their own worker. pub type EventSink = Arc; /// Which directories are worth a watch descriptor. Mirrors the indexer's /// walk filters so the watcher never spends a descriptor on a subtree the /// indexer would discard. #[derive(Debug, Clone)] pub struct WatchFilters { pub include_hidden: bool, pub follow_symlinks: bool, pub ignore: Arc, } /// Why live updates are unavailable. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WatchError { /// The indexed roots hold more directories than the cap allows. TooManyDirectories { dirs: usize, cap: usize }, /// The kernel refused a watch before our own cap was reached — /// `fs.inotify.max_user_watches` is lower than the cap, or other /// processes have consumed the shared budget. KernelLimit { registered: usize }, Other(String), } impl std::fmt::Display for WatchError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { WatchError::TooManyDirectories { dirs, cap } => write!( f, "the total number of directories to monitor exceeds {} \ (stopped counting at {}); live updates are disabled and \ changes are picked up by the periodic reindex instead", fmt_cap(*cap), dirs ), WatchError::KernelLimit { registered } => write!( f, "the system watch limit was reached after {} directories{}", registered, // Only inotify has a tunable the user can actually raise; // pointing a Windows user at a sysctl would be nonsense. if cfg!(target_os = "linux") { " (raise fs.inotify.max_user_watches to watch more)" } else { "" } ), WatchError::Other(msg) => write!(f, "{}", msg), } } } impl std::error::Error for WatchError {} /// Render the directory cap compactly: the default 128_000 reads "128k". fn fmt_cap(cap: usize) -> String { if cap >= 1000 && cap % 1000 == 0 { format!("{}k", cap / 1000) } else { cap.to_string() } } #[derive(Debug, Clone)] pub struct WatcherConfig { /// Per-directory debounce window. Bursts of events in the same directory /// collapse to one flush after this interval of quiet. pub throttle_window: Duration, /// How often the tick loop inspects the throttle map. Short ticks mean /// low latency for first-in-a-burst; long ticks lower CPU at idle. pub tick_interval: Duration, /// Maximum directories processed per tick. Caps the time spent in a /// single flush pass so long backlogs don't monopolize the thread. pub max_dirs_per_tick: usize, /// When to garbage-collect stale throttle entries (idle > window * N). pub prune_max_age_multiplier: u32, /// Directory budget; see [`DEFAULT_MAX_WATCHED_DIRS`]. pub max_watched_dirs: usize, } impl Default for WatcherConfig { fn default() -> Self { Self { throttle_window: Duration::from_secs(30), tick_interval: Duration::from_millis(500), max_dirs_per_tick: 64, prune_max_age_multiplier: 10, max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS, } } } /// The set of registered watches, plus the notify handle that owns them. /// /// Held behind a mutex because both the registering walk and the event loop /// add to it. The poll surface ([`Watcher::watched_dirs`], /// [`Watcher::is_degraded`]) reads atomics instead, so a coordinator poll /// never blocks behind a large subtree registration. struct WatchRegistry { raw: RecommendedWatcher, dirs: HashSet, cap: usize, } /// The mode every `watch()` call uses on this platform. See /// [`crate::platform::WATCH_ROOTS_RECURSIVELY`] for why it differs. const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; impl WatchRegistry { /// Register `dir` unless already watched. `Ok(false)` means "nothing to /// do" — already watched, or the path vanished mid-walk. fn add(&mut self, dir: &Path) -> Result { if self.dirs.contains(dir) { return Ok(false); } if self.dirs.len() >= self.cap { return Err(WatchError::TooManyDirectories { dirs: self.dirs.len(), cap: self.cap, }); } match self.raw.watch(dir, WATCH_MODE) { Ok(()) => { self.dirs.insert(dir.to_path_buf()); Ok(true) } // notify maps ENOSPC from inotify_add_watch to this. Err(e) if matches!(e.kind, NotifyErrorKind::MaxFilesWatch) => { Err(WatchError::KernelLimit { registered: self.dirs.len(), }) } // Deleted between the walk and the watch call; not an error. Err(e) if matches!(e.kind, NotifyErrorKind::PathNotFound) => Ok(false), Err(e) => Err(WatchError::Other(format!("watch {}: {}", dir.display(), e))), } } /// Forget `dir` and every watched directory beneath it, returning how /// many were dropped. /// /// `Path::starts_with` compares whole components, so `/a/bc` is not /// treated as living under `/a/b`. The kernel drops watches for deleted /// directories on its own; unwatching anyway keeps notify's internal /// descriptor map from growing across a long session of directory churn. fn remove_tree(&mut self, dir: &Path) -> usize { let doomed: Vec = self .dirs .iter() .filter(|d| d.starts_with(dir)) .cloned() .collect(); for d in &doomed { self.dirs.remove(d); let _ = self.raw.unwatch(d); } doomed.len() } } /// Register `dir` during startup, propagating only the budget limits. /// /// A directory the kernel refuses on its own terms — most often one the user /// cannot read, which the walk still hands us because its *parent* was /// readable — costs live updates that one directory, not the tree. Treating /// it as fatal used to abort the whole registration and report "permission /// denied" as the reason live updates were off, hiding the real, actionable /// limit from anyone whose roots also exceeded the directory budget. The /// budget errors stay fatal: those really do mean the tree can't be covered. fn add_watch(reg: &mut WatchRegistry, dir: &Path) -> Result<(), WatchError> { match reg.add(dir) { Ok(_) => Ok(()), Err(limit @ (WatchError::TooManyDirectories { .. } | WatchError::KernelLimit { .. })) => { Err(limit) } Err(e) => { crate::log_warn!("watcher: {}", e); Ok(()) } } } /// Handle to a running watcher. Dropping calls [`Self::stop`] implicitly. pub struct Watcher { stop_flag: Arc, handle: Option>, dir_count: Arc, degraded: Degraded, /// Held so the notify handle drops on stop, releasing every watch. _registry: Arc>, } /// Set when the watcher runs out of budget *after* starting, carrying which /// limit was hit so the UI can say the right thing. A plain flag would force /// the coordinator to guess. type Degraded = Arc>>; impl Watcher { /// Register watches for every indexable directory under `roots` and /// start the debouncing loop. /// /// Registration walks each root, so this takes proportional time on /// large trees — callers run it off their main loop. pub fn start( roots: I, filters: WatchFilters, config: WatcherConfig, sink: EventSink, ) -> Result where I: IntoIterator, P: AsRef, { let (tx, rx) = mpsc::channel::(); let degraded: Degraded = Arc::new(Mutex::new(None)); let degraded_cb = degraded.clone(); let raw = RecommendedWatcher::new( move |res: notify::Result| match res { Ok(ev) => { // A closed receiver just means the watcher was stopped; ignore. let _ = tx.send(ev); } Err(e) => { // The limit can also be hit asynchronously, when notify // reacts to a directory appearing. if matches!(e.kind, NotifyErrorKind::MaxFilesWatch) { let mut slot = degraded_cb.lock().unwrap(); slot.get_or_insert(WatchError::KernelLimit { registered: 0 }); } crate::log_warn!("watcher: notify error: {}", e); } }, NotifyConfig::default(), ) .map_err(|e| WatchError::Other(format!("create watcher: {}", e)))?; let registry = Arc::new(Mutex::new(WatchRegistry { raw, dirs: HashSet::new(), cap: config.max_watched_dirs, })); // Any error here drops `registry`, which drops the notify handle and // releases every watch already taken — the all-or-nothing guarantee. // Release is asynchronous: notify's Drop signals its event-loop // thread, which then closes the inotify fd. Measured at ~50 ms for a // few hundred watches, so a caller that immediately retries may // briefly see the old descriptors still charged to the user's quota. let roots: Vec = roots .into_iter() .map(|r| r.as_ref().to_path_buf()) .collect(); { let failures = UnreadableDirs::default(); let mut reg = registry.lock().unwrap(); for root in &roots { if crate::platform::WATCH_ROOTS_RECURSIVELY { // One watch covers the subtree. Ignored and hidden // subtrees can't be skipped here — nothing is registered // for them to skip — so their events are dropped on // arrival instead, in `is_event_interesting`. add_watch(&mut reg, root)?; continue; } let Some(root_str) = root.to_str() else { crate::log_warn!("watcher: skipping non-UTF-8 root {}", root.display()); continue; }; for entry in filtered_dirs( root_str, filters.follow_symlinks, filters.include_hidden, &filters.ignore, &failures, ) { add_watch(&mut reg, entry.path())?; } } if reg.dirs.is_empty() { return Err(WatchError::Other("no roots could be watched".into())); } } let dir_count = Arc::new(AtomicUsize::new(registry.lock().unwrap().dirs.len())); let stop_flag = Arc::new(AtomicBool::new(false)); let ctx = LoopCtx { sink, config, stop: stop_flag.clone(), registry: registry.clone(), filters, roots, dir_count: dir_count.clone(), degraded: degraded.clone(), }; let handle = thread::spawn(move || run_loop(rx, ctx)); Ok(Self { stop_flag, handle: Some(handle), dir_count, degraded, _registry: registry, }) } /// How many directories currently hold a watch descriptor. pub fn watched_dirs(&self) -> usize { self.dir_count.load(Ordering::Relaxed) } /// Which limit the watcher hit after starting, if any. `Some` means it /// can no longer see the whole tree; the coordinator polls this and /// falls back to periodic rescans. pub fn degraded_reason(&self) -> Option { self.degraded.lock().unwrap().clone() } /// Whether the watcher ran out of budget after starting. pub fn is_degraded(&self) -> bool { self.degraded.lock().unwrap().is_some() } /// Signal the background thread to stop and wait for it to join. Safe to /// call multiple times. pub fn stop(&mut self) { self.stop_flag.store(true, Ordering::Relaxed); if let Some(h) = self.handle.take() { let _ = h.join(); } } } impl Drop for Watcher { fn drop(&mut self) { self.stop(); } } /// Hand-written because `RecommendedWatcher` is not `Debug`, and the watch /// set behind a mutex is not worth locking to print. impl std::fmt::Debug for Watcher { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Watcher") .field("watched_dirs", &self.watched_dirs()) .field("degraded", &self.is_degraded()) .finish() } } /// Everything the event loop needs, bundled to keep signatures readable. struct LoopCtx { sink: EventSink, config: WatcherConfig, stop: Arc, registry: Arc>, filters: WatchFilters, /// The configured roots, needed to judge "hidden" relative to them: a /// root may itself sit under a hidden directory (`~/.config/app`, or /// anything below `%LOCALAPPDATA%`), and the walk keeps such a root. roots: Vec, dir_count: Arc, degraded: Degraded, } /// A queued event, deduplicated per path within a window. #[derive(Debug, Clone)] struct QueuedEvent { op: QueuedOp, } #[derive(Debug, Clone, Copy)] enum QueuedOp { Create, Modify, Remove, } #[derive(Debug)] struct DirThrottleEntry { /// Last time this entry's queue was flushed (or when the entry was /// created as leading-edge). record_time: Instant, /// Per-path pending op. Same path seen twice in a window keeps only the /// latest op — coalescing a rename-as-create+modify spam into one event. queue: HashMap, /// If true, the next tick flushes regardless of window age. Set for the /// first event in a previously-idle directory so it reacts fast. immediate: bool, } fn run_loop(rx: mpsc::Receiver, ctx: LoopCtx) { let mut throttle: HashMap = HashMap::new(); // Pending rename halves keyed by cookie are not supported by notify 6.x's // high-level API uniformly across backends; when From/To aren't bundled // we emit Remove/Create which remains correct semantically. let prune_interval_ticks = 20u32; let mut tick_counter: u32 = 0; loop { if ctx.stop.load(Ordering::Relaxed) { break; } // Drain incoming events. Block briefly to avoid spinning when idle. let deadline = Instant::now() + ctx.config.tick_interval; loop { if ctx.stop.load(Ordering::Relaxed) { break; } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { break; } match rx.recv_timeout(remaining) { Ok(ev) => handle_notify_event(&ev, &mut throttle, &ctx), Err(mpsc::RecvTimeoutError::Timeout) => break, Err(mpsc::RecvTimeoutError::Disconnected) => return, } } if ctx.stop.load(Ordering::Relaxed) { break; } // Tick: flush ready directories, up to max_dirs_per_tick. flush_ready(&mut throttle, &ctx.sink, &ctx.config); // Periodic GC of abandoned throttle entries. tick_counter = tick_counter.wrapping_add(1); if tick_counter % prune_interval_ticks == 0 { let max_age = ctx .config .throttle_window .saturating_mul(ctx.config.prune_max_age_multiplier); prune_stale(&mut throttle, max_age); } } } /// Register a directory that appeared after startup, and everything under /// it — a whole tree can be moved in with a single event. fn register_tree(ctx: &LoopCtx, root: &Path) { let Some(root_str) = root.to_str() else { return; }; let failures = UnreadableDirs::default(); let mut reg = ctx.registry.lock().unwrap(); for entry in filtered_dirs( root_str, ctx.filters.follow_symlinks, ctx.filters.include_hidden, &ctx.filters.ignore, &failures, ) { // Same policy as startup: only the budget limits are fatal. if let Err(limit) = add_watch(&mut reg, entry.path()) { // Live updates can no longer cover the tree. Stop here and let // the coordinator tear us down; a partially watched tree would // look live while going silently stale. Keep the first reason — // later ones are consequences of the same exhaustion. let mut slot = ctx.degraded.lock().unwrap(); slot.get_or_insert(limit); break; } } ctx.dir_count.store(reg.dirs.len(), Ordering::Relaxed); } /// Register `path` if it is a directory the indexer would keep. /// /// Uses the same path-based filters as [`crate::incremental::apply_fs_event`], /// so we never hold a descriptor for a directory whose events would be /// discarded on arrival. fn watch_if_new_dir(ctx: &LoopCtx, path: &Path) { // A recursive root watch already covers anything created beneath it. if crate::platform::WATCH_ROOTS_RECURSIVELY { return; } // Files are reported through their parent's watch; only directories // need one of their own. if !path.is_dir() { return; } if !is_event_interesting(ctx, path) { return; } register_tree(ctx, path); } /// Drop watches for a directory that went away. Cheap no-op for files. fn unwatch_tree(ctx: &LoopCtx, path: &Path) { // Nothing per-directory was ever registered, and the root's own watch // must outlive a deleted subdirectory. if crate::platform::WATCH_ROOTS_RECURSIVELY { return; } let mut reg = ctx.registry.lock().unwrap(); if reg.remove_tree(path) > 0 { ctx.dir_count.store(reg.dirs.len(), Ordering::Relaxed); } } /// Whether an event for `path` is worth queueing at all. /// /// The same predicate the walk applies, so the watcher and the indexer agree /// on which subtrees exist. Where a platform registers watches per directory /// this is mostly redundant — those subtrees were never watched — but under a /// recursive root watch it is the *only* thing keeping `node_modules` churn /// out of the throttle map. Applying it on both keeps one code path and drops /// ignored events a debounce window earlier than /// [`crate::incremental::apply_fs_event`] would. fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool { if ctx.filters.ignore.matches_path(path) { return false; } if !ctx.filters.include_hidden && path_has_hidden_component_under(path, &ctx.roots) { return false; } true } fn handle_notify_event( ev: &NotifyEvent, throttle: &mut HashMap, ctx: &LoopCtx, ) { // Rename events that carry both sides are emitted directly — they // can't be coalesced with same-dir creates/modifies meaningfully. if let EventKind::Modify(notify::event::ModifyKind::Name(kind)) = ev.kind { if matches!(kind, notify::event::RenameMode::Both) && ev.paths.len() == 2 { // A rename is only uninteresting when *both* ends are: moving a // file out of an ignored directory into a watched one is a real // Create, and the reverse is a real Remove. `apply_fs_event` // re-checks each end, so passing the pair through is safe. if !is_event_interesting(ctx, &ev.paths[0]) && !is_event_interesting(ctx, &ev.paths[1]) { return; } unwatch_tree(ctx, &ev.paths[0]); watch_if_new_dir(ctx, &ev.paths[1]); (ctx.sink)(FsEvent::Rename { from: ev.paths[0].clone(), to: ev.paths[1].clone(), }); return; } // Split renames (From alone, To alone) degrade to Remove/Create. } for p in &ev.paths { if !is_event_interesting(ctx, p) { continue; } let op = match ev.kind { EventKind::Create(_) => QueuedOp::Create, EventKind::Remove(_) => QueuedOp::Remove, EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::From)) => { QueuedOp::Remove } EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::To)) => { QueuedOp::Create } EventKind::Modify(_) => QueuedOp::Modify, _ => continue, }; // Keep the watch set in step with the tree before debouncing: a // directory created now may be populated before the throttle // window expires, and those child events need its watch in place. match op { QueuedOp::Create => watch_if_new_dir(ctx, p), QueuedOp::Remove => unwatch_tree(ctx, p), QueuedOp::Modify => {} } enqueue(throttle, p.clone(), op); } } fn enqueue( throttle: &mut HashMap, path: PathBuf, op: QueuedOp, ) { let dir = path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| path.clone()); let entry = throttle .entry(dir) .or_insert_with(|| DirThrottleEntry { record_time: Instant::now(), queue: HashMap::new(), immediate: true, }); // Coalesce: Remove after Create → drop both. Modify after Modify → one Modify. match (op, entry.queue.get(&path).map(|q| q.op)) { (QueuedOp::Remove, Some(QueuedOp::Create)) => { entry.queue.remove(&path); } _ => { entry .queue .insert(path, QueuedEvent { op }); } } } fn flush_ready( throttle: &mut HashMap, sink: &EventSink, config: &WatcherConfig, ) { let now = Instant::now(); // Collect ready dir keys first, up to max_dirs_per_tick. Copying keys // avoids borrow conflicts when we mutate entries below. let mut ready: Vec = Vec::new(); for (dir, entry) in throttle.iter() { let age = now.saturating_duration_since(entry.record_time); if entry.immediate || (!entry.queue.is_empty() && age >= config.throttle_window) { ready.push(dir.clone()); if ready.len() >= config.max_dirs_per_tick { break; } } } for dir in ready { if let Some(entry) = throttle.get_mut(&dir) { let drained: Vec<(PathBuf, QueuedOp)> = entry .queue .drain() .map(|(p, q)| (p, q.op)) .collect(); entry.immediate = false; entry.record_time = now; for (path, op) in drained { let ev = match op { QueuedOp::Create => FsEvent::Create(path), QueuedOp::Modify => FsEvent::Modify(path), QueuedOp::Remove => FsEvent::Remove(path), }; sink(ev); } } } } fn prune_stale(throttle: &mut HashMap, max_age: Duration) { let now = Instant::now(); throttle.retain(|_, entry| { !entry.queue.is_empty() || entry.immediate || now.saturating_duration_since(entry.record_time) < max_age }); } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex as StdMutex; fn sink_to_vec() -> (EventSink, Arc>>) { let v: Arc>> = Arc::new(StdMutex::new(Vec::new())); let v_clone = v.clone(); let s: EventSink = Arc::new(move |e| v_clone.lock().unwrap().push(e)); (s, v) } /// Filters matching the shipped defaults: hidden excluded, `.git` and /// `node_modules` ignored. fn default_filters() -> WatchFilters { WatchFilters { include_hidden: false, follow_symlinks: false, ignore: Arc::new( IgnoreSet::compile(&[".git".to_string(), "node_modules".to_string()]).unwrap(), ), } } fn fast_config() -> WatcherConfig { WatcherConfig { throttle_window: Duration::from_millis(50), tick_interval: Duration::from_millis(20), ..WatcherConfig::default() } } /// Unique temp directory; the repo has no `tempfile` dev-dependency. fn tmp_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "qs-watch-{}-{}-{}", tag, std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn enqueue_create_then_remove_cancels() { let mut map: HashMap = HashMap::new(); let p = PathBuf::from("/tmp/a.txt"); enqueue(&mut map, p.clone(), QueuedOp::Create); enqueue(&mut map, p.clone(), QueuedOp::Remove); let entry = map.get(p.parent().unwrap()).unwrap(); assert!(entry.queue.is_empty(), "Create then Remove should cancel"); } #[test] fn modify_after_modify_is_one() { let mut map: HashMap = HashMap::new(); let p = PathBuf::from("/tmp/a.txt"); enqueue(&mut map, p.clone(), QueuedOp::Modify); enqueue(&mut map, p.clone(), QueuedOp::Modify); let entry = map.get(p.parent().unwrap()).unwrap(); assert_eq!(entry.queue.len(), 1); } #[test] fn flush_ready_leading_edge_fires_immediately() { let mut map: HashMap = HashMap::new(); enqueue(&mut map, PathBuf::from("/tmp/a.txt"), QueuedOp::Create); let (sink, got) = sink_to_vec(); let config = WatcherConfig::default(); flush_ready(&mut map, &sink, &config); let got = got.lock().unwrap(); assert_eq!(got.len(), 1); assert!(matches!(got[0], FsEvent::Create(_))); } #[test] fn flush_ready_respects_max_dirs_per_tick() { let mut map: HashMap = HashMap::new(); for i in 0..10 { enqueue(&mut map, PathBuf::from(format!("/dir{}/a", i)), QueuedOp::Create); } let (sink, got) = sink_to_vec(); let mut config = WatcherConfig::default(); config.max_dirs_per_tick = 3; flush_ready(&mut map, &sink, &config); // Each dir contributes one event because each entry has one path. assert_eq!(got.lock().unwrap().len(), 3); } #[test] fn prune_stale_drops_empty_old_entries() { let mut map: HashMap = HashMap::new(); map.insert( PathBuf::from("/tmp"), DirThrottleEntry { record_time: Instant::now() - Duration::from_secs(3600), queue: HashMap::new(), immediate: false, }, ); prune_stale(&mut map, Duration::from_secs(1)); assert!(map.is_empty()); } #[test] fn prune_stale_keeps_active_entries() { let mut map: HashMap = HashMap::new(); let mut queue = HashMap::new(); queue.insert( PathBuf::from("/tmp/a"), QueuedEvent { op: QueuedOp::Modify }, ); map.insert( PathBuf::from("/tmp"), DirThrottleEntry { record_time: Instant::now() - Duration::from_secs(3600), queue, immediate: false, }, ); prune_stale(&mut map, Duration::from_secs(1)); assert_eq!(map.len(), 1); } /// End-to-end: create files in a tempdir, verify the watcher surfaces /// events via the sink. Short timeouts keep the test fast; if it becomes /// flaky on slow CI, increase the sleeps. #[test] fn e2e_create_modify_remove_surfaces() { let dir = tmp_dir("e2e"); let (sink, got) = sink_to_vec(); let mut w = Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink).unwrap(); let f = dir.join("hello.txt"); std::fs::write(&f, "hi").unwrap(); std::thread::sleep(Duration::from_millis(150)); std::fs::write(&f, "hi again").unwrap(); std::thread::sleep(Duration::from_millis(200)); std::fs::remove_file(&f).unwrap(); std::thread::sleep(Duration::from_millis(200)); w.stop(); let events = got.lock().unwrap().clone(); // Expect at least one Create (or Modify, depending on backend) and one Remove. // Some platforms emit Create+Modify for `write`. let has_create_or_modify = events .iter() .any(|e| matches!(e, FsEvent::Create(_) | FsEvent::Modify(_))); let has_remove = events.iter().any(|e| matches!(e, FsEvent::Remove(_))); assert!(has_create_or_modify, "no create/modify in {:?}", events); assert!(has_remove, "no remove in {:?}", events); std::fs::remove_dir_all(&dir).ok(); } /// The whole point of the rewrite: descriptors are not spent on /// directories the indexer would discard. #[test] fn ignored_and_hidden_dirs_are_not_registered() { let dir = tmp_dir("filter"); for sub in ["keep", "keep/nested", ".git", ".git/objects", "node_modules", "node_modules/pkg", ".hidden"] { std::fs::create_dir_all(dir.join(sub)).unwrap(); } let w = Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0) .unwrap(); // root + keep + keep/nested. The 4 ignored/hidden dirs cost nothing. assert_eq!( w.watched_dirs(), 3, "expected root, keep, keep/nested only" ); drop(w); std::fs::remove_dir_all(&dir).ok(); } #[test] fn include_hidden_registers_dotted_dirs() { let dir = tmp_dir("hidden"); std::fs::create_dir_all(dir.join(".hidden")).unwrap(); let filters = WatchFilters { include_hidden: true, ..default_filters() }; let w = Watcher::start(std::iter::once(&dir), filters, fast_config(), sink_to_vec().0) .unwrap(); assert_eq!(w.watched_dirs(), 2, "root + .hidden"); drop(w); std::fs::remove_dir_all(&dir).ok(); } /// Exceeding the cap fails the whole registration — no root gets /// partial live updates. #[test] fn exceeding_the_cap_fails_all_or_nothing() { let dir = tmp_dir("cap"); for sub in ["a", "b", "c", "d"] { std::fs::create_dir_all(dir.join(sub)).unwrap(); } let config = WatcherConfig { max_watched_dirs: 2, ..fast_config() }; let err = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) .unwrap_err(); assert_eq!( err, WatchError::TooManyDirectories { dirs: 2, cap: 2 }, "5 directories under a cap of 2 must fail" ); std::fs::remove_dir_all(&dir).ok(); } /// One folder the user cannot read is not a reason to switch live /// updates off for every root — it costs its own events only. #[test] #[cfg(unix)] fn an_unreadable_directory_is_skipped_not_fatal() { let dir = tmp_dir("denied"); std::fs::create_dir_all(dir.join("open")).unwrap(); let locked = dir.join("locked"); std::fs::create_dir_all(&locked).unwrap(); crate::platform::deny_read(&locked).unwrap(); let started = Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0); crate::platform::restore_read(&locked).ok(); let w = started.expect("an unreadable directory must not fail the watcher"); assert_eq!(w.watched_dirs(), 2, "root + open; locked is skipped"); assert!(!w.is_degraded()); drop(w); std::fs::remove_dir_all(&dir).ok(); } /// The bug this guards: an unreadable directory aborted registration /// with its own error, so a tree that was *also* over the cap reported /// "permission denied" as the reason live updates were off — hiding the /// one limit the user can actually act on. #[test] #[cfg(unix)] fn the_cap_outranks_an_unreadable_directory() { let dir = tmp_dir("denied-cap"); for sub in ["a", "b", "c", "d"] { std::fs::create_dir_all(dir.join(sub)).unwrap(); } let locked = dir.join("locked"); std::fs::create_dir_all(&locked).unwrap(); crate::platform::deny_read(&locked).unwrap(); let config = WatcherConfig { max_watched_dirs: 2, ..fast_config() }; let started = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0); crate::platform::restore_read(&locked).ok(); // Whichever order the walk visits them in, the cap is what stops us. assert_eq!( started.unwrap_err(), WatchError::TooManyDirectories { dirs: 2, cap: 2 }, "the reported reason must be the cap, not the unreadable folder" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn a_tree_inside_the_cap_registers() { let dir = tmp_dir("undercap"); std::fs::create_dir_all(dir.join("a")).unwrap(); let config = WatcherConfig { max_watched_dirs: 2, ..fast_config() }; let w = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) .unwrap(); assert_eq!(w.watched_dirs(), 2); assert!(!w.is_degraded()); drop(w); std::fs::remove_dir_all(&dir).ok(); } /// Regression guard for taking over recursion from notify: a directory /// created after startup must get its own watch, or its contents are /// invisible to live updates. #[test] fn a_directory_created_after_start_is_watched() { let dir = tmp_dir("newdir"); let (sink, got) = sink_to_vec(); let mut w = Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink).unwrap(); assert_eq!(w.watched_dirs(), 1, "only the root to begin with"); let sub = dir.join("later"); std::fs::create_dir(&sub).unwrap(); std::thread::sleep(Duration::from_millis(200)); assert_eq!(w.watched_dirs(), 2, "the new directory must be watched"); // A file inside it is only visible if that watch really landed. let f = sub.join("inside.txt"); std::fs::write(&f, "hi").unwrap(); std::thread::sleep(Duration::from_millis(300)); w.stop(); let events = got.lock().unwrap().clone(); assert!( events.iter().any(|e| matches!( e, FsEvent::Create(p) | FsEvent::Modify(p) if p == &f )), "no event for the file in the new directory: {:?}", events ); std::fs::remove_dir_all(&dir).ok(); } /// A whole tree can arrive in one event; every directory in it needs a /// watch, not just the top. #[test] fn a_moved_in_tree_registers_every_directory() { let staging = tmp_dir("staging"); let dir = tmp_dir("movein"); std::fs::create_dir_all(staging.join("tree/one/two")).unwrap(); let mut w = Watcher::start( std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0, ) .unwrap(); assert_eq!(w.watched_dirs(), 1); std::fs::rename(staging.join("tree"), dir.join("tree")).unwrap(); std::thread::sleep(Duration::from_millis(300)); assert_eq!(w.watched_dirs(), 4, "root + tree + tree/one + tree/one/two"); w.stop(); std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&staging).ok(); } #[test] fn a_removed_directory_releases_its_watches() { let dir = tmp_dir("rmdir"); std::fs::create_dir_all(dir.join("gone/deep")).unwrap(); let mut w = Watcher::start( std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0, ) .unwrap(); assert_eq!(w.watched_dirs(), 3, "root + gone + gone/deep"); std::fs::remove_dir_all(dir.join("gone")).unwrap(); std::thread::sleep(Duration::from_millis(300)); assert_eq!(w.watched_dirs(), 1, "descendants released with the parent"); w.stop(); std::fs::remove_dir_all(&dir).ok(); } /// `Path::starts_with` compares components, so a sibling sharing a name /// prefix must survive its neighbour's removal. #[test] fn remove_tree_does_not_match_name_prefixes() { let dir = tmp_dir("prefix"); std::fs::create_dir_all(dir.join("b")).unwrap(); std::fs::create_dir_all(dir.join("bc")).unwrap(); let mut w = Watcher::start( std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0, ) .unwrap(); assert_eq!(w.watched_dirs(), 3); std::fs::remove_dir_all(dir.join("b")).unwrap(); std::thread::sleep(Duration::from_millis(300)); assert_eq!(w.watched_dirs(), 2, "root + bc; /a/bc is not under /a/b"); w.stop(); std::fs::remove_dir_all(&dir).ok(); } /// Crossing the cap at runtime (rather than at startup) must record /// *which* limit was hit, so the coordinator doesn't have to guess. #[test] fn running_out_of_budget_later_records_the_reason() { let dir = tmp_dir("degrade"); let config = WatcherConfig { max_watched_dirs: 2, ..fast_config() }; let mut w = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) .unwrap(); assert!(!w.is_degraded(), "one directory is under the cap of 2"); // Two more directories: the first fits, the second cannot. std::fs::create_dir(dir.join("fits")).unwrap(); std::thread::sleep(Duration::from_millis(200)); std::fs::create_dir(dir.join("overflows")).unwrap(); std::thread::sleep(Duration::from_millis(300)); assert_eq!( w.degraded_reason(), Some(WatchError::TooManyDirectories { dirs: 2, cap: 2 }), "the cap, not a kernel limit" ); assert_eq!(w.watched_dirs(), 2, "never registers past the cap"); w.stop(); std::fs::remove_dir_all(&dir).ok(); } #[test] fn a_created_ignored_directory_is_not_watched() { let dir = tmp_dir("newignored"); let mut w = Watcher::start( std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0, ) .unwrap(); std::fs::create_dir(dir.join("node_modules")).unwrap(); std::fs::create_dir(dir.join(".cache")).unwrap(); std::thread::sleep(Duration::from_millis(300)); assert_eq!(w.watched_dirs(), 1, "neither ignored nor hidden dirs count"); w.stop(); std::fs::remove_dir_all(&dir).ok(); } }