Skip to main content

blit_fssync/
backend.rs

1//! Native watch backend via the `notify` crate (inotify on Linux, FSEvents
2//! on macOS, `ReadDirectoryChangesW` on Windows), demoted to a dirty-set
3//! hint source: every event becomes `Hint::Dirty(path)` and every
4//! loss signal — overflow, rescan flag, backend error — degrades to
5//! `Hint::Rescan`. No backend behavior is client-visible; the engine
6//! verifies everything against the filesystem before emitting.
7
8use crate::{BackendHandle, Hint, HintSender};
9use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13/// Keeps the native watch alive; dropping it unwatches.
14pub struct WatchBackend {
15    /// Shared with the reconciler, which registers and retires
16    /// directories through it for the root's whole lifetime.
17    pub watches: Arc<Watches>,
18}
19
20/// Whether per-directory arming is worth it for a filtered root.
21///
22/// Only on inotify, where a recursive watch is really one descriptor per
23/// directory and skipping `node_modules` is the whole point. FSEvents
24/// covers a tree with a single stream and `ReadDirectoryChangesW` with a
25/// single handle, so there per-directory arming would cost *more* objects,
26/// not fewer — and an unfiltered root keeps the recursive watch on every
27/// platform, so nothing changes for a sync that excludes nothing.
28pub fn per_dir_watching_pays(recursive: bool, single: bool, filtered: bool) -> bool {
29    cfg!(target_os = "linux") && recursive && !single && filtered
30}
31
32/// The native watches a root holds, and the reconciler's handle on them.
33///
34/// Two kinds, tracked apart because they are retired by different rules:
35///
36/// - **tree** directories, armed one at a time when `per_dir` is set. A
37///   recursive inotify watch is a descriptor per directory whether or not
38///   the sync mirrors it, so a filtered sync of a checkout would still pay
39///   for `node_modules` and `target` — the cost the exclusion exists to
40///   avoid, and on a big tree the one that hits
41///   `fs.inotify.max_user_watches`. Arming per directory puts the
42///   reconciler in charge: it arms exactly what it indexes, in the order it
43///   indexes it, and never reaches the excluded subtrees. The
44///   arm-before-list contract holds one level down — a directory is armed
45///   before it is read — and these are disarmed as the index drops them.
46/// - **outside** directories, holding ignore sources above the root. No
47///   hint from inside the tree could ever report those, so without them a
48///   parent `.gitignore` edit is invisible for the life of the sync. They
49///   are armed once and never retired, since nothing in the index tracks
50///   them.
51pub struct Watches {
52    watcher: Mutex<RecommendedWatcher>,
53    /// Whether the tree is watched a directory at a time. When false, one
54    /// recursive watch on the root already covers it and the tree-side
55    /// calls are no-ops.
56    per_dir: bool,
57    /// Tree directories currently armed, so re-arming is a set lookup
58    /// rather than a syscall and teardown knows what to unwatch.
59    ///
60    /// Ordered, because disarming is always a *subtree*: `Path`'s
61    /// component-wise ordering puts a directory's descendants immediately
62    /// after it and before any sibling, so a range query costs the size of
63    /// the subtree rather than the size of the tree. With a hash set,
64    /// deleting one directory scanned every armed path — and `rm -rf` on a
65    /// deep tree paid that per directory it removed.
66    armed: Mutex<std::collections::BTreeSet<PathBuf>>,
67    /// Directories outside the tree, exempt from every retirement rule.
68    outside: Mutex<std::collections::BTreeSet<PathBuf>>,
69}
70
71impl Watches {
72    /// Arm a tree directory, non-recursively. `false` only when the
73    /// process is out of watch descriptors — the caller closes the root,
74    /// because a mirror with an unwatched directory in it is silently
75    /// stale. Every other failure (the directory vanished mid-scan,
76    /// permission) returns `true`: there is nothing to watch, and nothing
77    /// to mirror either.
78    pub fn add_dir(&self, dir: &Path) -> bool {
79        if !self.per_dir {
80            return true; // the recursive watch on the root covers it
81        }
82        if self.armed.lock().unwrap().contains(dir) {
83            return true;
84        }
85        match self.arm(dir) {
86            Ok(()) => {
87                self.armed.lock().unwrap().insert(dir.to_path_buf());
88                true
89            }
90            Err(e) => !is_watch_exhaustion(&e),
91        }
92    }
93
94    /// Arm a directory outside the tree because it holds an ignore source.
95    /// Failure is not fatal the way a tree directory's is: the rules were
96    /// already read, and the only loss is noticing a later edit — the
97    /// behavior every sync had before these were watched at all.
98    pub fn watch_outside(&self, dir: &Path) {
99        if self.outside.lock().unwrap().contains(dir) {
100            return;
101        }
102        if self.arm(dir).is_ok() {
103            self.outside.lock().unwrap().insert(dir.to_path_buf());
104        }
105    }
106
107    /// Whether the tree is watched a directory at a time rather than by
108    /// one recursive watch on the root.
109    pub fn is_per_dir(&self) -> bool {
110        self.per_dir
111    }
112
113    fn arm(&self, dir: &Path) -> notify::Result<()> {
114        self.watcher
115            .lock()
116            .unwrap()
117            .watch(dir, RecursiveMode::NonRecursive)
118    }
119
120    /// Disarm `dir` and everything under it — a deleted or newly excluded
121    /// subtree. inotify drops the kernel watch on deletion by itself; this
122    /// is what keeps the bookkeeping (and notify's own map) from growing
123    /// across a create/delete cycle.
124    pub fn remove_dir(&self, dir: &Path) {
125        let gone: Vec<PathBuf> = {
126            let armed = self.armed.lock().unwrap();
127            armed
128                .range(dir.to_path_buf()..)
129                .take_while(|p| p.starts_with(dir))
130                .cloned()
131                .collect()
132        };
133        self.drop_watches(gone);
134    }
135
136    /// Drop every armed tree directory `keep` rejects. Used after a full
137    /// rescan, which replaces the index wholesale and so cannot report
138    /// individual removals — the one place a whole-set pass is the right
139    /// shape, since every entry has to be reconsidered anyway.
140    pub fn retain_dirs(&self, keep: &dyn Fn(&Path) -> bool) {
141        let gone: Vec<PathBuf> = {
142            let armed = self.armed.lock().unwrap();
143            armed.iter().filter(|p| !keep(p)).cloned().collect()
144        };
145        self.drop_watches(gone);
146    }
147
148    fn drop_watches(&self, gone: Vec<PathBuf>) {
149        if gone.is_empty() {
150            return;
151        }
152        let mut watcher = self.watcher.lock().unwrap();
153        let mut armed = self.armed.lock().unwrap();
154        for dir in gone {
155            // Already gone from the kernel's side once the directory was
156            // deleted; unwatch is how notify forgets it too.
157            let _ = watcher.unwatch(&dir);
158            armed.remove(&dir);
159        }
160    }
161}
162
163impl BackendHandle for Arc<Watches> {
164    fn add_dir(&self, dir: &Path) -> bool {
165        Watches::add_dir(self, dir)
166    }
167    fn watch_outside(&self, dir: &Path) {
168        Watches::watch_outside(self, dir);
169    }
170    fn remove_dir(&self, dir: &Path) {
171        Watches::remove_dir(self, dir);
172    }
173    fn retain_dirs(&self, keep: &dyn Fn(&Path) -> bool) {
174        Watches::retain_dirs(self, keep);
175    }
176}
177
178/// Whether arming failed because the process is out of watch descriptors,
179/// as opposed to the path being gone or unreadable. `ENOSPC` is what
180/// `inotify_add_watch` returns at `max_user_watches`.
181fn is_watch_exhaustion(err: &notify::Error) -> bool {
182    match &err.kind {
183        notify::ErrorKind::MaxFilesWatch => true,
184        notify::ErrorKind::Io(e) => matches!(e.raw_os_error(), Some(23) | Some(24) | Some(28)),
185        _ => false,
186    }
187}
188
189/// Whether an event reports only that something was *read*.
190///
191/// notify's inotify mask includes `IN_OPEN` (notify 8.2 `src/inotify.rs`),
192/// so on Linux every open of a watched file arrives as an event. Any
193/// watcher that reads inside its own watched tree — this engine hashing a
194/// file, the git engine opening `.gitignore` and `HEAD` to recompute
195/// status, an LSP backend reading a document — then retriggers itself, and
196/// the settle window becomes a spin loop rather than a debounce. macOS and
197/// Windows have no notion of a read event, so dropping these costs nothing
198/// and is not a platform-specific behavior difference: it removes one.
199///
200/// Only the unambiguous reads go. `Close(Write)` is inotify's
201/// `IN_CLOSE_WRITE` — the end of a *writing* session — and an unspecified
202/// `Access(Any)` could be either, so both stay: an extra pass is always
203/// cheaper than a lost change.
204pub fn is_read_only_event(kind: &notify::EventKind) -> bool {
205    use notify::event::{AccessKind, AccessMode};
206    matches!(
207        kind,
208        notify::EventKind::Access(
209            AccessKind::Read | AccessKind::Open(_) | AccessKind::Close(AccessMode::Read)
210        )
211    )
212}
213
214/// Build the platform watcher every blit watch goes through.
215///
216/// Identical to `notify::recommended_watcher` but for one setting:
217/// `Config::default()` turns symlink following *on*, and notify's inotify
218/// backend re-`WalkDir`s a subtree on every `IN_CREATE`/`IN_MOVED_TO` that
219/// carries `ISDIR` (notify 8.2 `src/inotify.rs`). A recursive watch on a
220/// worktree that contains a pnpm `node_modules` — where every package is a
221/// symlink into `.pnpm/`, so the same real directories are reachable under
222/// many paths — or a `.direnv` linking into the nix store therefore walks a
223/// tree several times its real size, and re-walks it per directory created
224/// anywhere inside. Measured on this repo: 9.7k real directories, 92k when
225/// following, and four such event loops pinned four cores indefinitely.
226///
227/// Cost is not the whole argument, because not following also changes *what*
228/// is covered. A recursive sync enumerates a symlinked directory under the
229/// link's own path (`docs/design/fs-watch.md` § Links), and notify's walk
230/// yields a symlink as a symlink when it is not following, so `filter_dir`
231/// drops it and no descriptor covers those aliased paths: an edit under one
232/// is hinted at the target's real path — which the sync sees only when that
233/// target is itself inside the root — and never at the alias.
234///
235/// Following did not reliably cover them either. `inotify_add_watch` returns
236/// the *same* descriptor for an inode already watched, and notify keys its
237/// descriptor→path map on that descriptor, so arming both a pnpm alias and
238/// its real path left whichever the walk reached last reporting for both, and
239/// unwatching either dropped both. The choice is therefore between one stable
240/// rule and an arming-order lottery that could strand the real path, not
241/// between coverage and none. The status engine never reported on the aliases
242/// at all: it asks git, which follows the index.
243pub fn watcher<F: notify::EventHandler>(handler: F) -> notify::Result<RecommendedWatcher> {
244    RecommendedWatcher::new(handler, Config::default().with_follow_symlinks(false))
245}
246
247/// Arm a native watch on `root` feeding `hints`. Must be called *before*
248/// the engine's initial enumeration so nothing slips between scan and arm.
249/// With `per_dir` (see [`per_dir_watching_pays`]) only `root` is armed here
250/// and the reconciler arms the rest as it enumerates, so excluded subtrees
251/// never cost a descriptor.
252pub fn watch(
253    root: &Path,
254    recursive: bool,
255    per_dir: bool,
256    hints: HintSender,
257) -> notify::Result<WatchBackend> {
258    let mut backend = watcher(move |res: notify::Result<notify::Event>| match res {
259        Ok(event) => {
260            if event.need_rescan() {
261                hints.send(Hint::Rescan);
262                return;
263            }
264            if is_read_only_event(&event.kind) {
265                return;
266            }
267            for path in event.paths {
268                hints.send(Hint::Dirty(path));
269            }
270        }
271        Err(_) => {
272            hints.send(Hint::Rescan);
273        }
274    })?;
275    let mode = if recursive && !per_dir {
276        RecursiveMode::Recursive
277    } else {
278        RecursiveMode::NonRecursive
279    };
280    backend.watch(root, mode)?;
281    Ok(WatchBackend {
282        watches: Arc::new(Watches {
283            watcher: Mutex::new(backend),
284            per_dir,
285            armed: Mutex::new(std::collections::BTreeSet::from([root.to_path_buf()])),
286            outside: Mutex::new(Default::default()),
287        }),
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use notify::EventKind;
295    use notify::event::{AccessKind, AccessMode, CreateKind, ModifyKind, RemoveKind};
296
297    /// The read/write split the whole watch layer depends on. Getting a
298    /// write wrong loses updates; getting a read wrong turns every settle
299    /// window into a spin loop, since watchers read inside their own tree.
300    #[test]
301    fn reads_are_filtered_and_writes_are_not() {
302        for kind in [
303            EventKind::Access(AccessKind::Read),
304            EventKind::Access(AccessKind::Open(AccessMode::Any)),
305            EventKind::Access(AccessKind::Open(AccessMode::Read)),
306            EventKind::Access(AccessKind::Open(AccessMode::Write)),
307            EventKind::Access(AccessKind::Close(AccessMode::Read)),
308        ] {
309            assert!(is_read_only_event(&kind), "{kind:?} reports a read");
310        }
311        for kind in [
312            // IN_CLOSE_WRITE: a writing session just ended.
313            EventKind::Access(AccessKind::Close(AccessMode::Write)),
314            // Unspecified: ambiguous, so it costs a pass rather than
315            // risking a lost change.
316            EventKind::Access(AccessKind::Any),
317            EventKind::Access(AccessKind::Other),
318            EventKind::Create(CreateKind::File),
319            EventKind::Modify(ModifyKind::Any),
320            EventKind::Remove(RemoveKind::File),
321            EventKind::Any,
322            EventKind::Other,
323        ] {
324            assert!(!is_read_only_event(&kind), "{kind:?} may report a change");
325        }
326    }
327
328    /// Disarming a subtree is a range query, which is only correct
329    /// because `Path` orders component-wise: `a/b`'s descendants sort
330    /// immediately after it and before `a/b-x`, a sibling that shares its
331    /// string prefix. Byte-wise ordering would put `a/b-x` *between* them
332    /// and the range would stop early, stranding watches.
333    #[test]
334    fn disarming_a_subtree_takes_the_subtree_and_stops_at_a_sibling() {
335        let dir = std::env::temp_dir().join(format!("blit-disarm-{}", std::process::id()));
336        let _ = std::fs::remove_dir_all(&dir);
337        for sub in ["a/b/c", "a/b-x", "a/bb"] {
338            std::fs::create_dir_all(dir.join(sub)).unwrap();
339        }
340        let (tx, _rx) = std::sync::mpsc::channel();
341        let watch = watch(&dir, true, true, HintSender { tx }).unwrap().watches;
342        for sub in ["a", "a/b", "a/b/c", "a/b-x", "a/bb"] {
343            assert!(watch.add_dir(&dir.join(sub)));
344        }
345        watch.remove_dir(&dir.join("a/b"));
346        let armed: Vec<PathBuf> = watch.armed.lock().unwrap().iter().cloned().collect();
347        let rel: Vec<&str> = armed
348            .iter()
349            .filter_map(|p| p.strip_prefix(&dir).ok())
350            .filter_map(|p| p.to_str())
351            .collect();
352        let _ = std::fs::remove_dir_all(&dir);
353        assert_eq!(rel, ["", "a", "a/b-x", "a/bb"], "the root itself stays too");
354    }
355
356    /// A watched tree reachable under two names — `real/` and a symlink to it —
357    /// must report changes under the *real* one.
358    ///
359    /// This is the property [`watcher`] buys, and it is a positive assertion
360    /// rather than a wait on a negative: `inotify_add_watch` hands back the
361    /// same descriptor for an inode already watched, and notify keys its
362    /// descriptor→path map on that descriptor, so with following on, arming
363    /// the link overwrote the mapping for the real directory and a write to
364    /// `real/inner/x` was delivered as `link/inner/x`. The real path — the one
365    /// git reports and every non-aliased sync entry lives under — then got no
366    /// hint at all. Reverting to `Config::default()` here fails this test with
367    /// exactly that swap, whenever the walk reaches the link second.
368    #[cfg(target_os = "linux")]
369    #[test]
370    fn changes_are_reported_under_the_real_path_not_a_symlinked_alias() {
371        use crate::{Hint, RootMsg};
372        use std::sync::mpsc;
373        use std::time::{Duration, Instant};
374
375        let dir = std::env::temp_dir().join(format!("blit-watch-alias-{}", std::process::id()));
376        let _ = std::fs::remove_dir_all(&dir);
377        std::fs::create_dir_all(dir.join("real/inner")).unwrap();
378        std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
379        let dir = dir.canonicalize().unwrap();
380
381        let (tx, rx) = mpsc::channel();
382        // `watch` arms synchronously, so nothing can slip in before the write.
383        let _backend = watch(&dir, true, false, HintSender { tx }).unwrap();
384        std::fs::write(dir.join("real/inner/w.txt"), b"x").unwrap();
385
386        let deadline = Instant::now() + Duration::from_secs(10);
387        let mut seen = Vec::new();
388        let hit = loop {
389            let left = deadline.saturating_duration_since(Instant::now());
390            if left.is_zero() {
391                break None;
392            }
393            match rx.recv_timeout(left) {
394                Ok(RootMsg::Hint(Hint::Dirty(p))) if p.ends_with("real/inner/w.txt") => {
395                    break Some(p);
396                }
397                Ok(RootMsg::Hint(hint)) => seen.push(format!("{hint:?}")),
398                Ok(_) => {}
399                Err(_) => break None,
400            }
401        };
402        let _ = std::fs::remove_dir_all(&dir);
403        assert!(
404            hit.is_some(),
405            "no hint under real/inner/; got {seen:?} — an alias reported instead means \
406             the watch is following symlinks again"
407        );
408    }
409}