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: ¬ify::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: ¬ify::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}