use std::{
cmp::Reverse,
collections::{HashMap, HashSet, VecDeque},
fs, io,
path::{Path, PathBuf},
sync::Arc,
};
use normalize_path::NormalizePath;
use notify::RecursiveMode;
use crate::{
error::{FsWatcherError, RuntimeError},
filter::Filterer,
WatchedPath,
};
use super::{
backend::{classify_resource_error, Backend, BatchWatchResult, DisconnectedBackend},
notify_multi_path_errors, recursion_strategy, RecursionStrategy, Watcher,
};
pub(super) trait Scanner: Send {
fn classify(&self, path: &Path, follow_symlinks: bool) -> io::Result<EntryKind>;
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
fn scan(&self, path: &Path, visit: &mut dyn FnMut(io::Result<PathBuf>)) -> io::Result<()>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum EntryKind {
Directory(PathBuf),
NonFollowedSymlink,
Other,
}
pub(super) struct FsScanner;
impl Scanner for FsScanner {
fn classify(&self, path: &Path, follow_symlinks: bool) -> io::Result<EntryKind> {
let link_metadata = fs::symlink_metadata(path)?;
if link_metadata.file_type().is_symlink() && !follow_symlinks {
return Ok(EntryKind::NonFollowedSymlink);
}
let metadata = if link_metadata.file_type().is_symlink() {
fs::metadata(path)?
} else {
link_metadata
};
if metadata.is_dir() {
Ok(EntryKind::Directory(fs::canonicalize(path)?))
} else {
Ok(EntryKind::Other)
}
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
fs::canonicalize(path)
}
fn scan(&self, path: &Path, visit: &mut dyn FnMut(io::Result<PathBuf>)) -> io::Result<()> {
for entry in fs::read_dir(path)? {
visit(entry.map(|entry| entry.path()));
}
Ok(())
}
}
type Root = WatchedPath;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum Identity {
Canonical(PathBuf),
Lexical(PathBuf),
}
#[derive(Debug)]
struct LogicalWatch {
identity: Identity,
owners: HashMap<Root, u64>,
}
#[derive(Debug)]
struct PhysicalWatch {
watch_path: PathBuf,
logicals: HashSet<PathBuf>,
guards: HashSet<PathBuf>,
mode: RecursiveMode,
registered: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CoverageWatch {
path: PathBuf,
canonical: PathBuf,
mode: RecursiveMode,
}
#[derive(Debug)]
struct ReplayWatch {
watch_path: PathBuf,
mode: RecursiveMode,
logicals: Vec<(PathBuf, LogicalWatch)>,
}
#[derive(Debug)]
struct GuardWatch {
identity: Identity,
owners: HashSet<Root>,
}
#[derive(Debug)]
enum Work {
Coverage,
Guard(Root),
Root(Root),
Candidate {
root: Root,
path: PathBuf,
transient_retries: u8,
},
Scan {
root: Root,
path: PathBuf,
transient_retries: u8,
},
Probe(PathBuf),
}
impl Work {
fn path(&self) -> &Path {
match self {
Self::Coverage => Path::new(""),
Self::Guard(root) => root.path.parent().unwrap_or(&root.path),
Self::Root(root) => &root.path,
Self::Candidate { path, .. } | Self::Scan { path, .. } | Self::Probe(path) => path,
}
}
}
#[derive(Debug)]
struct SweepEntry {
path: PathBuf,
root: Root,
epoch: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Traversing,
Sweeping,
Settled,
}
#[derive(Default)]
pub(super) struct StepResult {
pub(super) errors: Vec<RuntimeError>,
pub(super) rebuild_backend: bool,
}
const TRANSIENT_RETRIES: u8 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AddResult {
Added,
Skipped,
Invalidated,
Rebuild,
}
enum ExplicitRootState {
Entry(EntryKind),
Missing { path: PathBuf, error: io::Error },
Unsafe,
}
const fn path_not_found(error: ¬ify::Error) -> bool {
matches!(error.kind, notify::ErrorKind::PathNotFound)
}
fn mode_satisfies(actual: RecursiveMode, requested: RecursiveMode) -> bool {
actual == requested
|| matches!(
(actual, requested),
(RecursiveMode::Recursive, RecursiveMode::NonRecursive)
)
}
pub(super) struct Recursor {
backend: Box<dyn Backend>,
scanner: Box<dyn Scanner>,
kind: Watcher,
backend_kind: notify::WatcherKind,
strategy: RecursionStrategy,
follow_symlinks: bool,
cwd: PathBuf,
filter: Arc<dyn Filterer>,
roots: HashSet<Root>,
epoch: u64,
logical: HashMap<PathBuf, LogicalWatch>,
physical: HashMap<Identity, PhysicalWatch>,
coverage_desired: Vec<CoverageWatch>,
coverage_installed: Vec<CoverageWatch>,
coverage_pending: bool,
coverage_work_pending: bool,
replay_desired: HashMap<Identity, ReplayWatch>,
replay_queue: VecDeque<Identity>,
rebuild_exclusions: HashSet<PathBuf>,
guards: HashMap<PathBuf, GuardWatch>,
root_guards: HashMap<Root, PathBuf>,
seen: HashMap<Root, HashSet<Identity>>,
work: VecDeque<Work>,
pending_removals: VecDeque<PathBuf>,
pending_removal_set: HashSet<PathBuf>,
pending_owner_removals: VecDeque<(PathBuf, Root)>,
sweep: VecDeque<SweepEntry>,
phase: Phase,
rebuild_requested: bool,
resource_latched: bool,
resource_reported: bool,
skipped_additions: HashSet<PathBuf>,
addition_failures: HashMap<PathBuf, u8>,
retry_candidates: HashSet<(Root, PathBuf)>,
retry_roots: HashSet<Root>,
removed_prefixes: HashSet<PathBuf>,
refresh_tombstones: bool,
}
impl Recursor {
pub(super) fn new(
backend: Box<dyn Backend>,
scanner: Box<dyn Scanner>,
kind: Watcher,
backend_kind: notify::WatcherKind,
follow_symlinks: bool,
cwd: PathBuf,
filter: Arc<dyn Filterer>,
) -> Self {
Self {
backend,
scanner,
kind,
backend_kind,
strategy: recursion_strategy(backend_kind),
follow_symlinks,
cwd,
filter,
roots: HashSet::new(),
epoch: 0,
logical: HashMap::new(),
physical: HashMap::new(),
coverage_desired: Vec::new(),
coverage_installed: Vec::new(),
coverage_pending: matches!(
recursion_strategy(backend_kind),
RecursionStrategy::RecursiveRoots
),
coverage_work_pending: matches!(
recursion_strategy(backend_kind),
RecursionStrategy::RecursiveRoots
),
replay_desired: HashMap::new(),
replay_queue: VecDeque::new(),
rebuild_exclusions: HashSet::new(),
guards: HashMap::new(),
root_guards: HashMap::new(),
seen: HashMap::new(),
work: VecDeque::new(),
pending_removals: VecDeque::new(),
pending_removal_set: HashSet::new(),
pending_owner_removals: VecDeque::new(),
sweep: VecDeque::new(),
phase: Phase::Settled,
rebuild_requested: false,
resource_latched: false,
resource_reported: false,
skipped_additions: HashSet::new(),
addition_failures: HashMap::new(),
retry_candidates: HashSet::new(),
retry_roots: HashSet::new(),
removed_prefixes: HashSet::new(),
refresh_tombstones: false,
}
}
pub(super) const fn is_managed(&self) -> bool {
self.strategy.owns_logical_tree()
}
const fn retains_directory_root_guard(&self) -> bool {
matches!(
self.backend_kind,
notify::WatcherKind::ReadDirectoryChangesWatcher | notify::WatcherKind::Fsevent
)
}
const fn retains_nondirectory_root_guard(&self) -> bool {
matches!(
self.backend_kind,
notify::WatcherKind::Inotify | notify::WatcherKind::Fsevent
)
}
pub(super) fn needs_retry(&self) -> bool {
self.coverage_pending
|| !self.retry_roots.is_empty()
|| !self.retry_candidates.is_empty()
|| !self.replay_desired.is_empty()
|| self.resource_latched
}
pub(super) fn event_is_public(&self, event: ¬ify::Event) -> bool {
event.paths.is_empty()
|| event.paths.iter().any(|path| {
let path = self.absolute(path);
if self.strategy != RecursionStrategy::RecursiveRoots {
return self.roots.iter().any(|root| {
path == root.path
|| (root.recursive && path.starts_with(&root.path))
|| (!root.recursive && path.parent() == Some(root.path.as_path()))
});
}
self.projected_topology_paths(&path)
.into_iter()
.any(|path| {
self.roots.iter().any(|root| path == root.path)
|| self.logical.contains_key(&path)
|| path
.parent()
.map_or(false, |parent| self.logical.contains_key(parent))
})
})
}
pub(super) fn has_work(&self) -> bool {
self.rebuild_requested
|| !self.replay_queue.is_empty()
|| !self.pending_removals.is_empty()
|| !self.pending_owner_removals.is_empty()
|| !self.work.is_empty()
|| !self.sweep.is_empty()
|| self.phase != Phase::Settled
}
pub(super) fn reconcile(&mut self, pathset: &[WatchedPath], filter: Arc<dyn Filterer>) {
self.filter = filter;
let roots: HashSet<_> = pathset
.iter()
.map(|path| Root {
path: self.absolute(path.path.as_ref()),
recursive: path.recursive,
})
.collect();
if self.strategy == RecursionStrategy::RecursiveRoots && roots != self.roots {
self.coverage_desired.clear();
self.coverage_pending = true;
self.coverage_work_pending = true;
}
self.roots = roots;
self.begin_epoch();
}
pub(super) fn rescan(&mut self) {
self.begin_epoch();
if self.strategy == RecursionStrategy::PerDirectory {
self.rebuild_requested = true;
}
}
pub(super) fn prepare_backend_rebuild(&mut self) {
if self.strategy == RecursionStrategy::RecursiveRoots {
self.backend = Box::new(DisconnectedBackend);
self.coverage_installed.clear();
self.coverage_pending = true;
self.coverage_work_pending = true;
self.replay_desired.clear();
self.replay_queue.clear();
self.rebuild_exclusions.clear();
self.resource_latched = false;
self.restart_epoch_work();
return;
}
let mut desired = std::mem::take(&mut self.replay_desired);
let mut logical = std::mem::take(&mut self.logical);
for (identity, physical) in &self.physical {
let mut logicals: Vec<_> = physical
.logicals
.iter()
.filter(|path| !self.rebuild_exclusions.contains(*path))
.filter(|path| {
!self
.removed_prefixes
.iter()
.any(|prefix| path.starts_with(prefix))
})
.filter_map(|path| logical.remove(path).map(|watch| (path.clone(), watch)))
.collect();
if logicals.is_empty() {
continue;
}
logicals.sort_by(|(left, _), (right, _)| left.cmp(right));
let watch_path = if logicals
.iter()
.any(|(path, _)| path == &physical.watch_path)
{
physical.watch_path.clone()
} else {
logicals[0].0.clone()
};
desired.insert(
identity.clone(),
ReplayWatch {
watch_path,
mode: physical.mode,
logicals,
},
);
}
self.rebuild_exclusions.clear();
self.backend = Box::new(DisconnectedBackend);
self.physical.clear();
self.sweep.clear();
self.resource_latched = false;
self.replay_desired = desired;
self.replay_queue.clear();
self.requeue_replay();
self.restart_epoch_work();
}
pub(super) fn install_backend(&mut self, backend: Box<dyn Backend>) {
self.backend = backend;
}
pub(super) fn replay_count(&self) -> usize {
if self.strategy == RecursionStrategy::RecursiveRoots {
return self.coverage_desired.len().max(1);
}
self.replay_desired
.len()
.saturating_add(self.physical.len())
.saturating_add(self.guards.len())
}
pub(super) fn defer_replay(&mut self) {
self.replay_queue.clear();
}
pub(super) fn replay_backend_snapshot(&mut self) -> StepResult {
if self.strategy == RecursionStrategy::RecursiveRoots {
self.work.retain(|work| !matches!(work, Work::Coverage));
let mut result = StepResult::default();
self.step_coverage(&mut result);
return result;
}
let mut combined = StepResult::default();
let budget = self.replay_queue.len();
for _ in 0..budget {
let Some(identity) = self.replay_queue.pop_front() else {
break;
};
let mut step = StepResult::default();
self.step_replay(identity, &mut step);
combined.errors.append(&mut step.errors);
combined.rebuild_backend |= step.rebuild_backend;
if combined.rebuild_backend || self.resource_latched {
return combined;
}
}
let guard_roots: Vec<_> = self.root_guards.keys().cloned().collect();
for root in guard_roots {
let mut step = StepResult::default();
self.ensure_guard(root, &mut step);
combined.errors.append(&mut step.errors);
combined.rebuild_backend |= step.rebuild_backend;
if combined.rebuild_backend || self.resource_latched {
break;
}
}
combined
}
#[cfg(test)]
fn replace_backend(&mut self, backend: Box<dyn Backend>) {
self.prepare_backend_rebuild();
self.install_backend(backend);
}
pub(super) fn topology_create(&mut self, path: PathBuf) {
if !self.is_managed() {
return;
}
let path = self.absolute(&path);
for path in self.projected_topology_paths(&path) {
self.topology_create_one(path);
}
}
fn topology_create_one(&mut self, path: PathBuf) {
self.topology_create_one_with_removal(path, true);
}
fn topology_create_one_with_removal(&mut self, path: PathBuf, queue_removal: bool) {
self.clear_authoritative_prefix(&path);
let mut roots = self.candidate_roots(&path, false);
if queue_removal {
self.queue_remove_prefix(&path);
}
self.requeue_guarded_roots_below(&path);
self.requeue_configured_roots_below(&path);
for root in roots.drain() {
self.seen.remove(&root);
self.retry_candidates.insert((root.clone(), path.clone()));
self.work.push_front(Work::Candidate {
root,
path: path.clone(),
transient_retries: TRANSIENT_RETRIES,
});
}
}
pub(super) fn topology_remove(&mut self, path: PathBuf) {
if !self.is_managed() {
return;
}
let path = self.absolute(&path);
for path in self.projected_topology_paths(&path) {
self.topology_remove_one(path);
}
}
fn topology_remove_one(&mut self, path: PathBuf) {
self.refresh_tombstones = false;
self.removed_prefixes.insert(path.clone());
let mut affected: HashSet<_> = self
.root_guards
.iter()
.filter(|(root, guard)| root.path.starts_with(&path) || guard.starts_with(&path))
.map(|(root, _)| root.clone())
.collect();
affected.extend(
self.roots
.iter()
.filter(|root| root.path.starts_with(&path))
.cloned(),
);
self.queue_remove_prefix(&path);
for root in affected {
self.work.push_back(Work::Guard(root));
}
}
pub(super) fn topology_rename(&mut self, from: PathBuf, to: PathBuf) {
if !self.is_managed() {
return;
}
let from = self.absolute(&from);
let to = self.absolute(&to);
let from = self.projected_topology_paths(&from);
let to = self.projected_topology_paths(&to);
for path in from {
self.topology_remove_one(path);
}
for path in &to {
self.topology_remove_one(path.clone());
}
for path in to {
self.topology_create_one_with_removal(path, false);
}
}
pub(super) fn topology_ambiguous(&mut self, path: PathBuf) {
if !self.is_managed() {
return;
}
let path = self.absolute(&path);
for path in self.projected_topology_paths(&path) {
self.topology_ambiguous_one(path);
}
}
fn topology_ambiguous_one(&mut self, path: PathBuf) {
self.clear_authoritative_prefix(&path);
self.queue_remove_prefix(&path);
self.work.push_back(Work::Probe(path.clone()));
self.requeue_guarded_roots_below(&path);
self.requeue_configured_roots_below(&path);
}
fn projected_topology_paths(&self, path: &Path) -> Vec<PathBuf> {
let mut projected = HashSet::from([path.to_owned()]);
if let Some(physical) = self.physical.get(&Identity::Canonical(path.to_owned())) {
projected.extend(physical.logicals.iter().chain(&physical.guards).cloned());
}
let Some(parent) = path.parent() else {
return projected.into_iter().collect();
};
let Some(name) = path.file_name() else {
return projected.into_iter().collect();
};
let mut identities: HashSet<_> = self
.logical
.get(parent)
.map(|watch| &watch.identity)
.into_iter()
.chain(self.guards.get(parent).map(|watch| &watch.identity))
.cloned()
.collect();
identities.insert(Identity::Canonical(parent.to_owned()));
for identity in identities {
let Some(physical) = self.physical.get(&identity) else {
continue;
};
for alias in physical.logicals.iter().chain(&physical.guards) {
projected.insert(alias.join(name));
}
}
let mut projected: Vec<_> = projected.into_iter().collect();
projected.sort();
projected
}
fn clear_authoritative_prefix(&mut self, path: &Path) {
self.removed_prefixes
.retain(|prefix| !prefix.starts_with(path) && !path.starts_with(prefix));
self.skipped_additions
.retain(|candidate| !candidate.starts_with(path));
self.addition_failures
.retain(|candidate, _| !candidate.starts_with(path));
}
fn requeue_guarded_roots_below(&mut self, path: &Path) {
let guarded: Vec<_> = self
.root_guards
.keys()
.filter(|root| root.path.starts_with(path))
.cloned()
.collect();
for root in guarded {
self.work.push_front(Work::Guard(root));
}
}
fn requeue_configured_roots_below(&mut self, path: &Path) {
let roots: Vec<_> = self
.roots
.iter()
.filter(|root| root.path.starts_with(path))
.cloned()
.collect();
for root in roots {
self.seen.remove(&root);
let already_queued = self.work.iter().any(
|work| matches!(work, Work::Root(queued) | Work::Guard(queued) if queued == &root),
);
if !already_queued {
self.work.push_back(Work::Root(root));
}
}
}
pub(super) fn step(&mut self) -> StepResult {
let mut result = StepResult::default();
if self.rebuild_requested {
self.rebuild_requested = false;
result.rebuild_backend = true;
return result;
}
if let Some(identity) = self.replay_queue.pop_front() {
self.step_replay(identity, &mut result);
return result;
}
if let Some(path) = self.pending_removals.pop_front() {
self.pending_removal_set.remove(&path);
self.remove_logical(&path, &mut result);
return result;
}
if let Some((path, root)) = self.pending_owner_removals.pop_front() {
let entry = SweepEntry {
path,
root,
epoch: self.epoch,
};
self.remove_owner_force(entry, &mut result);
return result;
}
if self.refresh_tombstones {
self.removed_prefixes.clear();
self.refresh_tombstones = false;
}
if let Some(work) = self.work.pop_front() {
self.step_work(work, &mut result);
return result;
}
match self.phase {
Phase::Traversing => {
self.prepare_sweep();
if self.sweep.is_empty() {
self.settle();
}
}
Phase::Sweeping => {
if let Some(entry) = self.sweep.pop_front() {
self.remove_owner(entry, &mut result);
} else {
self.settle();
}
}
Phase::Settled => {}
}
result
}
fn begin_epoch(&mut self) {
self.epoch = self
.epoch
.checked_add(1)
.expect("filesystem recursion epoch overflow");
self.resource_latched = false;
self.resource_reported = false;
self.skipped_additions.clear();
self.addition_failures.clear();
self.rebuild_exclusions.clear();
self.refresh_tombstones = true;
self.restart_epoch_work();
}
fn restart_epoch_work(&mut self) {
self.work.clear();
self.seen.clear();
self.sweep.clear();
self.phase = Phase::Traversing;
self.retry_roots.retain(|root| self.roots.contains(root));
self.retry_candidates
.retain(|(root, _)| self.roots.contains(root));
self.requeue_replay();
let mut roots: Vec<_> = self.roots.iter().cloned().collect();
roots.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| right.recursive.cmp(&left.recursive))
});
if self.coverage_work_pending {
self.work.push_back(Work::Coverage);
}
if self.is_managed() {
let mut guarded: Vec<_> = self.root_guards.keys().cloned().collect();
guarded.sort_by(|left, right| left.path.cmp(&right.path));
self.work.extend(guarded.into_iter().map(Work::Guard));
self.work.extend(
self.retry_candidates
.iter()
.filter(|(root, _)| self.roots.contains(root))
.cloned()
.map(|(root, path)| Work::Candidate {
root,
path,
transient_retries: TRANSIENT_RETRIES,
}),
);
}
self.work.extend(
self.retry_roots
.iter()
.filter(|root| self.roots.contains(*root))
.cloned()
.map(Work::Root),
);
self.work.extend(roots.into_iter().map(Work::Root));
}
fn requeue_replay(&mut self) {
let queued: HashSet<_> = self.replay_queue.iter().cloned().collect();
let mut replay: Vec<_> = self
.replay_desired
.iter()
.filter(|(identity, _)| !queued.contains(*identity))
.map(|(identity, watch)| (watch.watch_path.clone(), identity.clone()))
.collect();
replay.sort_by(|(left, _), (right, _)| left.cmp(right));
self.replay_queue
.extend(replay.into_iter().map(|(_, identity)| identity));
}
fn schedule_retries(&mut self) {
self.resource_latched = false;
self.requeue_replay();
let mut roots: Vec<_> = self
.retry_roots
.iter()
.filter(|root| self.roots.contains(*root))
.cloned()
.collect();
roots.sort_by(|left, right| left.path.cmp(&right.path));
self.work.extend(roots.into_iter().map(Work::Root));
let mut candidates: Vec<_> = self
.retry_candidates
.iter()
.filter(|(root, _)| self.roots.contains(root))
.cloned()
.collect();
candidates.sort_by(|(_, left), (_, right)| left.cmp(right));
self.work
.extend(candidates.into_iter().map(|(root, path)| Work::Candidate {
root,
path,
transient_retries: TRANSIENT_RETRIES,
}));
}
fn step_replay(&mut self, identity: Identity, result: &mut StepResult) {
if self.resource_latched {
self.replay_queue.clear();
return;
}
let Some(mut replay) = self.replay_desired.remove(&identity) else {
return;
};
loop {
if self.skipped_additions.contains(&replay.watch_path) {
if let Some((path, _)) = replay
.logicals
.iter()
.find(|(path, _)| !self.skipped_additions.contains(path))
{
replay.watch_path.clone_from(path);
} else {
return;
}
}
let attempted = replay.watch_path.clone();
match self.backend.watch(&attempted, replay.mode) {
Ok(()) => {
if !self.verify_poll_registration(&attempted, &identity, result) {
return;
}
self.addition_failures.remove(&attempted);
let logicals = replay
.logicals
.iter()
.map(|(path, _)| path.clone())
.collect();
self.physical.insert(
identity.clone(),
PhysicalWatch {
watch_path: attempted,
logicals,
guards: HashSet::new(),
mode: replay.mode,
registered: true,
},
);
for (path, watch) in replay.logicals {
self.logical.insert(path, watch);
}
return;
}
Err(error) => {
if path_not_found(&error) {
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(attempted.clone()),
error,
false,
));
self.addition_failures.remove(&attempted);
self.skipped_additions.remove(&attempted);
if let Some(path) = self.retain_valid_replay_aliases(
&mut replay,
&identity,
&attempted,
result,
) {
replay.watch_path = path;
continue;
}
} else if let Some(resource) = classify_resource_error(&error) {
self.resource_latched = true;
self.replay_queue.clear();
if !self.resource_reported {
self.resource_reported = true;
let error = resource.into_fs_error(error);
result.errors.push(RuntimeError::FsWatcher {
kind: self.kind,
err: error,
});
}
self.replay_desired.insert(identity, replay);
} else {
let failures = self.addition_failures.entry(attempted.clone()).or_default();
*failures = failures.saturating_add(1);
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(attempted.clone()),
error,
false,
));
if *failures >= 2 {
self.skipped_additions.insert(attempted);
} else {
self.replay_desired.insert(identity, replay);
result.rebuild_backend = true;
}
}
return;
}
}
}
}
fn retain_valid_replay_aliases(
&self,
replay: &mut ReplayWatch,
identity: &Identity,
failed: &Path,
result: &mut StepResult,
) -> Option<PathBuf> {
let mut representative = None;
replay.logicals.retain(|(path, _)| {
if path == failed
|| self.skipped_additions.contains(path)
|| self
.removed_prefixes
.iter()
.any(|prefix| path.starts_with(prefix))
{
return false;
}
let valid = match self.scanner.classify(path, self.follow_symlinks) {
Ok(EntryKind::Directory(canonical)) => identity == &Identity::Canonical(canonical),
Ok(EntryKind::Other) => identity == &Identity::Lexical(path.clone()),
Ok(EntryKind::NonFollowedSymlink) => false,
Err(error) => {
result.errors.push(self.scan_error(path.clone(), error));
false
}
};
if valid && representative.is_none() {
representative = Some(path.clone());
}
valid
});
representative
}
fn step_work(&mut self, work: Work, result: &mut StepResult) {
match work {
Work::Coverage => self.step_coverage(result),
Work::Guard(root) => self.step_guard(root, result),
Work::Root(root) => self.step_root(root, result),
Work::Candidate {
root,
path,
transient_retries,
} => self.step_candidate(root, path, transient_retries, result),
Work::Scan {
root,
path,
transient_retries,
} => self.step_scan(root, path, transient_retries, result),
Work::Probe(path) => self.step_probe(path, result),
}
}
fn step_coverage(&mut self, result: &mut StepResult) {
if self.strategy != RecursionStrategy::RecursiveRoots
|| !self.coverage_pending
|| !self.coverage_work_pending
{
return;
}
self.coverage_work_pending = false;
if self.coverage_desired.is_empty() {
self.coverage_desired = self.build_coverage(result);
}
let requests: Vec<_> = self
.coverage_desired
.iter()
.filter(|desired| !self.skipped_additions.contains(&desired.path))
.filter(|desired| {
!self.coverage_installed.iter().any(|installed| {
installed.path == desired.path
&& installed.canonical == desired.canonical
&& mode_satisfies(installed.mode, desired.mode)
})
})
.cloned()
.collect();
if requests.is_empty() {
self.coverage_pending = false;
return;
}
let watches: Vec<_> = requests
.iter()
.map(|watch| (watch.path.clone(), watch.mode))
.collect();
let BatchWatchResult {
errors,
commit_error,
} = self.backend.watch_batch(&watches);
let mut failed = HashSet::new();
let mut refresh_desired = false;
for (path, error) in errors {
failed.insert(path.clone());
if path_not_found(&error) {
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::recursive(path),
error,
false,
));
refresh_desired = true;
result.rebuild_backend = true;
} else if let Some(resource) = classify_resource_error(&error) {
self.resource_latched = true;
if !self.resource_reported {
self.resource_reported = true;
result.errors.push(RuntimeError::FsWatcher {
kind: self.kind,
err: resource.into_fs_error(error),
});
}
} else {
let failures = self.addition_failures.entry(path.clone()).or_default();
*failures = failures.saturating_add(1);
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::recursive(path.clone()),
error,
false,
));
if *failures >= 2 {
self.skipped_additions.insert(path);
} else {
result.rebuild_backend = true;
}
}
}
if let Some(error) = commit_error {
let path = requests
.first()
.map_or_else(|| self.cwd.clone(), |watch| watch.path.clone());
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::recursive(path),
error,
false,
));
self.coverage_desired.clear();
self.coverage_pending = true;
result.rebuild_backend = true;
return;
}
let mut installed = Vec::new();
for request in requests {
if failed.contains(&request.path) {
continue;
}
match self.scanner.canonicalize(&request.path) {
Ok(canonical) if canonical == request.canonical => {
self.addition_failures.remove(&request.path);
installed.push(request);
}
Ok(_) => {
result.errors.push(self.scan_error(
request.path,
io::Error::new(
io::ErrorKind::InvalidData,
"path changed while installing recursive watcher coverage",
),
));
refresh_desired = true;
result.rebuild_backend = true;
}
Err(error) => {
result.errors.push(self.scan_error(request.path, error));
refresh_desired = true;
result.rebuild_backend = true;
}
}
}
if refresh_desired {
self.coverage_desired.clear();
self.coverage_pending = true;
return;
}
self.coverage_installed.extend(installed);
self.coverage_pending = failed
.iter()
.any(|path| !self.skipped_additions.contains(path));
}
fn build_coverage(&mut self, result: &mut StepResult) -> Vec<CoverageWatch> {
let mut coverage = HashMap::<PathBuf, CoverageWatch>::new();
let mut roots: Vec<_> = self.roots.iter().cloned().collect();
roots.sort_by(|left, right| left.path.cmp(&right.path));
for root in roots {
let mode = if root.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
match self.classify_explicit_root(&root.path) {
Ok(ExplicitRootState::Entry(EntryKind::Directory(canonical))) => {
Self::merge_coverage(
&mut coverage,
CoverageWatch {
path: root.path.clone(),
canonical,
mode,
},
);
if let Some(parent) = self.nearest_coverage_ancestor(&root.path, result) {
Self::merge_coverage(&mut coverage, parent);
}
}
Ok(ExplicitRootState::Entry(EntryKind::Other)) => {
let exact_coverage = match self.scanner.canonicalize(&root.path) {
Ok(canonical) => {
Self::merge_coverage(
&mut coverage,
CoverageWatch {
path: root.path.clone(),
canonical,
mode,
},
);
true
}
Err(error) => {
result
.errors
.push(self.scan_error(root.path.clone(), error));
self.retry_roots.insert(root.clone());
false
}
};
if let Some(mut parent) = self.nearest_coverage_ancestor(&root.path, result) {
if !exact_coverage {
parent.mode = RecursiveMode::Recursive;
}
Self::merge_coverage(&mut coverage, parent);
}
}
Ok(
ExplicitRootState::Entry(EntryKind::NonFollowedSymlink)
| ExplicitRootState::Unsafe,
) => {
if let Some(mut parent) = self.nearest_coverage_ancestor(&root.path, result) {
parent.mode = RecursiveMode::Recursive;
Self::merge_coverage(&mut coverage, parent);
}
}
Ok(ExplicitRootState::Missing { path, error }) => {
result.errors.push(self.scan_error(path, error));
if let Some(mut ancestor) = self.nearest_coverage_ancestor(&root.path, result) {
ancestor.mode = RecursiveMode::Recursive;
Self::merge_coverage(&mut coverage, ancestor);
}
}
Err((path, error)) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root.clone());
if let Some(mut ancestor) = self.nearest_coverage_ancestor(&root.path, result) {
ancestor.mode = RecursiveMode::Recursive;
Self::merge_coverage(&mut coverage, ancestor);
}
}
}
}
let mut coverage: Vec<_> = coverage.into_values().collect();
coverage.sort_by(|left, right| {
left.canonical
.components()
.count()
.cmp(&right.canonical.components().count())
.then_with(|| left.path.cmp(&right.path))
});
let mut minimized: Vec<CoverageWatch> = Vec::new();
for request in coverage {
if minimized.iter().any(|existing| {
existing.mode == RecursiveMode::Recursive
&& request.canonical.starts_with(&existing.canonical)
}) {
continue;
}
minimized.push(request);
}
minimized.sort_by(|left, right| left.path.cmp(&right.path));
minimized
}
fn merge_coverage(coverage: &mut HashMap<PathBuf, CoverageWatch>, request: CoverageWatch) {
coverage
.entry(request.path.clone())
.and_modify(|existing| {
existing.canonical.clone_from(&request.canonical);
if request.mode == RecursiveMode::Recursive {
existing.mode = RecursiveMode::Recursive;
}
})
.or_insert(request);
}
fn nearest_coverage_ancestor(
&self,
path: &Path,
result: &mut StepResult,
) -> Option<CoverageWatch> {
if self.follow_symlinks {
for ancestor in path.parent()?.ancestors() {
if ancestor.as_os_str().is_empty() {
continue;
}
match self.scanner.classify(ancestor, true) {
Ok(EntryKind::Directory(canonical)) => {
return Some(CoverageWatch {
path: ancestor.to_owned(),
canonical,
mode: RecursiveMode::NonRecursive,
});
}
Ok(EntryKind::NonFollowedSymlink | EntryKind::Other) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => result
.errors
.push(self.scan_error(ancestor.to_owned(), error)),
}
}
return None;
}
let mut found = None;
let mut prefixes: Vec<_> = path
.parent()?
.ancestors()
.filter(|prefix| !prefix.as_os_str().is_empty())
.map(Path::to_owned)
.collect();
prefixes.reverse();
for ancestor in prefixes {
match self.scanner.classify(&ancestor, false) {
Ok(EntryKind::Directory(canonical)) => {
found = Some(CoverageWatch {
path: ancestor,
canonical,
mode: RecursiveMode::NonRecursive,
});
}
Ok(EntryKind::NonFollowedSymlink | EntryKind::Other) => break,
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(error) => {
result.errors.push(self.scan_error(ancestor.clone(), error));
break;
}
}
}
found
}
fn coverage_covers_directory(&self, canonical: &Path) -> bool {
self.strategy != RecursionStrategy::RecursiveRoots
|| self.coverage_installed.iter().any(|coverage| {
coverage.mode == RecursiveMode::Recursive
&& canonical.starts_with(&coverage.canonical)
})
}
fn coverage_plan_covers_root(&self, canonical: &Path, requested: RecursiveMode) -> bool {
self.strategy != RecursionStrategy::RecursiveRoots
|| self.coverage_desired.iter().any(|coverage| {
(coverage.mode == RecursiveMode::Recursive
&& canonical.starts_with(&coverage.canonical))
|| (coverage.canonical == canonical && mode_satisfies(coverage.mode, requested))
})
}
fn classify_explicit_root(
&self,
path: &Path,
) -> Result<ExplicitRootState, (PathBuf, io::Error)> {
if self.follow_symlinks {
return match self.scanner.classify(path, true) {
Ok(entry) => Ok(ExplicitRootState::Entry(entry)),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
Ok(ExplicitRootState::Missing {
path: path.to_owned(),
error,
})
}
Err(error) => Err((path.to_owned(), error)),
};
}
let mut prefixes: Vec<_> = path
.ancestors()
.filter(|prefix| !prefix.as_os_str().is_empty())
.map(Path::to_owned)
.collect();
prefixes.reverse();
for prefix in prefixes {
let is_root = prefix == path;
match self.scanner.classify(&prefix, false) {
Ok(EntryKind::Directory(identity)) if is_root => {
return Ok(ExplicitRootState::Entry(EntryKind::Directory(identity)));
}
Ok(EntryKind::Directory(_)) => {}
Ok(EntryKind::Other) if is_root => {
return Ok(ExplicitRootState::Entry(EntryKind::Other));
}
Ok(EntryKind::Other | EntryKind::NonFollowedSymlink) => {
return Ok(ExplicitRootState::Unsafe);
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(ExplicitRootState::Missing {
path: prefix,
error,
});
}
Err(error) => return Err((prefix, error)),
}
}
Ok(ExplicitRootState::Unsafe)
}
fn recheck_guarded_root(&mut self, root: Root, result: &mut StepResult) {
match self.classify_explicit_root(&root.path) {
Ok(ExplicitRootState::Entry(_)) => {
self.removed_prefixes
.retain(|prefix| !root.path.starts_with(prefix));
self.skipped_additions.remove(&root.path);
self.addition_failures.remove(&root.path);
self.work.push_front(Work::Root(root));
}
Ok(ExplicitRootState::Missing { .. } | ExplicitRootState::Unsafe) => {}
Err((path, error)) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root);
}
}
}
fn step_guard(&mut self, root: Root, result: &mut StepResult) {
if !self.is_managed() {
return;
}
if !self.roots.contains(&root) {
self.remove_root_guard(&root, result);
return;
}
let tombstoned = self
.removed_prefixes
.iter()
.any(|prefix| root.path.starts_with(prefix));
if !tombstoned {
match self.classify_explicit_root(&root.path) {
Ok(ExplicitRootState::Entry(entry)) => {
let retain_guard = match entry {
EntryKind::Directory(_) => self.retains_directory_root_guard(),
EntryKind::Other => self.retains_nondirectory_root_guard(),
EntryKind::NonFollowedSymlink => false,
};
if !retain_guard {
self.remove_root_guard(&root, result);
}
self.work.push_front(Work::Root(root));
return;
}
Ok(ExplicitRootState::Missing { .. } | ExplicitRootState::Unsafe) => {}
Err((path, error)) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root);
return;
}
}
}
self.ensure_guard(root, result);
}
fn ensure_guard(&mut self, root: Root, result: &mut StepResult) {
let mut found = None;
if self.follow_symlinks {
let mut ancestor = root.path.parent().map(Path::to_owned);
while let Some(path) = ancestor {
match self.scanner.classify(&path, true) {
Ok(EntryKind::Directory(identity)) => {
let replaced = self.removed_prefixes.remove(&path);
found = Some((path, Identity::Canonical(identity), replaced));
break;
}
Ok(EntryKind::NonFollowedSymlink | EntryKind::Other) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root);
return;
}
}
ancestor = path.parent().map(Path::to_owned);
}
} else if let Some(parent) = root.path.parent() {
let mut prefixes: Vec<_> = parent
.ancestors()
.filter(|prefix| !prefix.as_os_str().is_empty())
.map(Path::to_owned)
.collect();
prefixes.reverse();
for path in prefixes {
match self.scanner.classify(&path, false) {
Ok(EntryKind::Directory(identity)) => {
let replaced = self.removed_prefixes.remove(&path);
found = Some((path, Identity::Canonical(identity), replaced));
}
Ok(EntryKind::NonFollowedSymlink | EntryKind::Other) => break,
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(error) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root);
return;
}
}
}
}
let Some((path, identity, replaced)) = found else {
self.remove_root_guard(&root, result);
return;
};
let same_guard = self.root_guards.get(&root) == Some(&path)
&& self
.guards
.get(&path)
.map_or(false, |guard| guard.identity == identity);
let attached = self
.physical
.get(&identity)
.map_or(false, |physical| physical.guards.contains(&path));
if same_guard && attached && !replaced {
self.retry_roots.remove(&root);
self.recheck_guarded_root(root, result);
return;
}
if replaced
|| self
.guards
.get(&path)
.map_or(false, |guard| guard.identity != identity)
{
let old_identity = self.guards.get(&path).map(|guard| guard.identity.clone());
if let Some(old_identity) = old_identity {
if !self.detach_guard_registration(&path, &old_identity, result) {
self.retry_roots.insert(root);
return;
}
}
}
if self.root_guards.get(&root) != Some(&path) {
self.remove_root_guard(&root, result);
if result.rebuild_backend {
self.retry_roots.insert(root);
return;
}
}
if self.strategy == RecursionStrategy::RecursiveRoots {
self.physical
.entry(identity.clone())
.and_modify(|physical| {
physical.guards.insert(path.clone());
})
.or_insert_with(|| PhysicalWatch {
watch_path: path.clone(),
logicals: HashSet::new(),
guards: HashSet::from([path.clone()]),
mode: RecursiveMode::Recursive,
registered: false,
});
self.guards
.entry(path.clone())
.and_modify(|guard| {
guard.identity = identity.clone();
guard.owners.insert(root.clone());
})
.or_insert_with(|| GuardWatch {
identity,
owners: HashSet::from([root.clone()]),
});
self.root_guards.insert(root.clone(), path);
self.retry_roots.remove(&root);
self.recheck_guarded_root(root, result);
return;
}
let shared = if let Some(physical) = self.physical.get_mut(&identity) {
physical.guards.insert(path.clone());
true
} else {
false
};
if !shared {
if self.resource_latched || self.skipped_additions.contains(&path) {
self.retry_roots.insert(root);
return;
}
match self.backend.watch(&path, RecursiveMode::NonRecursive) {
Ok(()) => {
if !self.verify_poll_registration(&path, &identity, result) {
self.work.push_front(Work::Guard(root));
return;
}
self.addition_failures.remove(&path);
self.physical.insert(
identity.clone(),
PhysicalWatch {
watch_path: path.clone(),
logicals: HashSet::new(),
guards: HashSet::from([path.clone()]),
mode: RecursiveMode::NonRecursive,
registered: true,
},
);
}
Err(error) => {
self.retry_roots.insert(root.clone());
if path_not_found(&error) {
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(path.clone()),
error,
false,
));
self.removed_prefixes.insert(path);
self.work.push_front(Work::Guard(root));
} else if let Some(resource) = classify_resource_error(&error) {
self.resource_latched = true;
if !self.resource_reported {
self.resource_reported = true;
let error = resource.into_fs_error(error);
result.errors.push(RuntimeError::FsWatcher {
kind: self.kind,
err: error,
});
}
} else {
let failures = self.addition_failures.entry(path.clone()).or_default();
*failures = failures.saturating_add(1);
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(path.clone()),
error,
false,
));
if *failures >= 2 {
self.skipped_additions.insert(path);
} else {
result.rebuild_backend = true;
}
}
return;
}
}
}
self.guards
.entry(path.clone())
.and_modify(|guard| {
guard.identity = identity.clone();
guard.owners.insert(root.clone());
})
.or_insert_with(|| GuardWatch {
identity,
owners: HashSet::from([root.clone()]),
});
self.root_guards.insert(root.clone(), path);
self.retry_roots.remove(&root);
self.recheck_guarded_root(root, result);
}
fn detach_guard_registration(
&mut self,
path: &Path,
identity: &Identity,
result: &mut StepResult,
) -> bool {
let Some(physical) = self.physical.get_mut(identity) else {
return true;
};
physical.guards.remove(path);
let has_owners = !physical.logicals.is_empty() || !physical.guards.is_empty();
if !physical.registered {
if physical.watch_path == path {
if let Some(representative) = physical
.logicals
.iter()
.chain(&physical.guards)
.min()
.cloned()
{
physical.watch_path = representative;
}
}
if !has_owners {
self.physical.remove(identity);
}
return true;
}
if has_owners {
if physical.watch_path == path && !physical.logicals.contains(path) {
result.rebuild_backend = true;
return false;
}
return true;
}
let Some(physical) = self.physical.remove(identity) else {
return true;
};
if self.try_unwatch(&physical.watch_path, result) {
self.schedule_retries();
true
} else {
false
}
}
fn remove_root_guard(&mut self, root: &Root, result: &mut StepResult) {
let Some(path) = self.root_guards.remove(root) else {
return;
};
let remove = if let Some(guard) = self.guards.get_mut(&path) {
guard.owners.remove(root);
guard.owners.is_empty()
} else {
false
};
if !remove {
return;
}
let Some(guard) = self.guards.remove(&path) else {
return;
};
self.detach_guard_registration(&path, &guard.identity, result);
}
fn step_root(&mut self, root: Root, result: &mut StepResult) {
if !self.roots.contains(&root) || self.skipped_additions.contains(&root.path) {
return;
}
if !self.is_managed() {
if !self.follow_symlinks {
match self.classify_explicit_root(&root.path) {
Ok(ExplicitRootState::Entry(_) | ExplicitRootState::Missing { .. }) => {}
Ok(ExplicitRootState::Unsafe) => {
self.retry_roots.remove(&root);
self.queue_remove_prefix(&root.path);
return;
}
Err((path, error)) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root);
return;
}
}
}
let mode = if root.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
match self.add_logical(
root.path.clone(),
Identity::Lexical(root.path.clone()),
root.clone(),
mode,
result,
) {
AddResult::Added => {
self.retry_roots.remove(&root);
}
AddResult::Skipped | AddResult::Invalidated | AddResult::Rebuild => {
self.retry_roots.insert(root);
}
}
return;
}
let tombstoned = self
.removed_prefixes
.iter()
.any(|prefix| root.path.starts_with(prefix));
if tombstoned {
self.queue_remove_prefix(&root.path);
self.work.push_front(Work::Guard(root));
return;
}
let (identity, scan, retain_guard) = match self.classify_explicit_root(&root.path) {
Ok(ExplicitRootState::Entry(EntryKind::Directory(identity))) => (
Identity::Canonical(identity),
root.recursive,
self.retains_directory_root_guard(),
),
Ok(ExplicitRootState::Entry(EntryKind::Other)) => {
let identity = if self.strategy == RecursionStrategy::RecursiveRoots {
match self.scanner.canonicalize(&root.path) {
Ok(canonical) => Identity::Canonical(canonical),
Err(error) => {
result
.errors
.push(self.scan_error(root.path.clone(), error));
self.retry_roots.insert(root.clone());
Identity::Lexical(root.path.clone())
}
}
} else {
Identity::Lexical(root.path.clone())
};
(identity, false, self.retains_nondirectory_root_guard())
}
Ok(
ExplicitRootState::Entry(EntryKind::NonFollowedSymlink) | ExplicitRootState::Unsafe,
) => {
self.retry_roots.remove(&root);
self.queue_remove_prefix(&root.path);
self.work.push_front(Work::Guard(root));
return;
}
Ok(ExplicitRootState::Missing { path, error }) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.remove(&root);
self.removed_prefixes.insert(root.path.clone());
self.queue_remove_prefix(&root.path);
self.work.push_front(Work::Guard(root));
return;
}
Err((path, error)) => {
result.errors.push(self.scan_error(path, error));
self.retry_roots.insert(root.clone());
if self.has_source_owner(&root.path, &root) {
self.carry_forward(&root, &root.path);
}
self.ensure_guard(root, result);
return;
}
};
if let Identity::Canonical(canonical) = &identity {
let requested = if root.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
if !self.coverage_plan_covers_root(canonical, requested) {
self.coverage_desired.clear();
self.coverage_pending = true;
result.rebuild_backend = true;
return;
}
}
match self.add_logical(
root.path.clone(),
identity.clone(),
root.clone(),
RecursiveMode::NonRecursive,
result,
) {
AddResult::Added => {
self.retry_roots.remove(&root);
if retain_guard {
if !self.root_guards.contains_key(&root) {
self.ensure_guard(root.clone(), result);
}
} else {
self.remove_root_guard(&root, result);
}
if scan && self.mark_seen(&root, identity) {
let path = root.path.clone();
self.work.push_back(Work::Scan {
root,
path,
transient_retries: TRANSIENT_RETRIES,
});
}
}
AddResult::Skipped | AddResult::Rebuild => {
self.retry_roots.insert(root);
}
AddResult::Invalidated => {
self.retry_roots.remove(&root);
self.removed_prefixes.insert(root.path.clone());
self.queue_remove_prefix(&root.path);
self.work.push_front(Work::Guard(root));
}
}
}
fn retry_transient_candidate(&mut self, root: &Root, path: &Path, transient_retries: u8) {
self.retry_candidates
.remove(&(root.clone(), path.to_owned()));
self.carry_forward(root, path);
if transient_retries > 0 {
self.work.push_back(Work::Candidate {
root: root.clone(),
path: path.to_owned(),
transient_retries: transient_retries - 1,
});
}
}
fn step_candidate(
&mut self,
root: Root,
path: PathBuf,
transient_retries: u8,
result: &mut StepResult,
) {
if !self.roots.contains(&root) || !root.recursive {
self.retry_candidates.remove(&(root, path));
return;
}
if self.skipped_additions.contains(&path) {
return;
}
if self
.removed_prefixes
.iter()
.any(|prefix| path.starts_with(prefix))
{
self.retry_candidates.remove(&(root, path));
return;
}
let classification = match self.scanner.classify(&path, self.follow_symlinks) {
Ok(classification) => classification,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
result.errors.push(self.scan_error(path.clone(), error));
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
for explicit in self.roots.iter().filter(|item| item.path == path).cloned() {
self.work.push_back(Work::Guard(explicit));
}
return;
}
Err(error) => {
result.errors.push(self.scan_error(path.clone(), error));
self.retry_transient_candidate(&root, &path, transient_retries);
return;
}
};
let EntryKind::Directory(identity) = classification else {
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
return;
};
if !self.coverage_covers_directory(&identity) {
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
return;
}
match self.candidate_allowed(&root, &path) {
Ok(true) => {}
Ok(false) => {
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
return;
}
Err(error) => {
result.errors.push(error);
self.retry_transient_candidate(&root, &path, transient_retries);
return;
}
}
let identity = Identity::Canonical(identity);
match self.add_logical(
path.clone(),
identity.clone(),
root.clone(),
RecursiveMode::NonRecursive,
result,
) {
AddResult::Added => {
self.retry_candidates.remove(&(root.clone(), path.clone()));
if self.mark_seen(&root, identity) {
self.work.push_back(Work::Scan {
root,
path,
transient_retries: TRANSIENT_RETRIES,
});
}
}
AddResult::Skipped | AddResult::Rebuild => {
self.retry_candidates.insert((root, path));
}
AddResult::Invalidated => {
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
}
}
}
fn retry_transient_scan(&mut self, root: &Root, path: &Path, transient_retries: u8) {
self.carry_forward(root, path);
if transient_retries > 0 {
self.work.push_back(Work::Scan {
root: root.clone(),
path: path.to_owned(),
transient_retries: transient_retries - 1,
});
}
}
fn step_scan(
&mut self,
root: Root,
path: PathBuf,
transient_retries: u8,
result: &mut StepResult,
) {
if !self.roots.contains(&root)
|| !self
.logical
.get(&path)
.map_or(false, |watch| watch.owners.contains_key(&root))
{
return;
}
let mut entry_failed = false;
let kind = self.kind;
let scan_result = {
let scanner = &self.scanner;
let cwd = &self.cwd;
let work = &mut self.work;
let errors = &mut result.errors;
scanner.scan(&path, &mut |entry| match entry {
Ok(entry_path) => {
let entry_path = if entry_path.is_absolute() {
entry_path.normalize()
} else {
cwd.join(entry_path).normalize()
};
work.push_back(Work::Candidate {
root: root.clone(),
path: entry_path,
transient_retries: TRANSIENT_RETRIES,
});
}
Err(error) => {
entry_failed = true;
errors.push(RuntimeError::FsWatcher {
kind,
err: FsWatcherError::PathScan {
path: path.clone(),
err: error,
},
});
}
})
};
match scan_result {
Ok(()) => {
if entry_failed {
self.retry_transient_scan(&root, &path, transient_retries);
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
result.errors.push(self.scan_error(path.clone(), error));
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.queue_remove_owner_prefix(&root, &path);
for explicit in self.roots.iter().filter(|item| item.path == path).cloned() {
self.work.push_back(Work::Guard(explicit));
}
}
Err(error) => {
result.errors.push(self.scan_error(path.clone(), error));
self.retry_candidates.remove(&(root.clone(), path.clone()));
self.retry_transient_scan(&root, &path, transient_retries);
}
}
}
fn step_probe(&mut self, path: PathBuf, result: &mut StepResult) {
match self.scanner.classify(&path, self.follow_symlinks) {
Ok(EntryKind::Directory(_)) => {
let roots = self.candidate_roots(&path, true);
self.queue_remove_prefix(&path);
for root in roots {
self.seen.remove(&root);
self.work.push_back(Work::Candidate {
root,
path: path.clone(),
transient_retries: TRANSIENT_RETRIES,
});
}
self.requeue_configured_roots_below(&path);
}
Ok(EntryKind::NonFollowedSymlink | EntryKind::Other) => {
self.queue_remove_prefix(&path);
self.requeue_configured_roots_below(&path);
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
self.queue_remove_prefix(&path);
self.requeue_configured_roots_below(&path);
}
Err(error) => result.errors.push(self.scan_error(path, error)),
}
}
fn candidate_allowed(&self, root: &Root, path: &Path) -> Result<bool, RuntimeError> {
let Ok(relative) = path.strip_prefix(&root.path) else {
return Ok(false);
};
let mut candidate = root.path.clone();
for component in relative.components() {
candidate.push(component);
if !self.filter.check_dir(&candidate)? {
return Ok(false);
}
}
Ok(true)
}
fn candidate_roots(&self, path: &Path, include_self: bool) -> HashSet<Root> {
let mut roots = HashSet::new();
let self_path = if include_self { Some(path) } else { None };
for owner_path in path.parent().into_iter().chain(self_path) {
if let Some(watch) = self.logical.get(owner_path) {
roots.extend(
watch
.owners
.iter()
.filter(|(root, generation)| {
**generation == self.epoch
&& root.recursive && self.roots.contains(*root)
})
.map(|(root, _)| root.clone()),
);
}
}
roots
}
fn add_logical(
&mut self,
path: PathBuf,
identity: Identity,
root: Root,
mode: RecursiveMode,
result: &mut StepResult,
) -> AddResult {
if self.strategy == RecursionStrategy::RecursiveRoots {
return self.add_virtual_logical(path, identity, root);
}
if let Some(watch) = self.logical.get_mut(&path) {
let mode_matches = self
.physical
.get(&watch.identity)
.map_or(false, |physical| mode_satisfies(physical.mode, mode));
if watch.identity != identity || !mode_matches {
self.rebuild_exclusions.insert(path.clone());
result.rebuild_backend = true;
return AddResult::Rebuild;
}
watch.owners.insert(root, self.epoch);
self.addition_failures.remove(&path);
return AddResult::Added;
}
if let Some(physical) = self.physical.get_mut(&identity) {
if !mode_satisfies(physical.mode, mode) {
result.rebuild_backend = true;
return AddResult::Rebuild;
}
physical.logicals.insert(path.clone());
self.addition_failures.remove(&path);
self.logical.insert(
path,
LogicalWatch {
identity,
owners: HashMap::from([(root, self.epoch)]),
},
);
return AddResult::Added;
}
if self.resource_latched || self.skipped_additions.contains(&path) {
return AddResult::Skipped;
}
match self.backend.watch(&path, mode) {
Ok(()) => {
if !self.verify_poll_registration(&path, &identity, result) {
return if result.rebuild_backend {
AddResult::Rebuild
} else {
AddResult::Invalidated
};
}
self.addition_failures.remove(&path);
self.physical.insert(
identity.clone(),
PhysicalWatch {
watch_path: path.clone(),
logicals: HashSet::from([path.clone()]),
guards: HashSet::new(),
mode,
registered: true,
},
);
self.logical.insert(
path,
LogicalWatch {
identity,
owners: HashMap::from([(root, self.epoch)]),
},
);
AddResult::Added
}
Err(error) => {
if path_not_found(&error) {
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(path),
error,
false,
));
AddResult::Invalidated
} else if let Some(resource) = classify_resource_error(&error) {
self.resource_latched = true;
if !self.resource_reported {
self.resource_reported = true;
let error = resource.into_fs_error(error);
result.errors.push(RuntimeError::FsWatcher {
kind: self.kind,
err: error,
});
}
AddResult::Skipped
} else {
let failures = self.addition_failures.entry(path.clone()).or_default();
*failures = failures.saturating_add(1);
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(path.clone()),
error,
false,
));
if *failures >= 2 {
self.skipped_additions.insert(path);
AddResult::Skipped
} else {
result.rebuild_backend = true;
AddResult::Rebuild
}
}
}
}
}
fn add_virtual_logical(&mut self, path: PathBuf, identity: Identity, root: Root) -> AddResult {
let mut owners = self
.logical
.remove(&path)
.map_or_else(HashMap::new, |watch| {
if watch.identity != identity {
self.detach_virtual_logical(&path, &watch.identity);
}
watch.owners
});
owners.insert(root, self.epoch);
self.physical
.entry(identity.clone())
.and_modify(|physical| {
physical.logicals.insert(path.clone());
})
.or_insert_with(|| PhysicalWatch {
watch_path: path.clone(),
logicals: HashSet::from([path.clone()]),
guards: HashSet::new(),
mode: RecursiveMode::Recursive,
registered: false,
});
self.logical.insert(path, LogicalWatch { identity, owners });
AddResult::Added
}
fn detach_virtual_logical(&mut self, path: &Path, identity: &Identity) {
let remove = if let Some(physical) = self.physical.get_mut(identity) {
physical.logicals.remove(path);
if physical.watch_path == path {
if let Some(representative) = physical
.logicals
.iter()
.chain(&physical.guards)
.min()
.cloned()
{
physical.watch_path = representative;
}
}
physical.logicals.is_empty() && physical.guards.is_empty()
} else {
false
};
if remove {
self.physical.remove(identity);
}
}
fn verify_poll_registration(
&mut self,
path: &Path,
expected: &Identity,
result: &mut StepResult,
) -> bool {
if self.backend_kind != notify::WatcherKind::PollWatcher {
return true;
}
let verification = match self.scanner.classify(path, self.follow_symlinks) {
Ok(verification) => verification,
Err(error) => {
result.errors.push(self.scan_error(path.to_owned(), error));
return self.undo_invalid_poll_registration(path, result);
}
};
let valid = match verification {
EntryKind::Directory(identity) => expected == &Identity::Canonical(identity),
EntryKind::Other => expected == &Identity::Lexical(path.to_owned()),
EntryKind::NonFollowedSymlink => false,
};
if valid {
return true;
}
result.errors.push(self.scan_error(
path.to_owned(),
io::Error::new(
io::ErrorKind::InvalidData,
"path changed while installing polling watch",
),
));
self.undo_invalid_poll_registration(path, result)
}
fn undo_invalid_poll_registration(&mut self, path: &Path, result: &mut StepResult) -> bool {
self.try_unwatch(path, result);
false
}
fn try_unwatch(&mut self, path: &Path, result: &mut StepResult) -> bool {
match self.backend.unwatch(path) {
Ok(())
| Err(notify::Error {
kind: notify::ErrorKind::WatchNotFound,
..
}) => true,
Err(error) => {
result.errors.extend(notify_multi_path_errors(
self.kind,
WatchedPath::non_recursive(path),
error,
true,
));
result.rebuild_backend = true;
false
}
}
}
fn mark_seen(&mut self, root: &Root, identity: Identity) -> bool {
self.seen.entry(root.clone()).or_default().insert(identity)
}
fn has_source_owner(&self, path: &Path, root: &Root) -> bool {
self.logical
.get(path)
.map_or(false, |watch| watch.owners.contains_key(root))
}
fn carry_forward(&mut self, root: &Root, prefix: &Path) {
for (path, watch) in &mut self.logical {
if path.starts_with(prefix) && watch.owners.contains_key(root) {
watch.owners.insert(root.clone(), self.epoch);
}
}
}
fn prepare_sweep(&mut self) {
let mut stale = Vec::new();
for (path, watch) in &self.logical {
for (root, generation) in &watch.owners {
if *generation != self.epoch || !self.roots.contains(root) {
stale.push(SweepEntry {
path: path.clone(),
root: root.clone(),
epoch: self.epoch,
});
}
}
}
stale.sort_by(|left, right| {
right
.path
.components()
.count()
.cmp(&left.path.components().count())
.then_with(|| right.path.cmp(&left.path))
});
self.sweep = stale.into();
self.phase = Phase::Sweeping;
}
fn queue_remove_owner_prefix(&mut self, root: &Root, prefix: &Path) {
self.retry_candidates
.retain(|(owner, path)| owner != root || !path.starts_with(prefix));
self.work.retain(|work| {
!matches!(
work,
Work::Candidate {
root: owner, path, ..
} | Work::Scan {
root: owner, path, ..
}
if owner == root && path.starts_with(prefix)
)
});
let mut paths: Vec<_> = self
.logical
.iter()
.filter(|(path, watch)| path.starts_with(prefix) && watch.owners.contains_key(root))
.map(|(path, _)| path.clone())
.collect();
paths.sort_by_key(|path| Reverse(path.components().count()));
self.pending_owner_removals
.extend(paths.into_iter().map(|path| (path, root.clone())));
}
fn queue_remove_prefix(&mut self, prefix: &Path) {
let configured = &self.roots;
self.work.retain(|work| match work {
Work::Root(root) | Work::Guard(root) if configured.contains(root) => true,
_ => !work.path().starts_with(prefix),
});
self.retry_candidates
.retain(|(_, path)| !path.starts_with(prefix));
for replay in self.replay_desired.values_mut() {
replay
.logicals
.retain(|(path, _)| !path.starts_with(prefix));
if !replay
.logicals
.iter()
.any(|(path, _)| path == &replay.watch_path)
{
if let Some((path, _)) = replay.logicals.first() {
replay.watch_path.clone_from(path);
}
}
}
self.replay_desired
.retain(|_, replay| !replay.logicals.is_empty());
self.replay_queue
.retain(|identity| self.replay_desired.contains_key(identity));
let mut paths: Vec<_> = self
.logical
.keys()
.filter(|path| path.starts_with(prefix))
.cloned()
.collect();
paths.sort_by(|left, right| {
right
.components()
.count()
.cmp(&left.components().count())
.then_with(|| right.cmp(left))
});
for path in paths {
if self.pending_removal_set.insert(path.clone()) {
self.pending_removals.push_back(path);
}
}
}
fn remove_owner_force(&mut self, entry: SweepEntry, result: &mut StepResult) {
let remove_logical = if let Some(watch) = self.logical.get_mut(&entry.path) {
watch.owners.remove(&entry.root);
watch.owners.is_empty()
} else {
false
};
if remove_logical {
self.remove_logical(&entry.path, result);
}
}
fn remove_owner(&mut self, entry: SweepEntry, result: &mut StepResult) {
if entry.epoch != self.epoch {
return;
}
let remove_logical = if let Some(watch) = self.logical.get_mut(&entry.path) {
if watch.owners.get(&entry.root).copied() != Some(entry.epoch) {
watch.owners.remove(&entry.root);
}
watch.owners.is_empty()
} else {
false
};
if remove_logical {
self.remove_logical(&entry.path, result);
}
}
fn remove_logical(&mut self, path: &Path, result: &mut StepResult) {
let Some(logical) = self.logical.remove(path) else {
return;
};
if self.strategy == RecursionStrategy::RecursiveRoots {
self.detach_virtual_logical(path, &logical.identity);
return;
}
let (remove_physical, removed_representative) =
if let Some(physical) = self.physical.get_mut(&logical.identity) {
physical.logicals.remove(path);
let has_owners = !physical.logicals.is_empty() || !physical.guards.is_empty();
(
!has_owners,
physical.watch_path == path && has_owners && !physical.guards.contains(path),
)
} else {
(false, false)
};
if removed_representative {
result.rebuild_backend = true;
return;
}
if !remove_physical {
return;
}
let Some(physical) = self.physical.remove(&logical.identity) else {
return;
};
if self.try_unwatch(&physical.watch_path, result) {
self.schedule_retries();
}
}
fn settle(&mut self) {
self.phase = Phase::Settled;
}
const fn scan_error(&self, path: PathBuf, error: io::Error) -> RuntimeError {
RuntimeError::FsWatcher {
kind: self.kind,
err: FsWatcherError::PathScan { path, err: error },
}
}
fn absolute(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.normalize()
} else {
self.cwd.join(path).normalize()
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use notify::{ErrorKind, EventKind};
use watchexec_events::{Event, Priority};
use super::*;
#[derive(Clone, Debug, PartialEq, Eq)]
enum Operation {
Watch(PathBuf, RecursiveMode),
WatchBatch(Vec<(PathBuf, RecursiveMode)>),
Unwatch(PathBuf),
Classify(PathBuf),
Scan(PathBuf),
}
#[derive(Default)]
struct FakeBackendState {
operations: Vec<Operation>,
resource_failure: Option<PathBuf>,
path_not_found_failures: HashMap<PathBuf, usize>,
generic_failures: HashMap<PathBuf, usize>,
generic_unwatch_failures: HashMap<PathBuf, usize>,
batch_commit_failures: usize,
watch_not_found: HashSet<PathBuf>,
}
struct FakeBackend(Arc<Mutex<FakeBackendState>>);
fn fake_watch(state: &mut FakeBackendState, path: &Path) -> notify::Result<()> {
if state.resource_failure.as_deref() == Some(path) {
Err(notify::Error::new(ErrorKind::MaxFilesWatch))
} else if let Some(remaining) = state.path_not_found_failures.get_mut(path) {
if *remaining > 0 {
*remaining -= 1;
return Err(notify::Error::new(ErrorKind::PathNotFound));
}
Ok(())
} else if let Some(remaining) = state.generic_failures.get_mut(path) {
if *remaining > 0 {
*remaining -= 1;
return Err(notify::Error::generic("fake watch failure"));
}
Ok(())
} else {
Ok(())
}
}
impl Backend for FakeBackend {
fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
let mut state = self.0.lock().unwrap();
state
.operations
.push(Operation::Watch(path.to_owned(), mode));
fake_watch(&mut state, path)
}
fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
let mut state = self.0.lock().unwrap();
state.operations.push(Operation::Unwatch(path.to_owned()));
if let Some(remaining) = state.generic_unwatch_failures.get_mut(path) {
if *remaining > 0 {
*remaining -= 1;
return Err(notify::Error::generic("fake unwatch failure"));
}
}
if state.watch_not_found.contains(path) {
Err(notify::Error::new(ErrorKind::WatchNotFound))
} else {
Ok(())
}
}
fn watch_batch(&mut self, watches: &[(PathBuf, RecursiveMode)]) -> BatchWatchResult {
let mut state = self.0.lock().unwrap();
state
.operations
.push(Operation::WatchBatch(watches.to_vec()));
let mut errors = Vec::new();
for (path, _) in watches {
if let Err(error) = fake_watch(&mut state, path) {
errors.push((path.clone(), error));
}
}
let commit_error = if state.batch_commit_failures > 0 {
state.batch_commit_failures -= 1;
Some(notify::Error::generic("fake batch commit failure"))
} else {
None
};
BatchWatchResult {
errors,
commit_error,
}
}
}
#[derive(Default)]
struct FakeScannerState {
directories: HashMap<PathBuf, PathBuf>,
entries: HashMap<PathBuf, Vec<PathBuf>>,
not_found: HashSet<PathBuf>,
not_found_after: HashMap<PathBuf, usize>,
non_followed_symlinks: HashSet<PathBuf>,
classify_errors: HashSet<PathBuf>,
classify_errors_after: HashMap<PathBuf, usize>,
scan_errors: HashSet<PathBuf>,
operations: Vec<Operation>,
}
struct FakeScanner(Arc<Mutex<FakeScannerState>>);
impl Scanner for FakeScanner {
fn classify(&self, path: &Path, follow_symlinks: bool) -> io::Result<EntryKind> {
let mut state = self.0.lock().unwrap();
state.operations.push(Operation::Classify(path.to_owned()));
let delayed_missing = state
.not_found_after
.get_mut(path)
.map_or(false, |remaining| {
if *remaining == 0 {
true
} else {
*remaining -= 1;
false
}
});
let delayed_error =
state
.classify_errors_after
.get_mut(path)
.map_or(false, |remaining| {
if *remaining == 0 {
true
} else {
*remaining -= 1;
false
}
});
if state.not_found.contains(path) || delayed_missing {
Err(io::Error::new(io::ErrorKind::NotFound, "fake missing path"))
} else if state.non_followed_symlinks.contains(path) && !follow_symlinks {
Ok(EntryKind::NonFollowedSymlink)
} else if state.classify_errors.contains(path) || delayed_error {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"fake classify failure",
))
} else {
Ok(state
.directories
.get(path)
.cloned()
.map_or(EntryKind::Other, EntryKind::Directory))
}
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
let mut state = self.0.lock().unwrap();
state.operations.push(Operation::Classify(path.to_owned()));
if state.not_found.contains(path) {
Err(io::Error::new(io::ErrorKind::NotFound, "fake missing path"))
} else if state.classify_errors.contains(path) {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"fake canonicalize failure",
))
} else {
Ok(state
.directories
.get(path)
.cloned()
.unwrap_or_else(|| path.to_owned()))
}
}
fn scan(&self, path: &Path, visit: &mut dyn FnMut(io::Result<PathBuf>)) -> io::Result<()> {
let entries = {
let mut state = self.0.lock().unwrap();
state.operations.push(Operation::Scan(path.to_owned()));
if state.scan_errors.contains(path) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"fake scan failure",
));
}
state.entries.get(path).cloned().unwrap_or_default()
};
for entry in entries {
visit(Ok(entry));
}
Ok(())
}
}
#[derive(Debug)]
struct TestFilter {
denied: HashSet<PathBuf>,
}
impl Filterer for TestFilter {
fn check_dir(&self, path: &Path) -> Result<bool, RuntimeError> {
Ok(!self.denied.contains(path))
}
fn check_event(&self, _event: &Event, _priority: Priority) -> Result<bool, RuntimeError> {
Ok(true)
}
}
fn filter(denied: impl IntoIterator<Item = &'static str>) -> Arc<dyn Filterer> {
Arc::new(TestFilter {
denied: denied.into_iter().map(PathBuf::from).collect(),
})
}
fn directory(state: &Arc<Mutex<FakeScannerState>>, path: &str) {
state
.lock()
.unwrap()
.directories
.insert(path.into(), path.into());
}
fn entries(state: &Arc<Mutex<FakeScannerState>>, path: &str, entries: &[&str]) {
state
.lock()
.unwrap()
.entries
.insert(path.into(), entries.iter().map(PathBuf::from).collect());
}
fn fixture() -> (
Recursor,
Arc<Mutex<FakeBackendState>>,
Arc<Mutex<FakeScannerState>>,
) {
fixture_with_backend_kind(notify::WatcherKind::Inotify)
}
fn fixture_with_backend_kind(
backend_kind: notify::WatcherKind,
) -> (
Recursor,
Arc<Mutex<FakeBackendState>>,
Arc<Mutex<FakeScannerState>>,
) {
let backend = Arc::new(Mutex::new(FakeBackendState::default()));
let scanner = Arc::new(Mutex::new(FakeScannerState::default()));
directory(&scanner, "/");
let recursor = Recursor::new(
Box::new(FakeBackend(backend.clone())),
Box::new(FakeScanner(scanner.clone())),
Watcher::Native,
backend_kind,
true,
PathBuf::from("/work"),
filter([]),
);
(recursor, backend, scanner)
}
fn drain(recursor: &mut Recursor) -> Vec<RuntimeError> {
let mut errors = Vec::new();
for _ in 0..256 {
if !recursor.has_work() {
return errors;
}
let step = recursor.step();
assert!(!step.rebuild_backend, "unexpected backend rebuild");
errors.extend(step.errors);
}
panic!("recursor did not settle");
}
fn run_until_rebuild(recursor: &mut Recursor) -> Vec<RuntimeError> {
let mut errors = Vec::new();
for _ in 0..256 {
assert!(recursor.has_work(), "recursor settled before rebuilding");
let step = recursor.step();
errors.extend(step.errors);
if step.rebuild_backend {
return errors;
}
}
panic!("recursor did not request a rebuild");
}
fn watched(backend: &Arc<Mutex<FakeBackendState>>, path: &str) -> usize {
backend
.lock()
.unwrap()
.operations
.iter()
.filter(
|operation| matches!(operation, Operation::Watch(watched, _) if watched == Path::new(path)),
)
.count()
}
fn unwatched(backend: &Arc<Mutex<FakeBackendState>>, path: &str) -> usize {
backend
.lock()
.unwrap()
.operations
.iter()
.filter(
|operation| matches!(operation, Operation::Unwatch(watched) if watched == Path::new(path)),
)
.count()
}
fn watch_batches(backend: &Arc<Mutex<FakeBackendState>>) -> Vec<Vec<(PathBuf, RecursiveMode)>> {
backend
.lock()
.unwrap()
.operations
.iter()
.filter_map(|operation| match operation {
Operation::WatchBatch(watches) => Some(watches.clone()),
_ => None,
})
.collect()
}
#[test]
fn watches_each_directory_before_scanning_it() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
directory(&scanner, "/root/child");
entries(&scanner, "/root", &["/root/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
recursor.step();
assert_eq!(watched(&backend, "/root"), 1);
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root".into())));
recursor.step();
assert!(scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root".into())));
recursor.step();
assert_eq!(watched(&backend, "/root/child"), 1);
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root/child".into())));
recursor.step();
assert!(scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root/child".into())));
drain(&mut recursor);
assert!(backend.lock().unwrap().operations.iter().all(|operation| {
!matches!(operation, Operation::Watch(_, RecursiveMode::Recursive))
}));
}
#[test]
fn ignored_descendant_is_pruned_but_explicit_root_is_not() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/ignored", "/root/ignored/nested"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/ignored"]);
entries(&scanner, "/root/ignored", &["/root/ignored/nested"]);
recursor.reconcile(
&[WatchedPath::recursive("/root")],
filter(["/root/ignored"]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/ignored"), 0);
recursor.reconcile(
&[
WatchedPath::recursive("/root"),
WatchedPath::recursive("/root/ignored"),
],
filter(["/root/ignored"]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/ignored"), 1);
assert_eq!(watched(&backend, "/root/ignored/nested"), 1);
}
#[test]
fn a_local_scan_failure_does_not_stop_good_siblings() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/good"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/bad", "/root/good"]);
scanner
.lock()
.unwrap()
.classify_errors
.insert("/root/bad".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert!(errors.iter().any(|error| matches!(
error,
RuntimeError::FsWatcher {
err: FsWatcherError::PathScan { path, .. },
..
} if path == Path::new("/root/bad")
)));
assert_eq!(watched(&backend, "/root/good"), 1);
}
#[test]
fn transient_child_classification_failure_is_retried_once() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
recursor.step();
recursor.step();
scanner
.lock()
.unwrap()
.classify_errors
.insert("/root/child".into());
let failed = recursor.step();
assert_eq!(failed.errors.len(), 1);
scanner
.lock()
.unwrap()
.classify_errors
.remove(Path::new("/root/child"));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/child"), 1);
assert_eq!(watched(&backend, "/root/child/grand"), 1);
}
#[test]
fn transient_child_scan_failure_is_retried_once() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
recursor.step();
recursor.step();
recursor.step();
scanner
.lock()
.unwrap()
.scan_errors
.insert("/root/child".into());
let failed = recursor.step();
assert_eq!(failed.errors.len(), 1);
scanner
.lock()
.unwrap()
.scan_errors
.remove(Path::new("/root/child"));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/child/grand"), 1);
}
#[test]
fn persistent_child_errors_are_bounded_and_keep_parent_coverage() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
directory(&scanner, "/root/child");
entries(&scanner, "/root", &["/root/child"]);
scanner
.lock()
.unwrap()
.classify_errors
.insert("/root/child".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
let classifications = scanner
.lock()
.unwrap()
.operations
.iter()
.filter(|operation| operation == &&Operation::Classify("/root/child".into()))
.count();
assert_eq!(errors.len(), 2);
assert_eq!(classifications, 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
assert_eq!(unwatched(&backend, "/root"), 0);
}
#[test]
fn second_epoch_classification_failure_preserves_known_subtree() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.classify_errors
.insert("/root/child".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(errors.len(), 2);
assert_eq!(unwatched(&backend, "/root/child"), 0);
assert_eq!(unwatched(&backend, "/root/child/grand"), 0);
assert!(recursor
.logical
.contains_key(Path::new("/root/child/grand")));
}
#[test]
fn second_epoch_scan_failure_preserves_known_descendants() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.scan_errors
.insert("/root/child".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(errors.len(), 2);
assert_eq!(unwatched(&backend, "/root/child/grand"), 0);
assert!(recursor
.logical
.contains_key(Path::new("/root/child/grand")));
}
#[test]
fn nonrecursive_root_is_watched_once_without_scanning() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
entries(&scanner, "/root", &["/root/child"]);
recursor.reconcile(&[WatchedPath::non_recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
assert!(!scanner
.lock()
.unwrap()
.operations
.iter()
.any(|operation| matches!(operation, Operation::Scan(_))));
}
#[test]
fn native_poll_fallback_verifies_success_against_metadata() {
let (mut recursor, backend, scanner) = fixture();
recursor.backend_kind = notify::WatcherKind::PollWatcher;
directory(&scanner, "/root");
scanner
.lock()
.unwrap()
.not_found_after
.insert("/root".into(), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
assert_eq!(unwatched(&backend, "/root"), 1);
assert!(!recursor.logical.contains_key(Path::new("/root")));
assert!(errors.iter().any(|error| matches!(
error,
RuntimeError::FsWatcher {
err: FsWatcherError::PathScan { path, .. },
..
} if path == Path::new("/root")
)));
}
#[test]
fn poll_permission_error_does_not_commit_unverified_registration() {
let (mut recursor, backend, scanner) = fixture();
recursor.backend_kind = notify::WatcherKind::PollWatcher;
directory(&scanner, "/root");
scanner
.lock()
.unwrap()
.classify_errors_after
.insert("/root".into(), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
assert_eq!(unwatched(&backend, "/root"), 1);
assert!(!recursor.logical.contains_key(Path::new("/root")));
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
assert!(errors.iter().any(|error| matches!(
error,
RuntimeError::FsWatcher {
err: FsWatcherError::PathScan { path, err },
..
} if path == Path::new("/root") && err.kind() == io::ErrorKind::PermissionDenied
)));
}
#[test]
fn unmanaged_backend_installs_complete_roots_with_requested_modes() {
let backend = Arc::new(Mutex::new(FakeBackendState::default()));
let scanner = Arc::new(Mutex::new(FakeScannerState::default()));
let mut recursor = Recursor::new(
Box::new(FakeBackend(backend.clone())),
Box::new(FakeScanner(scanner.clone())),
Watcher::Native,
notify::WatcherKind::Kqueue,
true,
PathBuf::from("/work"),
filter([]),
);
recursor.reconcile(
&[
WatchedPath::recursive("/one"),
WatchedPath::non_recursive("/two"),
],
filter([]),
);
drain(&mut recursor);
let operations = &backend.lock().unwrap().operations;
assert!(operations.contains(&Operation::Watch("/one".into(), RecursiveMode::Recursive)));
assert!(operations.contains(&Operation::Watch(
"/two".into(),
RecursiveMode::NonRecursive
)));
assert!(scanner.lock().unwrap().operations.is_empty());
}
#[test]
fn unmanaged_same_value_epoch_retries_resource_failed_root() {
let backend = Arc::new(Mutex::new(FakeBackendState::default()));
let scanner = Arc::new(Mutex::new(FakeScannerState::default()));
backend.lock().unwrap().resource_failure = Some("/root".into());
let mut recursor = Recursor::new(
Box::new(FakeBackend(backend.clone())),
Box::new(FakeScanner(scanner)),
Watcher::Native,
notify::WatcherKind::Kqueue,
true,
PathBuf::from("/work"),
filter([]),
);
let pathset = [WatchedPath::recursive("/root")];
recursor.reconcile(&pathset, filter([]));
let first = drain(&mut recursor);
assert_eq!(first.len(), 1);
assert!(recursor.needs_retry());
backend.lock().unwrap().resource_failure = None;
recursor.reconcile(&pathset, filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
assert!(!recursor.needs_retry());
}
#[test]
fn fsevents_batches_root_coverage_before_logical_scan() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
recursor.step();
let batches = watch_batches(&backend);
assert_eq!(batches.len(), 1);
assert!(batches[0].contains(&(PathBuf::from("/root"), RecursiveMode::Recursive)));
assert!(batches[0].contains(&(PathBuf::from("/"), RecursiveMode::NonRecursive)));
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root".into())));
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/child")));
let operations = &backend.lock().unwrap().operations;
assert!(!operations
.iter()
.any(|operation| matches!(operation, Operation::Watch(..) | Operation::Unwatch(_))));
assert!(recursor
.physical
.values()
.all(|physical| !physical.registered));
}
#[test]
fn fsevents_prunes_and_reconciles_only_the_logical_tree() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in [
"/root",
"/root/accepted",
"/root/ignored",
"/root/ignored/nested",
] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/accepted", "/root/ignored"]);
entries(&scanner, "/root/ignored", &["/root/ignored/nested"]);
let roots = [WatchedPath::recursive("/root")];
recursor.reconcile(&roots, filter(["/root/ignored"]));
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/accepted")));
assert!(!recursor.logical.contains_key(Path::new("/root/ignored")));
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root/ignored".into())));
assert!(!recursor.event_is_public(
¬ify::Event::new(EventKind::Any).add_path("/root/ignored/nested/file".into())
));
assert!(recursor.event_is_public(
¬ify::Event::new(EventKind::Any).add_path("/root/accepted/file".into())
));
let initial_operations = backend.lock().unwrap().operations.clone();
directory(&scanner, "/root/accepted/new");
recursor.topology_create("/root/accepted/new".into());
drain(&mut recursor);
recursor.topology_remove("/root/accepted/new".into());
drain(&mut recursor);
recursor.reconcile(&roots, filter(["/root/accepted"]));
drain(&mut recursor);
recursor.rescan();
drain(&mut recursor);
assert_eq!(backend.lock().unwrap().operations, initial_operations);
assert!(!recursor.logical.contains_key(Path::new("/root/accepted")));
}
#[test]
fn fsevents_rescan_refreshes_logical_state_without_native_churn() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in ["/root", "/root/old"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/old"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/old")));
let initial_operations = backend.lock().unwrap().operations.clone();
directory(&scanner, "/root/new");
entries(&scanner, "/root", &["/root/new"]);
recursor.rescan();
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root/old")));
assert!(recursor.logical.contains_key(Path::new("/root/new")));
assert_eq!(backend.lock().unwrap().operations, initial_operations);
}
#[test]
fn fsevents_discovers_populated_tree_without_descendant_registration() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
let initial_operations = backend.lock().unwrap().operations.clone();
for path in ["/root/new", "/root/new/deep"] {
directory(&scanner, path);
}
entries(&scanner, "/root/new", &["/root/new/deep"]);
recursor.topology_create("/root/new".into());
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/new/deep")));
assert_eq!(backend.lock().unwrap().operations, initial_operations);
}
#[test]
fn fsevents_does_not_claim_external_descendant_symlink_coverage() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/root");
scanner
.lock()
.unwrap()
.directories
.insert("/root/link".into(), "/external".into());
entries(&scanner, "/root", &["/root/link"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root/link")));
assert_eq!(watch_batches(&backend).len(), 1);
assert!(!recursor.event_is_public(
¬ify::Event::new(EventKind::Any).add_path("/root/link/file".into())
));
}
#[test]
fn fsevents_explicit_nested_root_overrides_pruned_ancestor() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in ["/root", "/root/ignored", "/root/ignored/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/ignored"]);
entries(&scanner, "/root/ignored", &["/root/ignored/child"]);
recursor.reconcile(
&[
WatchedPath::recursive("/root"),
WatchedPath::recursive("/root/ignored"),
],
filter(["/root/ignored"]),
);
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/ignored")));
assert!(recursor
.logical
.contains_key(Path::new("/root/ignored/child")));
let batches = watch_batches(&backend);
assert_eq!(batches.len(), 1);
assert!(!batches[0]
.iter()
.any(|(path, _)| path == Path::new("/root/ignored")));
}
#[test]
fn fsevents_missing_nonrecursive_root_has_stable_child_coverage() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/parent");
scanner
.lock()
.unwrap()
.not_found
.insert("/parent/root".into());
recursor.reconcile(&[WatchedPath::non_recursive("/parent/root")], filter([]));
drain(&mut recursor);
assert_eq!(
watch_batches(&backend),
vec![vec![(PathBuf::from("/parent"), RecursiveMode::Recursive)]]
);
{
let mut scanner = scanner.lock().unwrap();
scanner.not_found.remove(Path::new("/parent/root"));
scanner
.directories
.insert("/parent/root".into(), "/parent/root".into());
}
recursor.topology_create("/parent/root".into());
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/parent/root")));
assert!(recursor.event_is_public(
¬ify::Event::new(EventKind::Any).add_path("/parent/root/file".into())
));
assert_eq!(watch_batches(&backend).len(), 1);
}
#[test]
fn fsevents_file_root_can_become_a_directory_without_native_churn() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/root");
let root = PathBuf::from("/root/item");
recursor.reconcile(&[WatchedPath::recursive(root.clone())], filter([]));
drain(&mut recursor);
directory(&scanner, "/root/item");
directory(&scanner, "/root/item/child");
entries(&scanner, "/root/item", &["/root/item/child"]);
recursor.topology_ambiguous(root);
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/root/item/child")));
assert_eq!(watch_batches(&backend).len(), 1);
}
#[test]
fn fsevents_projects_root_level_canonical_events_to_aliases() {
let (mut recursor, _backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
scanner
.lock()
.unwrap()
.directories
.insert("/alias".into(), "/real".into());
recursor.reconcile(&[WatchedPath::recursive("/alias")], filter([]));
drain(&mut recursor);
assert!(recursor
.projected_topology_paths(Path::new("/real"))
.contains(&PathBuf::from("/alias")));
assert!(
recursor.event_is_public(¬ify::Event::new(EventKind::Any).add_path("/real".into()))
);
scanner.lock().unwrap().not_found.insert("/alias".into());
recursor.topology_remove("/real".into());
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/alias")));
}
#[test]
fn fsevents_rebuilds_when_configured_root_changes_canonical_target() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
scanner
.lock()
.unwrap()
.directories
.insert("/alias".into(), "/target-a".into());
recursor.reconcile(&[WatchedPath::recursive("/alias")], filter([]));
drain(&mut recursor);
assert_eq!(watch_batches(&backend).len(), 1);
{
let mut scanner = scanner.lock().unwrap();
scanner
.directories
.insert("/alias".into(), "/target-b".into());
scanner
.directories
.insert("/alias/child".into(), "/target-b/child".into());
scanner
.entries
.insert("/alias".into(), vec![PathBuf::from("/alias/child")]);
}
recursor.topology_ambiguous("/alias".into());
run_until_rebuild(&mut recursor);
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
let replay = recursor.replay_backend_snapshot();
assert!(!replay.rebuild_backend);
drain(&mut recursor);
assert_eq!(watch_batches(&backend).len(), 2);
assert_eq!(
recursor
.logical
.get(Path::new("/alias"))
.map(|watch| &watch.identity),
Some(&Identity::Canonical(PathBuf::from("/target-b")))
);
assert!(recursor.logical.contains_key(Path::new("/alias/child")));
}
#[test]
fn fsevents_pending_coverage_does_not_retry_on_logical_epochs() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/root");
backend.lock().unwrap().resource_failure = Some("/root".into());
let roots = [WatchedPath::recursive("/root")];
recursor.reconcile(&roots, filter([]));
drain(&mut recursor);
assert!(recursor.needs_retry());
assert_eq!(watch_batches(&backend).len(), 1);
recursor.reconcile(&roots, filter(["/root/ignored"]));
drain(&mut recursor);
recursor.rescan();
drain(&mut recursor);
assert_eq!(watch_batches(&backend).len(), 1);
}
#[test]
fn fsevents_path_disappearance_rebuilds_before_scanning() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
backend
.lock()
.unwrap()
.path_not_found_failures
.insert("/root".into(), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
run_until_rebuild(&mut recursor);
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root".into())));
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
let replay = recursor.replay_backend_snapshot();
assert!(!replay.rebuild_backend);
drain(&mut recursor);
assert_eq!(watch_batches(&backend).len(), 2);
assert!(recursor.logical.contains_key(Path::new("/root/child")));
}
#[test]
fn fsevents_file_root_uses_stable_parent_coverage_across_replacement() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
directory(&scanner, "/root");
let file = PathBuf::from("/root/file");
recursor.reconcile(&[WatchedPath::recursive(file.clone())], filter([]));
drain(&mut recursor);
assert_eq!(
watch_batches(&backend),
vec![vec![
(PathBuf::from("/root"), RecursiveMode::NonRecursive),
(PathBuf::from("/root/file"), RecursiveMode::Recursive),
]]
);
for _ in 0..8 {
recursor.topology_remove(file.clone());
drain(&mut recursor);
assert!(recursor.logical.contains_key(&file));
}
assert_eq!(watch_batches(&backend).len(), 1);
assert_eq!(unwatched(&backend, "/root"), 0);
assert!(recursor.event_is_public(¬ify::Event::new(EventKind::Any).add_path(file)));
assert!(!recursor
.event_is_public(¬ify::Event::new(EventKind::Any).add_path("/root/sibling".into())));
}
#[test]
fn fsevents_rebuild_replays_only_batched_root_coverage() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::Fsevent);
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
backend.lock().unwrap().batch_commit_failures = 1;
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
run_until_rebuild(&mut recursor);
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
let replay = recursor.replay_backend_snapshot();
assert!(!replay.rebuild_backend);
drain(&mut recursor);
assert_eq!(watch_batches(&backend).len(), 2);
assert!(recursor.logical.contains_key(Path::new("/root/child")));
assert!(!backend
.lock()
.unwrap()
.operations
.iter()
.any(|operation| matches!(operation, Operation::Watch(..) | Operation::Unwatch(_))));
}
#[test]
fn recursive_and_nonrecursive_same_root_share_one_watch() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(
&[
WatchedPath::non_recursive("/root"),
WatchedPath::recursive("/root"),
],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
assert!(scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root".into())));
}
#[test]
fn queued_sweep_does_not_remove_refreshed_owner() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let root = recursor.roots.iter().next().unwrap().clone();
recursor.work.clear();
recursor.prepare_sweep();
assert!(!recursor.sweep.is_empty());
recursor
.logical
.get_mut(Path::new("/root/child"))
.unwrap()
.owners
.insert(root, recursor.epoch);
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/root/child"), 0);
assert!(recursor.logical.contains_key(Path::new("/root/child")));
}
#[test]
fn overlapping_roots_share_registrations_and_ownership() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(
&[
WatchedPath::recursive("/root"),
WatchedPath::recursive("/root/child"),
],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/child"), 1);
recursor.reconcile(&[WatchedPath::recursive("/root/child")], filter([]));
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/root/child"), 0);
assert_eq!(unwatched(&backend, "/root/child/grand"), 0);
assert_eq!(unwatched(&backend, "/root"), 1);
}
#[test]
fn recursive_file_discovery_keeps_explicit_file_owner() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
entries(&scanner, "/root", &["/root/file"]);
recursor.reconcile(
&[
WatchedPath::recursive("/root"),
WatchedPath::non_recursive("/root/file"),
],
filter([]),
);
drain(&mut recursor);
let explicit = Root {
path: "/root/file".into(),
recursive: false,
};
let recursive = Root {
path: "/root".into(),
recursive: true,
};
let file = recursor.logical.get(Path::new("/root/file")).unwrap();
assert!(file.owners.contains_key(&explicit));
assert!(!file.owners.contains_key(&recursive));
assert_eq!(watched(&backend, "/root/file"), 1);
assert_eq!(unwatched(&backend, "/root/file"), 0);
}
#[test]
fn removal_uses_known_prefix_without_metadata() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/gone", "/root/gone/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/gone"]);
entries(&scanner, "/root/gone", &["/root/gone/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
let classifications = scanner.lock().unwrap().operations.len();
recursor.topology_remove("/root/gone".into());
drain(&mut recursor);
assert_eq!(scanner.lock().unwrap().operations.len(), classifications);
assert_eq!(unwatched(&backend, "/root/gone/child"), 1);
assert_eq!(unwatched(&backend, "/root/gone"), 1);
}
#[test]
fn prefix_removal_finishes_after_generic_unwatch_rebuild() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/gone", "/root/gone/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/gone"]);
entries(&scanner, "/root/gone", &["/root/gone/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
backend
.lock()
.unwrap()
.generic_unwatch_failures
.insert("/root/gone/child".into(), 1);
recursor.topology_remove("/root/gone".into());
assert_eq!(run_until_rebuild(&mut recursor).len(), 1);
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement.clone())));
drain(&mut recursor);
assert_eq!(watched(&replacement, "/root/gone"), 0);
assert_eq!(watched(&replacement, "/root/gone/child"), 0);
assert!(!recursor.logical.contains_key(Path::new("/root/gone")));
assert!(!recursor.logical.contains_key(Path::new("/root/gone/child")));
}
#[test]
fn already_removed_backend_watch_converges_without_error() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/gone"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/gone"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
backend
.lock()
.unwrap()
.watch_not_found
.insert("/root/gone".into());
recursor.topology_remove("/root/gone".into());
let errors = drain(&mut recursor);
assert!(errors.is_empty());
assert!(!recursor.logical.contains_key(Path::new("/root/gone")));
assert_eq!(unwatched(&backend, "/root/gone"), 1);
}
#[test]
fn windows_directory_root_keeps_parent_guard_after_recreation() {
let (mut recursor, backend, scanner) =
fixture_with_backend_kind(notify::WatcherKind::ReadDirectoryChangesWatcher);
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/"), 1);
assert_eq!(watched(&backend, "/root"), 1);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
scanner.lock().unwrap().not_found.insert("/root".into());
recursor.topology_remove("/root".into());
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root")));
assert_eq!(unwatched(&backend, "/"), 0);
scanner.lock().unwrap().not_found.remove(Path::new("/root"));
recursor.topology_create("/root".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
assert_eq!(watched(&backend, "/"), 1);
}
#[test]
fn configured_directory_root_is_reacquired_after_delete_recreate() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/"), 0);
scanner.lock().unwrap().not_found.insert("/root".into());
recursor.topology_remove("/root".into());
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root")));
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
assert_eq!(watched(&backend, "/"), 1);
scanner.lock().unwrap().not_found.remove(Path::new("/root"));
recursor.topology_create("/root".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
assert!(recursor.root_guards.is_empty());
}
#[test]
fn from_only_atomic_root_exchange_reacquires_without_create() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.directories
.insert("/root".into(), "/identity/a".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.directories
.insert("/root".into(), "/identity/b".into());
recursor.topology_remove("/root".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 2);
assert_eq!(
recursor.logical.get(Path::new("/root")).unwrap().identity,
Identity::Canonical("/identity/b".into())
);
assert!(recursor.root_guards.is_empty());
}
#[test]
fn from_only_atomic_file_root_exchange_reacquires_without_create() {
let (mut recursor, backend, _scanner) = fixture();
recursor.reconcile(&[WatchedPath::recursive("/file")], filter([]));
drain(&mut recursor);
recursor.topology_remove("/file".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/file"), 2);
assert!(recursor.logical.contains_key(Path::new("/file")));
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
assert_eq!(watched(&backend, "/"), 1);
}
#[test]
fn missing_root_guard_moves_to_nearest_existing_ancestor() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.not_found
.extend([PathBuf::from("/a"), PathBuf::from("/a/b")]);
recursor.reconcile(&[WatchedPath::recursive("/a/b")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/a/b"), 0);
assert_eq!(watched(&backend, "/"), 1);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
directory(&scanner, "/a");
scanner.lock().unwrap().not_found.remove(Path::new("/a"));
recursor.topology_create("/a".into());
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/"), 1);
assert_eq!(watched(&backend, "/a"), 1);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/a"))
);
scanner.lock().unwrap().not_found.insert("/a".into());
recursor.topology_remove("/a".into());
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/a"), 1);
assert_eq!(watched(&backend, "/"), 2);
scanner.lock().unwrap().not_found.remove(Path::new("/a"));
scanner.lock().unwrap().not_found.remove(Path::new("/a/b"));
directory(&scanner, "/a/b");
recursor.topology_create("/a".into());
recursor.topology_create("/a/b".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/a/b"), 1);
assert!(recursor.root_guards.is_empty());
}
#[test]
fn missing_outer_root_does_not_discard_queued_nested_root() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.not_found
.extend([PathBuf::from("/a"), PathBuf::from("/a/b")]);
recursor.reconcile(
&[
WatchedPath::non_recursive("/a"),
WatchedPath::recursive("/a/b"),
],
filter([]),
);
drain(&mut recursor);
assert_eq!(recursor.root_guards.len(), 2);
assert!(recursor
.roots
.iter()
.all(|root| recursor.root_guards.get(root) == Some(&PathBuf::from("/"))));
assert_eq!(watched(&backend, "/"), 1);
assert_eq!(watched(&backend, "/a"), 0);
assert_eq!(watched(&backend, "/a/b"), 0);
}
#[test]
fn guarded_nested_root_advances_on_one_sided_move_to_ancestor() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.not_found
.extend([PathBuf::from("/a"), PathBuf::from("/a/b")]);
recursor.reconcile(&[WatchedPath::recursive("/a/b")], filter([]));
drain(&mut recursor);
assert_eq!(
recursor
.root_guards
.get(recursor.roots.iter().next().unwrap()),
Some(&PathBuf::from("/"))
);
directory(&scanner, "/a");
scanner.lock().unwrap().not_found.remove(Path::new("/a"));
recursor.topology_ambiguous("/a".into());
drain(&mut recursor);
assert_eq!(
recursor
.root_guards
.get(recursor.roots.iter().next().unwrap()),
Some(&PathBuf::from("/a"))
);
assert_eq!(watched(&backend, "/a"), 1);
directory(&scanner, "/a/b");
scanner.lock().unwrap().not_found.remove(Path::new("/a/b"));
recursor.topology_create("/a/b".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/a/b"), 1);
assert!(recursor.logical.contains_key(Path::new("/a/b")));
assert!(recursor.root_guards.is_empty());
}
#[test]
fn atomic_exchange_of_guarded_ancestor_reregisters_closer_guard() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.directories
.extend([(PathBuf::from("/a"), PathBuf::from("/identity/old"))]);
scanner.lock().unwrap().not_found.insert("/a/b".into());
recursor.reconcile(&[WatchedPath::recursive("/a/b")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/a"), 1);
scanner
.lock()
.unwrap()
.directories
.insert("/a".into(), "/identity/new".into());
recursor.topology_remove("/a".into());
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/a"), 1);
assert_eq!(watched(&backend, "/a"), 2);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/a"))
);
directory(&scanner, "/a/b");
scanner.lock().unwrap().not_found.remove(Path::new("/a/b"));
recursor.topology_create("/a/b".into());
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/a/b")));
assert!(recursor.root_guards.is_empty());
}
#[test]
fn guard_permission_error_preserves_closer_existing_coverage() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/a");
scanner.lock().unwrap().not_found.insert("/a/b".into());
recursor.reconcile(&[WatchedPath::recursive("/a/b")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/a"), 1);
scanner.lock().unwrap().classify_errors.insert("/a".into());
let root = recursor.roots.iter().next().unwrap().clone();
recursor.work.push_back(Work::Guard(root));
let errors = drain(&mut recursor);
assert_eq!(errors.len(), 1);
assert_eq!(unwatched(&backend, "/a"), 0);
assert_eq!(watched(&backend, "/"), 0);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/a"))
);
}
#[test]
fn initial_root_permission_error_installs_guard_and_retries_once() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.classify_errors
.insert("/denied".into());
recursor.reconcile(&[WatchedPath::recursive("/denied")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(errors.len(), 2);
assert_eq!(watched(&backend, "/denied"), 0);
assert_eq!(watched(&backend, "/"), 1);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
assert_eq!(
scanner
.lock()
.unwrap()
.operations
.iter()
.filter(|operation| {
matches!(operation, Operation::Classify(path) if path == Path::new("/denied"))
})
.count(),
2
);
scanner
.lock()
.unwrap()
.classify_errors
.remove(Path::new("/denied"));
directory(&scanner, "/denied");
recursor.topology_create("/denied".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/denied"), 1);
assert!(recursor.root_guards.is_empty());
}
#[test]
fn non_followed_explicit_symlink_uses_only_ancestor_guard() {
let (mut recursor, backend, scanner) = fixture();
recursor.follow_symlinks = false;
scanner
.lock()
.unwrap()
.non_followed_symlinks
.insert("/link".into());
recursor.reconcile(&[WatchedPath::recursive("/link")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/link"), 0);
assert_eq!(watched(&backend, "/"), 1);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/"))
);
}
#[test]
fn non_followed_component_uses_nearest_safe_guard() {
let (mut recursor, backend, scanner) = fixture();
recursor.follow_symlinks = false;
directory(&scanner, "/base");
scanner
.lock()
.unwrap()
.non_followed_symlinks
.insert("/base/link".into());
recursor.reconcile(
&[WatchedPath::recursive("/base/link/deeper/root")],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/base"), 1);
assert_eq!(watched(&backend, "/base/link"), 0);
assert_eq!(watched(&backend, "/base/link/deeper/root"), 0);
scanner
.lock()
.unwrap()
.non_followed_symlinks
.remove(Path::new("/base/link"));
for path in ["/base/link", "/base/link/deeper", "/base/link/deeper/root"] {
directory(&scanner, path);
}
recursor.topology_create("/base/link".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/base/link/deeper/root"), 1);
assert!(recursor.root_guards.is_empty());
}
#[test]
fn unmanaged_non_followed_component_skips_notify_registration() {
let backend = Arc::new(Mutex::new(FakeBackendState::default()));
let scanner = Arc::new(Mutex::new(FakeScannerState::default()));
directory(&scanner, "/");
directory(&scanner, "/base");
scanner
.lock()
.unwrap()
.non_followed_symlinks
.insert("/base/link".into());
let mut recursor = Recursor::new(
Box::new(FakeBackend(backend.clone())),
Box::new(FakeScanner(scanner)),
Watcher::Native,
notify::WatcherKind::Kqueue,
false,
PathBuf::from("/work"),
filter([]),
);
recursor.reconcile(
&[WatchedPath::recursive("/base/link/deeper/root")],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/base/link/deeper/root"), 0);
assert_eq!(watched(&backend, "/base"), 0);
}
#[test]
fn followed_missing_root_guards_lexical_symlink_ancestor() {
let (mut recursor, backend, scanner) = fixture();
scanner
.lock()
.unwrap()
.directories
.insert("/alias".into(), "/target".into());
scanner
.lock()
.unwrap()
.not_found
.insert("/alias/missing".into());
recursor.reconcile(&[WatchedPath::recursive("/alias/missing")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/alias"), 1);
assert_eq!(watched(&backend, "/"), 0);
assert_eq!(
recursor.root_guards.values().next(),
Some(&PathBuf::from("/alias"))
);
}
#[test]
fn canonical_source_alias_covers_guard_and_projects_topology() {
let (mut recursor, backend, scanner) = fixture();
scanner.lock().unwrap().directories.extend([
(PathBuf::from("/a-source"), PathBuf::from("/real")),
(PathBuf::from("/b-alias"), PathBuf::from("/real")),
]);
scanner
.lock()
.unwrap()
.not_found
.insert("/b-alias/missing".into());
recursor.reconcile(
&[
WatchedPath::recursive("/a-source"),
WatchedPath::recursive("/b-alias/missing"),
],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/a-source"), 1);
assert_eq!(watched(&backend, "/b-alias"), 0);
assert_eq!(
recursor
.root_guards
.values()
.find(|path| path.as_path() == Path::new("/b-alias")),
Some(&PathBuf::from("/b-alias"))
);
scanner
.lock()
.unwrap()
.not_found
.remove(Path::new("/b-alias/missing"));
scanner.lock().unwrap().directories.extend([
(
PathBuf::from("/a-source/missing"),
PathBuf::from("/real/missing"),
),
(
PathBuf::from("/b-alias/missing"),
PathBuf::from("/real/missing"),
),
]);
recursor.topology_create("/a-source/missing".into());
drain(&mut recursor);
assert!(recursor.logical.contains_key(Path::new("/a-source")));
assert!(recursor.logical.contains_key(Path::new("/b-alias/missing")));
assert_eq!(
watched(&backend, "/a-source/missing") + watched(&backend, "/b-alias/missing"),
1
);
assert_eq!(unwatched(&backend, "/a-source"), 0);
assert_eq!(watched(&backend, "/a-source"), 1);
assert!(recursor.root_guards.is_empty());
}
#[test]
fn configured_file_root_is_reacquired_after_delete_recreate() {
let (mut recursor, backend, scanner) = fixture();
recursor.reconcile(&[WatchedPath::non_recursive("/file")], filter([]));
drain(&mut recursor);
scanner.lock().unwrap().not_found.insert("/file".into());
recursor.topology_remove("/file".into());
drain(&mut recursor);
scanner.lock().unwrap().not_found.remove(Path::new("/file"));
recursor.topology_create("/file".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/file"), 2);
assert!(recursor.logical.contains_key(Path::new("/file")));
}
#[test]
fn parent_guard_events_outside_roots_are_not_public() {
let (mut recursor, _backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
let sibling = notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::Any))
.add_path("/sibling".into());
let root = notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::Any))
.add_path("/root".into());
assert!(!recursor.event_is_public(&sibling));
assert!(recursor.event_is_public(&root));
}
#[test]
fn create_scans_an_already_populated_subtree() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
for path in ["/root/new", "/root/new/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root/new", &["/root/new/child"]);
recursor.topology_create("/root/new".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/new"), 1);
assert_eq!(watched(&backend, "/root/new/child"), 1);
}
#[test]
fn split_replacement_rebuilds_populated_subtree_and_later_nested_state() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/item", "/root/item/old"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/item"]);
entries(&scanner, "/root/item", &["/root/item/old"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.not_found
.insert("/root/item/old".into());
for path in ["/root/item/new", "/root/item/new/grand"] {
directory(&scanner, path);
}
entries(&scanner, "/root/item", &["/root/item/new"]);
entries(&scanner, "/root/item/new", &[]);
recursor.topology_remove("/root/item".into());
recursor.topology_ambiguous("/root/item".into());
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root/item/old")));
assert!(recursor.logical.contains_key(Path::new("/root/item/new")));
assert_eq!(watched(&backend, "/root/item"), 2);
entries(&scanner, "/root/item/new", &["/root/item/new/grand"]);
recursor.topology_create("/root/item/new/grand".into());
drain(&mut recursor);
assert!(recursor
.logical
.contains_key(Path::new("/root/item/new/grand")));
assert!(recursor.event_is_public(
¬ify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Any))
.add_path("/root/item/new/grand".into())
));
}
#[test]
fn topology_does_not_inherit_stale_owner_during_filter_epoch() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/ignored", "/root/ignored/new"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/ignored"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
recursor.reconcile(
&[WatchedPath::recursive("/root")],
filter(["/root/ignored"]),
);
recursor.topology_create("/root/ignored/new".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/ignored/new"), 0);
assert!(!recursor
.logical
.contains_key(Path::new("/root/ignored/new")));
}
#[test]
fn filter_replacement_prunes_and_reopens_subtrees() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/build", "/root/build/cache"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/build"]);
entries(&scanner, "/root/build", &["/root/build/cache"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter(["/root/build"]));
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/root/build/cache"), 1);
assert_eq!(unwatched(&backend, "/root/build"), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/build"), 2);
assert_eq!(watched(&backend, "/root/build/cache"), 2);
}
#[test]
fn resource_error_preserves_existing_watches_and_latches_epoch() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
directory(&scanner, "/root/new");
backend.lock().unwrap().resource_failure = Some("/root/new".into());
recursor.topology_create("/root/new".into());
let first = recursor.step();
assert!(!first.rebuild_backend);
assert_eq!(first.errors.len(), 1);
assert!(matches!(
first.errors[0],
RuntimeError::FsWatcher {
err: FsWatcherError::TooManyWatches(_),
..
}
));
assert!(recursor.logical.contains_key(Path::new("/root")));
assert_eq!(unwatched(&backend, "/root"), 0);
directory(&scanner, "/root/other");
recursor.topology_create("/root/other".into());
let second = recursor.step();
assert!(second.errors.is_empty());
assert_eq!(watched(&backend, "/root/other"), 0);
backend.lock().unwrap().resource_failure = None;
entries(&scanner, "/root", &["/root/new", "/root/other"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/new"), 2);
assert_eq!(watched(&backend, "/root/other"), 1);
}
#[test]
fn resource_skipped_candidate_retries_when_removal_frees_capacity() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/old", "/root/new"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/old"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
backend.lock().unwrap().resource_failure = Some("/root/new".into());
recursor.topology_create("/root/new".into());
let failed = recursor.step();
assert_eq!(failed.errors.len(), 1);
assert_eq!(watched(&backend, "/root/new"), 1);
backend.lock().unwrap().resource_failure = None;
recursor.topology_remove("/root/old".into());
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/root/old"), 1);
assert_eq!(watched(&backend, "/root/new"), 2);
assert!(recursor.logical.contains_key(Path::new("/root/new")));
}
#[test]
fn generic_watch_failure_is_retried_once_on_fresh_backend() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
backend
.lock()
.unwrap()
.generic_failures
.insert("/root".into(), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = run_until_rebuild(&mut recursor);
assert_eq!(errors.len(), 1);
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
assert!(drain(&mut recursor).is_empty());
assert_eq!(watched(&backend, "/root"), 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
}
#[test]
fn backend_path_not_found_is_local_invalidation_not_rebuild() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
backend
.lock()
.unwrap()
.path_not_found_failures
.insert("/root".into(), 1);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
let errors = drain(&mut recursor);
assert_eq!(errors.len(), 1);
assert_eq!(watched(&backend, "/root"), 2);
assert!(recursor.logical.contains_key(Path::new("/root")));
assert!(recursor.addition_failures.get(Path::new("/root")).is_none());
assert!(!recursor.skipped_additions.contains(Path::new("/root")));
}
#[test]
fn recreated_descendant_clears_persistent_watch_suppression() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
backend
.lock()
.unwrap()
.generic_failures
.insert("/root/child".into(), 2);
recursor.topology_create("/root/child".into());
assert_eq!(run_until_rebuild(&mut recursor).len(), 1);
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
assert_eq!(drain(&mut recursor).len(), 1);
assert!(recursor
.skipped_additions
.contains(Path::new("/root/child")));
assert!(!recursor.logical.contains_key(Path::new("/root/child")));
recursor.topology_create("/root/child".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/root/child"), 3);
assert!(!recursor
.skipped_additions
.contains(Path::new("/root/child")));
assert!(recursor.logical.contains_key(Path::new("/root/child")));
}
#[test]
fn generic_rebuild_replays_coverage_before_scanner_failures() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/child", "/root/child/grand", "/root/new"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
entries(&scanner, "/root/child", &["/root/child/grand"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
backend
.lock()
.unwrap()
.generic_failures
.insert("/root/new".into(), 1);
recursor.topology_create("/root/new".into());
assert_eq!(run_until_rebuild(&mut recursor).len(), 1);
scanner
.lock()
.unwrap()
.classify_errors
.insert("/root/child".into());
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement.clone())));
drain(&mut recursor);
assert_eq!(watched(&replacement, "/root/child"), 1);
assert_eq!(watched(&replacement, "/root/child/grand"), 1);
assert_eq!(watched(&replacement, "/root/new"), 1);
assert!(recursor
.logical
.contains_key(Path::new("/root/child/grand")));
}
#[test]
fn persistent_watch_failure_is_excluded_after_one_retry() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/bad", "/healthy"] {
directory(&scanner, path);
}
recursor.reconcile(&[WatchedPath::recursive("/healthy")], filter([]));
drain(&mut recursor);
backend
.lock()
.unwrap()
.generic_failures
.insert("/bad".into(), 2);
recursor.reconcile(
&[
WatchedPath::recursive("/bad"),
WatchedPath::recursive("/healthy"),
],
filter([]),
);
assert_eq!(run_until_rebuild(&mut recursor).len(), 1);
recursor.replace_backend(Box::new(FakeBackend(backend.clone())));
assert_eq!(drain(&mut recursor).len(), 1);
assert_eq!(watched(&backend, "/bad"), 2);
assert_eq!(watched(&backend, "/healthy"), 2);
assert!(!recursor.logical.contains_key(Path::new("/bad")));
assert!(recursor.logical.contains_key(Path::new("/healthy")));
}
#[test]
fn replacing_backend_replays_all_roots() {
let (mut recursor, _backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement.clone())));
drain(&mut recursor);
assert_eq!(watched(&replacement, "/root"), 1);
}
#[test]
fn fresh_backend_replays_known_coverage_before_returning() {
let (mut recursor, _backend, scanner) = fixture();
for path in ["/root", "/root/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.prepare_backend_rebuild();
recursor.install_backend(Box::new(FakeBackend(replacement.clone())));
let replay = recursor.replay_backend_snapshot();
assert!(!replay.rebuild_backend);
assert!(replay.errors.is_empty());
assert_eq!(watched(&replacement, "/root"), 1);
assert_eq!(watched(&replacement, "/root/child"), 1);
assert!(recursor.replay_queue.is_empty());
assert!(recursor.logical.contains_key(Path::new("/root/child")));
}
#[test]
fn replay_path_not_found_migrates_to_surviving_alias() {
let (mut recursor, _backend, scanner) = fixture();
scanner.lock().unwrap().directories.extend([
(PathBuf::from("/alias-a"), PathBuf::from("/real")),
(PathBuf::from("/alias-b"), PathBuf::from("/real")),
]);
recursor.reconcile(
&[
WatchedPath::recursive("/alias-a"),
WatchedPath::recursive("/alias-b"),
],
filter([]),
);
drain(&mut recursor);
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
replacement
.lock()
.unwrap()
.path_not_found_failures
.insert("/alias-a".into(), 1);
recursor.prepare_backend_rebuild();
recursor.install_backend(Box::new(FakeBackend(replacement.clone())));
let replay = recursor.replay_backend_snapshot();
assert_eq!(replay.errors.len(), 1);
assert!(!replay.rebuild_backend);
assert_eq!(watched(&replacement, "/alias-a"), 1);
assert_eq!(watched(&replacement, "/alias-b"), 1);
assert!(recursor.replay_queue.is_empty());
assert!(recursor.replay_desired.is_empty());
assert!(!recursor.logical.contains_key(Path::new("/alias-a")));
assert!(recursor.logical.contains_key(Path::new("/alias-b")));
assert_eq!(
recursor
.physical
.get(&Identity::Canonical("/real".into()))
.unwrap()
.watch_path,
PathBuf::from("/alias-b")
);
}
#[test]
fn replay_resource_latch_stops_later_calls_and_retains_pending() {
let (mut recursor, _backend, scanner) = fixture();
for path in ["/a", "/b"] {
directory(&scanner, path);
}
recursor.reconcile(
&[WatchedPath::recursive("/a"), WatchedPath::recursive("/b")],
filter([]),
);
drain(&mut recursor);
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
replacement.lock().unwrap().resource_failure = Some("/a".into());
recursor.prepare_backend_rebuild();
recursor.install_backend(Box::new(FakeBackend(replacement.clone())));
let replay = recursor.replay_backend_snapshot();
assert_eq!(replay.errors.len(), 1);
assert!(!replay.rebuild_backend);
assert_eq!(watched(&replacement, "/a"), 1);
assert_eq!(watched(&replacement, "/b"), 0);
assert_eq!(recursor.replay_desired.len(), 2);
assert!(recursor.replay_queue.is_empty());
assert!(recursor.needs_retry());
}
#[test]
fn managed_rescan_rebuilds_and_replays_backend() {
let (mut recursor, _backend, scanner) = fixture();
directory(&scanner, "/root");
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
recursor.rescan();
assert!(run_until_rebuild(&mut recursor).is_empty());
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement.clone())));
drain(&mut recursor);
assert_eq!(watched(&replacement, "/root"), 1);
assert!(recursor.logical.contains_key(Path::new("/root")));
}
#[test]
fn canonical_identity_change_rebuilds_before_scanning_new_tree() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
scanner
.lock()
.unwrap()
.directories
.insert("/root/link".into(), "/identity/one".into());
entries(&scanner, "/root", &["/root/link"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.directories
.insert("/root/link".into(), "/identity/two".into());
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
assert!(run_until_rebuild(&mut recursor).is_empty());
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement)));
drain(&mut recursor);
assert_eq!(
recursor
.logical
.get(Path::new("/root/link"))
.unwrap()
.identity,
Identity::Canonical("/identity/two".into())
);
assert_eq!(watched(&backend, "/root/link"), 1);
}
#[test]
fn paired_rename_invalidates_existing_destination_before_add() {
let (mut recursor, backend, scanner) = fixture();
for path in [
"/root",
"/root/from",
"/root/to",
"/root/to/old",
"/root/to/new",
] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/from", "/root/to"]);
entries(&scanner, "/root/to", &["/root/to/old"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
entries(&scanner, "/root/to", &["/root/to/new"]);
recursor.topology_rename("/root/from".into(), "/root/to".into());
drain(&mut recursor);
assert_eq!(unwatched(&backend, "/root/to/old"), 1);
assert_eq!(unwatched(&backend, "/root/to"), 1);
assert_eq!(watched(&backend, "/root/to"), 2);
assert_eq!(watched(&backend, "/root/to/new"), 1);
assert!(!recursor.logical.contains_key(Path::new("/root/from")));
}
#[test]
fn ambiguous_directory_to_file_removes_stale_subtree() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/root", "/root/item", "/root/item/child"] {
directory(&scanner, path);
}
entries(&scanner, "/root", &["/root/item"]);
entries(&scanner, "/root/item", &["/root/item/child"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.directories
.remove(Path::new("/root/item"));
recursor.topology_ambiguous("/root/item".into());
drain(&mut recursor);
assert!(!recursor.logical.contains_key(Path::new("/root/item")));
assert!(!recursor.logical.contains_key(Path::new("/root/item/child")));
assert_eq!(unwatched(&backend, "/root/item/child"), 1);
assert_eq!(unwatched(&backend, "/root/item"), 1);
}
#[test]
fn ambiguous_same_identity_directory_is_reregistered() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/item", "/item/child"] {
directory(&scanner, path);
}
entries(&scanner, "/item", &["/item/child"]);
recursor.reconcile(&[WatchedPath::recursive("/item")], filter([]));
drain(&mut recursor);
recursor.topology_ambiguous("/item".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/item"), 2);
assert_eq!(watched(&backend, "/item/child"), 2);
}
#[test]
fn same_path_root_replacement_installs_new_identity() {
let (mut recursor, backend, scanner) = fixture();
for path in ["/item", "/item/child"] {
directory(&scanner, path);
}
entries(&scanner, "/item", &["/item/child"]);
recursor.reconcile(&[WatchedPath::recursive("/item")], filter([]));
drain(&mut recursor);
scanner
.lock()
.unwrap()
.directories
.remove(Path::new("/item"));
recursor.topology_ambiguous("/item".into());
drain(&mut recursor);
assert_eq!(watched(&backend, "/item"), 2);
assert!(!recursor.logical.contains_key(Path::new("/item/child")));
assert_eq!(
recursor.logical.get(Path::new("/item")).unwrap().identity,
Identity::Lexical("/item".into())
);
}
#[test]
fn removing_representative_alias_replays_surviving_alias() {
let (mut recursor, backend, scanner) = fixture();
scanner.lock().unwrap().directories.extend([
(PathBuf::from("/alias1"), PathBuf::from("/real")),
(PathBuf::from("/alias2"), PathBuf::from("/real")),
]);
recursor.reconcile(
&[
WatchedPath::recursive("/alias1"),
WatchedPath::recursive("/alias2"),
],
filter([]),
);
drain(&mut recursor);
assert_eq!(watched(&backend, "/alias1"), 1);
assert_eq!(watched(&backend, "/alias2"), 0);
recursor.reconcile(&[WatchedPath::recursive("/alias2")], filter([]));
assert!(run_until_rebuild(&mut recursor).is_empty());
let replacement = Arc::new(Mutex::new(FakeBackendState::default()));
recursor.replace_backend(Box::new(FakeBackend(replacement.clone())));
drain(&mut recursor);
assert_eq!(watched(&replacement, "/alias2"), 1);
scanner
.lock()
.unwrap()
.directories
.insert("/alias2/new".into(), "/real/new".into());
recursor.topology_create("/alias2/new".into());
drain(&mut recursor);
assert_eq!(watched(&replacement, "/alias2/new"), 1);
}
#[test]
fn canonical_identity_breaks_basic_symlink_cycle() {
let (mut recursor, backend, scanner) = fixture();
directory(&scanner, "/root");
scanner
.lock()
.unwrap()
.directories
.insert("/root/link".into(), "/root".into());
entries(&scanner, "/root", &["/root/link"]);
entries(&scanner, "/root/link", &["/root/link"]);
recursor.reconcile(&[WatchedPath::recursive("/root")], filter([]));
drain(&mut recursor);
assert_eq!(watched(&backend, "/root"), 1);
assert_eq!(watched(&backend, "/root/link"), 0);
assert!(!scanner
.lock()
.unwrap()
.operations
.contains(&Operation::Scan("/root/link".into())));
}
}