use crate::{BackendHandle, Hint, HintSender};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub struct WatchBackend {
pub watches: Arc<Watches>,
}
pub fn per_dir_watching_pays(recursive: bool, single: bool, filtered: bool) -> bool {
cfg!(target_os = "linux") && recursive && !single && filtered
}
pub struct Watches {
watcher: Mutex<RecommendedWatcher>,
per_dir: bool,
armed: Mutex<std::collections::BTreeSet<PathBuf>>,
outside: Mutex<std::collections::BTreeSet<PathBuf>>,
}
impl Watches {
pub fn add_dir(&self, dir: &Path) -> bool {
if !self.per_dir {
return true; }
if self.armed.lock().unwrap().contains(dir) {
return true;
}
match self.arm(dir) {
Ok(()) => {
self.armed.lock().unwrap().insert(dir.to_path_buf());
true
}
Err(e) => !is_watch_exhaustion(&e),
}
}
pub fn watch_outside(&self, dir: &Path) {
if self.outside.lock().unwrap().contains(dir) {
return;
}
if self.arm(dir).is_ok() {
self.outside.lock().unwrap().insert(dir.to_path_buf());
}
}
pub fn is_per_dir(&self) -> bool {
self.per_dir
}
fn arm(&self, dir: &Path) -> notify::Result<()> {
self.watcher
.lock()
.unwrap()
.watch(dir, RecursiveMode::NonRecursive)
}
pub fn remove_dir(&self, dir: &Path) {
let gone: Vec<PathBuf> = {
let armed = self.armed.lock().unwrap();
armed
.range(dir.to_path_buf()..)
.take_while(|p| p.starts_with(dir))
.cloned()
.collect()
};
self.drop_watches(gone);
}
pub fn retain_dirs(&self, keep: &dyn Fn(&Path) -> bool) {
let gone: Vec<PathBuf> = {
let armed = self.armed.lock().unwrap();
armed.iter().filter(|p| !keep(p)).cloned().collect()
};
self.drop_watches(gone);
}
fn drop_watches(&self, gone: Vec<PathBuf>) {
if gone.is_empty() {
return;
}
let mut watcher = self.watcher.lock().unwrap();
let mut armed = self.armed.lock().unwrap();
for dir in gone {
let _ = watcher.unwatch(&dir);
armed.remove(&dir);
}
}
}
impl BackendHandle for Arc<Watches> {
fn add_dir(&self, dir: &Path) -> bool {
Watches::add_dir(self, dir)
}
fn watch_outside(&self, dir: &Path) {
Watches::watch_outside(self, dir);
}
fn remove_dir(&self, dir: &Path) {
Watches::remove_dir(self, dir);
}
fn retain_dirs(&self, keep: &dyn Fn(&Path) -> bool) {
Watches::retain_dirs(self, keep);
}
}
fn is_watch_exhaustion(err: ¬ify::Error) -> bool {
match &err.kind {
notify::ErrorKind::MaxFilesWatch => true,
notify::ErrorKind::Io(e) => matches!(e.raw_os_error(), Some(23) | Some(24) | Some(28)),
_ => false,
}
}
pub fn is_read_only_event(kind: ¬ify::EventKind) -> bool {
use notify::event::{AccessKind, AccessMode};
matches!(
kind,
notify::EventKind::Access(
AccessKind::Read | AccessKind::Open(_) | AccessKind::Close(AccessMode::Read)
)
)
}
pub fn watcher<F: notify::EventHandler>(handler: F) -> notify::Result<RecommendedWatcher> {
RecommendedWatcher::new(handler, Config::default().with_follow_symlinks(false))
}
pub fn watch(
root: &Path,
recursive: bool,
per_dir: bool,
hints: HintSender,
) -> notify::Result<WatchBackend> {
let mut backend = watcher(move |res: notify::Result<notify::Event>| match res {
Ok(event) => {
if event.need_rescan() {
hints.send(Hint::Rescan);
return;
}
if is_read_only_event(&event.kind) {
return;
}
for path in event.paths {
hints.send(Hint::Dirty(path));
}
}
Err(_) => {
hints.send(Hint::Rescan);
}
})?;
let mode = if recursive && !per_dir {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
backend.watch(root, mode)?;
Ok(WatchBackend {
watches: Arc::new(Watches {
watcher: Mutex::new(backend),
per_dir,
armed: Mutex::new(std::collections::BTreeSet::from([root.to_path_buf()])),
outside: Mutex::new(Default::default()),
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
use notify::EventKind;
use notify::event::{AccessKind, AccessMode, CreateKind, ModifyKind, RemoveKind};
#[test]
fn reads_are_filtered_and_writes_are_not() {
for kind in [
EventKind::Access(AccessKind::Read),
EventKind::Access(AccessKind::Open(AccessMode::Any)),
EventKind::Access(AccessKind::Open(AccessMode::Read)),
EventKind::Access(AccessKind::Open(AccessMode::Write)),
EventKind::Access(AccessKind::Close(AccessMode::Read)),
] {
assert!(is_read_only_event(&kind), "{kind:?} reports a read");
}
for kind in [
EventKind::Access(AccessKind::Close(AccessMode::Write)),
EventKind::Access(AccessKind::Any),
EventKind::Access(AccessKind::Other),
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Any),
EventKind::Remove(RemoveKind::File),
EventKind::Any,
EventKind::Other,
] {
assert!(!is_read_only_event(&kind), "{kind:?} may report a change");
}
}
#[test]
fn disarming_a_subtree_takes_the_subtree_and_stops_at_a_sibling() {
let dir = std::env::temp_dir().join(format!("blit-disarm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for sub in ["a/b/c", "a/b-x", "a/bb"] {
std::fs::create_dir_all(dir.join(sub)).unwrap();
}
let (tx, _rx) = std::sync::mpsc::channel();
let watch = watch(&dir, true, true, HintSender { tx }).unwrap().watches;
for sub in ["a", "a/b", "a/b/c", "a/b-x", "a/bb"] {
assert!(watch.add_dir(&dir.join(sub)));
}
watch.remove_dir(&dir.join("a/b"));
let armed: Vec<PathBuf> = watch.armed.lock().unwrap().iter().cloned().collect();
let rel: Vec<&str> = armed
.iter()
.filter_map(|p| p.strip_prefix(&dir).ok())
.filter_map(|p| p.to_str())
.collect();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(rel, ["", "a", "a/b-x", "a/bb"], "the root itself stays too");
}
#[cfg(target_os = "linux")]
#[test]
fn changes_are_reported_under_the_real_path_not_a_symlinked_alias() {
use crate::{Hint, RootMsg};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let dir = std::env::temp_dir().join(format!("blit-watch-alias-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("real/inner")).unwrap();
std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
let dir = dir.canonicalize().unwrap();
let (tx, rx) = mpsc::channel();
let _backend = watch(&dir, true, false, HintSender { tx }).unwrap();
std::fs::write(dir.join("real/inner/w.txt"), b"x").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen = Vec::new();
let hit = loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break None;
}
match rx.recv_timeout(left) {
Ok(RootMsg::Hint(Hint::Dirty(p))) if p.ends_with("real/inner/w.txt") => {
break Some(p);
}
Ok(RootMsg::Hint(hint)) => seen.push(format!("{hint:?}")),
Ok(_) => {}
Err(_) => break None,
}
};
let _ = std::fs::remove_dir_all(&dir);
assert!(
hit.is_some(),
"no hint under real/inner/; got {seen:?} — an alias reported instead means \
the watch is following symlinks again"
);
}
}