Files
dev/third_party/pi/packages/env/daemon/src/watch.rs
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

779 lines
27 KiB
Rust

//! `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<String>,
}
#[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<u64>,
}
type Snapshot = HashMap<String, Entry>;
#[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<u64>) -> 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<String> {
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<String>,
/// Directories counted against the limit, and directories traversed per target.
counted: HashSet<String>,
traversed: HashSet<(usize, String)>,
/// Kinds of listed entries without following links.
listed: HashMap<String, Kind>,
}
struct Installed {
dev: u64,
ino: u64,
/// The watched path with links resolved, as FSEvents spells event paths (`/private/var` for `/var`).
canonical: Option<String>,
}
struct FileWatcher<'a> {
id: u32,
output: &'a Output,
control: &'a Control,
targets: Vec<Target>,
mode: Mode,
poll_interval: Duration,
max_directories: usize,
snapshot: Snapshot,
native: Option<RecommendedWatcher>,
events_sender: Sender<notify::Result<Event>>,
installed: HashMap<String, Installed>,
events: HashSet<String>,
settle_at: Option<Instant>,
}
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<Scan, ScanError> {
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<String> {
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<HashSet<String>, 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<String>) -> bool {
let per_directory = cfg!(any(target_os = "linux", target_os = "android"));
let mut wanted: HashMap<String, bool> = 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<String> = 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<Event>) -> 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<String> = 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<Vec<Target>, 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<Value, Failure> {
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<Instant> = 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<Instant>| 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;
}
}
}
}