1211 lines
44 KiB
Rust
1211 lines
44 KiB
Rust
//! 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<dyn Fn(FsEvent) + Send + Sync + 'static>;
|
|
|
|
/// 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<IgnoreSet>,
|
|
}
|
|
|
|
/// 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<PathBuf>,
|
|
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<bool, WatchError> {
|
|
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<PathBuf> = 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<AtomicBool>,
|
|
handle: Option<JoinHandle<()>>,
|
|
dir_count: Arc<AtomicUsize>,
|
|
degraded: Degraded,
|
|
/// Held so the notify handle drops on stop, releasing every watch.
|
|
_registry: Arc<Mutex<WatchRegistry>>,
|
|
}
|
|
|
|
/// 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<Mutex<Option<WatchError>>>;
|
|
|
|
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<I, P>(
|
|
roots: I,
|
|
filters: WatchFilters,
|
|
config: WatcherConfig,
|
|
sink: EventSink,
|
|
) -> Result<Self, WatchError>
|
|
where
|
|
I: IntoIterator<Item = P>,
|
|
P: AsRef<Path>,
|
|
{
|
|
let (tx, rx) = mpsc::channel::<NotifyEvent>();
|
|
let degraded: Degraded = Arc::new(Mutex::new(None));
|
|
let degraded_cb = degraded.clone();
|
|
let raw = RecommendedWatcher::new(
|
|
move |res: notify::Result<NotifyEvent>| 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<PathBuf> = 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<WatchError> {
|
|
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<AtomicBool>,
|
|
registry: Arc<Mutex<WatchRegistry>>,
|
|
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<PathBuf>,
|
|
dir_count: Arc<AtomicUsize>,
|
|
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<PathBuf, QueuedEvent>,
|
|
/// 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<NotifyEvent>, ctx: LoopCtx) {
|
|
let mut throttle: HashMap<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry>,
|
|
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<PathBuf, DirThrottleEntry>,
|
|
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<PathBuf, DirThrottleEntry>,
|
|
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<PathBuf> = 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<PathBuf, DirThrottleEntry>, 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<StdMutex<Vec<FsEvent>>>) {
|
|
let v: Arc<StdMutex<Vec<FsEvent>>> = 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<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry> = 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<PathBuf, DirThrottleEntry> = 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();
|
|
}
|
|
}
|