//! `watch`: Durable's `NodeFileWatcher` (packages/durable/src/env/node-watch.ts) running next to the files, so a scan //! costs no round trips. Native events only trigger a debounced rescan; changes are the difference between snapshots //! plus the event paths. Events go out as `change` events of the request, which lasts until it is cancelled. use crate::errors::Failure; use crate::exec::Control; use crate::frame::{EVENT, Frame}; use crate::output::Output; use crate::sys; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; use std::fs::{self, Metadata}; use std::hash::Hasher; use std::io; use std::path::Path; use std::sync::atomic::Ordering; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::time::{Duration, Instant, SystemTime}; const DEBOUNCE: Duration = Duration::from_millis(50); /// macOS: FSEvents streams go live after `watch` returns; a rescan this long after installing watchers catches changes /// made in between. const FSEVENTS_SETTLE: Duration = Duration::from_millis(500); const DEFAULT_POLL_MS: u64 = 2000; const DEFAULT_MAX_DIRECTORIES: usize = 10_000; /// In polling mode, recently modified small files are also compared by content. const HASH_MAX_BYTES: u64 = 256 * 1024; const HASH_RECENT: Duration = Duration::from_secs(5); /// How often a waiting watcher looks for a cancel. const CANCEL_POLL: Duration = Duration::from_millis(100); /// Linux `statfs` magic numbers of file systems that accept watches but do not report changes made elsewhere. #[cfg(any(target_os = "linux", target_os = "android"))] const UNRELIABLE_FILE_SYSTEMS: [u64; 12] = [ 0x6969, // NFS 0x517b, // SMB 0xff534d42, // CIFS 0xfe534d42, // SMB2 0x65735546, // FUSE (sshfs, Android shared storage) 0x01021997, // 9P (WSL2 Windows drives) 0x0bd00bd0, // Lustre 0x47504653, // GPFS 0x00c36400, // Ceph 0x5346414f, // OpenAFS 0x6b414653, // kAFS 0x5dca2df5, // sdcardfs ]; struct Target { path: String, recursive: bool, hidden: bool, names: HashSet, } #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { File, Directory, Symlink, Other, } /// What a snapshot remembers of one path. Directories and ancestors are compared by identity only. #[derive(Clone, PartialEq, Eq)] struct Entry { kind: Kind, dev: u64, ino: u64, size: u64, mtime: (i64, i64), hash: Option, } type Snapshot = HashMap; #[derive(Clone, Copy, PartialEq, Eq)] enum Mode { Native, Polling, } impl Mode { fn name(self) -> &'static str { match self { Mode::Native => "native", Mode::Polling => "polling", } } } enum ScanError { Budget(usize), Io(io::Error, &'static str, String), } impl ScanError { /// The error `NodeExecutionEnv.watch` returns when opening fails. fn failure(&self) -> Failure { match self { ScanError::Budget(max) => { Failure::new("EINVAL", format!("Watched paths exceed {max} directories")) } ScanError::Io(error, syscall, path) => Failure::io(error, syscall, path), } } /// The `FileError` code `NodeFileWatcher` reports when a later rescan fails. fn code(&self) -> &'static str { match self { ScanError::Io(error, ..) if denied(error) => "permission_denied", _ => "invalid", } } } fn denied(error: &io::Error) -> bool { error.kind() == io::ErrorKind::PermissionDenied } fn kind_of(metadata: &Metadata) -> Kind { let file_type = metadata.file_type(); if file_type.is_file() { Kind::File } else if file_type.is_dir() { Kind::Directory } else if file_type.is_symlink() { Kind::Symlink } else { Kind::Other } } /// What a snapshot remembers of `path`, whose `metadata` came from `stat` (`follow`) or `lstat`. fn entry_of(path: &str, metadata: &Metadata, follow: bool, hash: Option) -> Entry { let kind = kind_of(metadata); let (dev, ino) = sys::path_identity(path, metadata, follow); let directory = kind == Kind::Directory; Entry { kind, dev, ino, size: if directory { 0 } else { metadata.len() }, mtime: if directory { (0, 0) } else { sys::mtime(metadata) }, hash, } } /// Ancestors of `path`, nearest first, up to the root. fn ancestors(path: &str) -> Vec { Path::new(path) .ancestors() .skip(1) .map(|ancestor| ancestor.to_string_lossy().into_owned()) .collect() } fn with_separator(directory: &str) -> String { if directory.ends_with(sys::PATH_SEPARATOR) { directory.to_string() } else { format!("{directory}{}", sys::PATH_SEPARATOR) } } /// Whether `path` is `ancestor` or below it. fn is_within(path: &str, ancestor: &str) -> bool { path == ancestor || path.starts_with(&with_separator(ancestor)) } fn excluded(target: &Target, name: &str) -> bool { (target.hidden && name.starts_with('.')) || target.names.contains(name) } /// JavaScript's default sort: by UTF-16 code units. fn sort_like_javascript(paths: &mut [String]) { paths.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); } /// Whether any path, or its nearest existing ancestor, is on a file system that does not report remote changes. #[cfg(any(target_os = "linux", target_os = "android"))] fn any_unreliable(targets: &[Target]) -> bool { use std::ffi::CString; for target in targets { for candidate in std::iter::once(target.path.clone()).chain(ancestors(&target.path)) { let Ok(name) = CString::new(candidate) else { break; }; // SAFETY: a NUL-terminated path and a zeroed statfs buffer for the call to fill. let mut info: libc::statfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statfs(name.as_ptr(), &mut info) } != 0 { continue; } #[allow(clippy::unnecessary_cast)] let kind = (info.f_type as u64) & 0xffff_ffff; if UNRELIABLE_FILE_SYSTEMS.contains(&kind) { return true; } break; } } false } #[cfg(not(any(target_os = "linux", target_os = "android")))] fn any_unreliable(_targets: &[Target]) -> bool { false } /// One scan: the snapshot, and targets that are symbolic links to files, whose files need their own watchers. struct Scan { snapshot: Snapshot, linked_files: HashSet, /// Directories counted against the limit, and directories traversed per target. counted: HashSet, traversed: HashSet<(usize, String)>, /// Kinds of listed entries without following links. listed: HashMap, } struct Installed { dev: u64, ino: u64, /// The watched path with links resolved, as FSEvents spells event paths (`/private/var` for `/var`). canonical: Option, } struct FileWatcher<'a> { id: u32, output: &'a Output, control: &'a Control, targets: Vec, mode: Mode, poll_interval: Duration, max_directories: usize, snapshot: Snapshot, native: Option, events_sender: Sender>, installed: HashMap, events: HashSet, settle_at: Option, } impl FileWatcher<'_> { fn send(&self, json: Value) { self.output.control(Frame::new(EVENT, self.id, json)); } fn cancelled(&self) -> bool { self.control.aborted.load(Ordering::SeqCst) } fn record(&self, scan: &mut Scan, path: &str, metadata: &Metadata, follow: bool) { let mut hash = None; let recent = metadata .modified() .map(|modified| { SystemTime::now() .duration_since(modified) .map_or(true, |age| age < HASH_RECENT) }) .unwrap_or(false); if self.mode == Mode::Polling && metadata.is_file() && metadata.len() <= HASH_MAX_BYTES && recent { hash = fs::read(path).ok().map(|content| { let mut hasher = std::collections::hash_map::DefaultHasher::new(); hasher.write(&content); hasher.finish() }); } scan.snapshot .insert(path.to_string(), entry_of(path, metadata, follow, hash)); } fn count(&self, scan: &mut Scan, path: &str) -> Result<(), ScanError> { scan.counted.insert(path.to_string()); if scan.counted.len() > self.max_directories { return Err(ScanError::Budget(self.max_directories)); } Ok(()) } fn scan_directory( &self, scan: &mut Scan, index: usize, directory: &str, ) -> Result<(), ScanError> { let target = &self.targets[index]; if !scan.traversed.insert((index, directory.to_string())) { return Ok(()); } let skip = |error: io::Error| -> Result<(), ScanError> { // The watched directory itself must be readable; below it, unreadable directories are skipped. if directory == target.path && denied(&error) { return Err(ScanError::Io(error, "scandir", directory.to_string())); } match error.kind() { io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied | io::ErrorKind::NotADirectory => Ok(()), _ => Err(ScanError::Io(error, "scandir", directory.to_string())), } }; let mut names = Vec::new(); match fs::read_dir(directory) { Err(error) => return skip(error), Ok(entries) => { for entry in entries { match entry { Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()), Err(error) => return skip(error), } } } } let prefix = with_separator(directory); for name in names { if excluded(target, &name) { continue; } let path = format!("{prefix}{name}"); let kind = match scan.listed.get(&path) { Some(kind) => *kind, None => { let Ok(metadata) = fs::symlink_metadata(&path) else { continue; }; let kind = kind_of(&metadata); scan.listed.insert(path.clone(), kind); // A target's own entry (following links) wins over its listing by another target. if !scan.snapshot.contains_key(&path) { self.record(scan, &path, &metadata, false); } kind } }; if target.recursive && kind == Kind::Directory { self.count(scan, &path)?; self.scan_directory(scan, index, &path)?; } } Ok(()) } fn scan(&self) -> Result { let mut scan = Scan { snapshot: Snapshot::new(), linked_files: HashSet::new(), counted: HashSet::new(), traversed: HashSet::new(), listed: HashMap::new(), }; for (index, target) in self.targets.iter().enumerate() { for ancestor in ancestors(&target.path) { if scan.snapshot.contains_key(&ancestor) { continue; } // Identity only: an ancestor's own timestamps change with every unrelated sibling. if let Ok(metadata) = fs::symlink_metadata(&ancestor) { let entry = Entry { size: 0, mtime: (0, 0), ..entry_of(&ancestor, &metadata, false, None) }; scan.snapshot.insert(ancestor, entry); } } // The target may be a symbolic link to what is watched; follow it. A missing target is watched for its // creation; one that cannot be reached for lack of permission fails. let metadata = match fs::metadata(&target.path) { Ok(metadata) => metadata, Err(error) if denied(&error) => { return Err(ScanError::Io(error, "stat", target.path.clone())); } Err(_) => continue, }; self.record(&mut scan, &target.path, &metadata, true); if metadata.is_file() && fs::symlink_metadata(&target.path) .is_ok_and(|link| link.file_type().is_symlink()) { scan.linked_files.insert(target.path.clone()); } if metadata.is_dir() { self.count(&mut scan, &target.path)?; self.scan_directory(&mut scan, index, &target.path)?; } } Ok(scan) } /// An ancestor that changed identity moved every target below it; report those targets. fn reported(&self, path: &str) -> String { if self .targets .iter() .any(|target| is_within(path, &target.path)) { return path.to_string(); } self.targets .iter() .find(|target| is_within(&target.path, path)) .map_or_else(|| path.to_string(), |target| target.path.clone()) } fn diff(&self, previous: &Snapshot, next: &Snapshot) -> Vec { let mut changed = Vec::new(); for (path, entry) in next { if previous.get(path) != Some(entry) { changed.push(self.reported(path)); } } for path in previous.keys() { if !next.contains_key(path) { changed.push(self.reported(path)); } } changed } /// Rescan, report differences, and install watchers for new directories, rescanning until none are new. fn sync(&mut self, mut report: bool) -> Result, ScanError> { let mut changed = HashSet::new(); for _ in 0..10 { if self.cancelled() { break; } let scan = self.scan()?; if report { changed.extend(self.diff(&self.snapshot, &scan.snapshot)); } self.snapshot = scan.snapshot; if self.mode == Mode::Polling || !self.reconcile(&scan.linked_files) { break; } if cfg!(target_os = "macos") { self.settle_at = Some(Instant::now() + FSEVENTS_SETTLE); } // Something written into a new directory before its watcher existed shows up in the next round. report = true; } Ok(changed) } /// Watch every existing ancestor of each target, each target directory, each target that links to a file, and on /// Linux each directory below a recursive target (elsewhere one recursive watcher per target). Returns whether a /// watcher was added. fn reconcile(&mut self, linked_files: &HashSet) -> bool { let per_directory = cfg!(any(target_os = "linux", target_os = "android")); let mut wanted: HashMap = linked_files .iter() .map(|path| (path.clone(), false)) .collect(); for target in &self.targets { for ancestor in ancestors(&target.path) { if self .snapshot .get(&ancestor) .is_some_and(|entry| entry.kind == Kind::Directory) { wanted.entry(ancestor).or_insert(false); } } if self .snapshot .get(&target.path) .is_none_or(|entry| entry.kind != Kind::Directory) { continue; } wanted.insert(target.path.clone(), target.recursive && !per_directory); if target.recursive && per_directory { for (path, entry) in &self.snapshot { if entry.kind == Kind::Directory && *path != target.path && is_within(path, &target.path) { wanted.insert(path.clone(), false); } } } } // Gone, or replaced: a watcher follows the directory it was installed on, not the path. let stale: Vec = self .installed .iter() .filter(|(path, installed)| { !wanted.contains_key(*path) || self.snapshot.get(*path).is_none_or(|entry| { entry.dev != installed.dev || entry.ino != installed.ino }) }) .map(|(path, _)| path.clone()) .collect(); for path in stale { self.installed.remove(&path); if let Some(native) = &mut self.native { let _ = native.unwatch(Path::new(&path)); } } let mut added = false; for (path, recursive) in wanted { let Some(entry) = self.snapshot.get(&path) else { continue; }; if self.installed.contains_key(&path) { continue; } let (dev, ino) = (entry.dev, entry.ino); if self.native.is_none() { let sender = self.events_sender.clone(); match RecommendedWatcher::new( move |event| drop(sender.send(event)), notify::Config::default(), ) { Ok(native) => self.native = Some(native), Err(_) => { self.switch_to_polling(); return false; } } } let mode = if recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; match self.native.as_mut().unwrap().watch(Path::new(&path), mode) { Ok(()) => { let canonical = fs::canonicalize(&path) .ok() .map(|canonical| canonical.to_string_lossy().into_owned()) .filter(|canonical| *canonical != path); self.installed.insert( path, Installed { dev, ino, canonical, }, ); added = true; } Err(error) => { let gone = match &error.kind { notify::ErrorKind::PathNotFound => true, notify::ErrorKind::Io(io) => { matches!( io.kind(), io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied ) } _ => false, }; // Out of watches or unsupported: compare snapshots from now on, and say coverage was uncertain. if !gone { self.switch_to_polling(); return false; } } } } added } fn switch_to_polling(&mut self) { if self.mode == Mode::Polling { return; } self.mode = Mode::Polling; self.native = None; self.installed.clear(); self.send(json!({ "kind": "change", "overflow": true, "mode": "polling" })); } fn in_scope(&self, path: &str) -> bool { for target in &self.targets { if is_within(&target.path, path) { return true; } if !is_within(path, &target.path) || path == target.path { continue; } let relative = &path[with_separator(&target.path).len()..]; let components: Vec<&str> = relative.split(sys::PATH_SEPARATOR).collect(); if !target.recursive && components.len() > 1 { continue; } if components.iter().any(|name| excluded(target, name)) { continue; } return true; } false } /// An event path in the spelling of the watched path it is under. fn watched_spelling(&self, path: &str) -> String { let mut best: Option<(&str, &str)> = None; for (watched, installed) in &self.installed { if let Some(canonical) = &installed.canonical && is_within(path, canonical) && best.is_none_or(|(_, longest)| canonical.len() > longest.len()) { best = Some((watched, canonical)); } } match best { Some((watched, canonical)) => format!("{watched}{}", &path[canonical.len()..]), None => path.to_string(), } } /// Note a native event; returns whether it calls for a rescan. fn on_event(&mut self, event: notify::Result) -> bool { let event = match event { Ok(event) => event, Err(error) => { // A watched directory that disappears or fails: rescan, which also replaces its watcher. for path in &error.paths { self.installed.remove(&*path.to_string_lossy()); } return true; } }; // Reads are not changes (libuv does not ask for them). if matches!(event.kind, EventKind::Access(_)) { return false; } if event.need_rescan() || event.paths.is_empty() { return true; } let mut flush = false; for path in &event.paths { let path = self.watched_spelling(&path.to_string_lossy()); // Events about unrelated siblings of an ancestor, or about excluded entries, are ignored. if self.in_scope(&path) { let reported = self.reported(&path); self.events.insert(reported); flush = true; } } flush } /// Rescan and report what changed; an error ends the watcher. fn flush(&mut self) -> bool { let events = std::mem::take(&mut self.events); match self.sync(true) { Ok(mut changed) => { changed.extend(events); if !changed.is_empty() && !self.cancelled() { let mut paths: Vec = changed.into_iter().collect(); sort_like_javascript(&mut paths); self.send(json!({ "kind": "change", "paths": paths })); } true } Err(error) => { let message = error.failure().message; self.send(json!({ "kind": "error", "code": error.code(), "message": message })); false } } } } fn parse_targets(json: &Value) -> Result, Failure> { let invalid = || Failure::new("EINVAL", "watch needs targets"); json["targets"] .as_array() .ok_or_else(invalid)? .iter() .map(|target| { Ok(Target { path: target["path"].as_str().ok_or_else(invalid)?.to_string(), recursive: target["recursive"].as_bool().unwrap_or(false), hidden: target["exclude"]["hidden"].as_bool().unwrap_or(false), names: target["exclude"]["names"] .as_array() .map(|names| { names .iter() .filter_map(|name| name.as_str().map(str::to_string)) .collect() }) .unwrap_or_default(), }) }) .collect() } /// Watch until cancelled: a `ready` event once coverage is established, then `change` events, or an `error` event /// after which nothing follows. pub fn run(id: u32, json: &Value, output: &Output, control: &Control) -> Result { let targets = parse_targets(json)?; // Windows refuses to rename a directory while a directory below it is open, and native watchers keep watched // directories open, so Windows polls, as do file systems that do not report remote changes. let mode = match json["mode"].as_str() { Some("native") => Mode::Native, Some("polling") => Mode::Polling, _ if cfg!(windows) || any_unreliable(&targets) => Mode::Polling, _ => Mode::Native, }; let (events_sender, events) = mpsc::channel(); let mut watcher = FileWatcher { id, output, control, targets, mode, poll_interval: Duration::from_millis( json["pollIntervalMs"].as_u64().unwrap_or(DEFAULT_POLL_MS), ), max_directories: json["maxDirectories"] .as_u64() .map_or(DEFAULT_MAX_DIRECTORIES, |max| max as usize), snapshot: Snapshot::new(), native: None, events_sender, installed: HashMap::new(), events: HashSet::new(), settle_at: None, }; watcher.sync(false).map_err(|error| error.failure())?; watcher.send(json!({ "kind": "ready", "mode": watcher.mode.name() })); let mut flush_at: Option = None; let mut poll_at = Instant::now() + watcher.poll_interval; loop { if watcher.cancelled() { return Ok(json!({})); } let now = Instant::now(); let mut wake = now + CANCEL_POLL; for at in [flush_at, watcher.settle_at].into_iter().flatten() { wake = wake.min(at); } if watcher.mode == Mode::Polling { wake = wake.min(poll_at); } match events.recv_timeout(wake.saturating_duration_since(now)) { Ok(event) => { if watcher.on_event(event) && flush_at.is_none() && watcher.mode == Mode::Native { flush_at = Some(Instant::now() + DEBOUNCE); } } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => unreachable!("the watcher holds a sender"), } let now = Instant::now(); let due = |at: Option| at.is_some_and(|at| at <= now); let polling_due = watcher.mode == Mode::Polling && poll_at <= now; if due(flush_at) || due(watcher.settle_at) || polling_due { if due(flush_at) { flush_at = None; } if due(watcher.settle_at) { watcher.settle_at = None; } if !watcher.flush() { return Ok(json!({})); } if polling_due { poll_at = Instant::now() + watcher.poll_interval; } } } }