Skip to main content

termesh_filesystem/
watch.rs

1//! File watching, and the coalescing policy that makes it usable (ADR-0005 §5).
2//!
3//! `notify` reports raw OS events, and a single "save" in an editor can produce half a
4//! dozen of them (write a temp file, rename it over the target, delete the backup). Left
5//! raw, that is one full directory re-read per event.
6//!
7//! We coalesce in our own code rather than pulling in `notify-debouncer-full`: one
8//! dependency instead of two, the debouncer's surface has moved across `notify` major
9//! versions, and — the real reason — a policy we own is a policy we can unit-test by
10//! feeding it synthetic batches with a synthetic clock, which is exactly what the tests
11//! below do.
12
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::sync::mpsc::{self, RecvTimeoutError};
16use std::time::{Duration, Instant};
17
18use notify::{RecursiveMode, Watcher};
19
20#[cfg(not(test))]
21type ActiveWatcher = notify::RecommendedWatcher;
22#[cfg(test)]
23type ActiveWatcher = notify::PollWatcher;
24
25use crate::ignore_rules::IgnoreRules;
26
27/// How long to gather events before emitting a batch. Long enough to absorb an editor's
28/// save dance, short enough that the tree feels live.
29pub const DEFAULT_WINDOW: Duration = Duration::from_millis(100);
30
31/// Accumulates changed paths and releases them once the window has elapsed.
32///
33/// Deliberately holds no clock of its own: the caller supplies `now`, which is what makes
34/// the policy testable without sleeping.
35#[derive(Debug)]
36pub struct Coalescer {
37    window: Duration,
38    /// A set, so a burst of edits to one path collapses to one entry. Ordered so batches
39    /// are deterministic.
40    pending: BTreeSet<PathBuf>,
41    opened_at: Option<Instant>,
42}
43
44impl Coalescer {
45    pub fn new(window: Duration) -> Self {
46        Self { window, pending: BTreeSet::new(), opened_at: None }
47    }
48
49    /// Note a changed path. The window starts on the first path of a batch.
50    pub fn push(&mut self, path: PathBuf, now: Instant) {
51        if self.pending.is_empty() {
52            self.opened_at = Some(now);
53        }
54        self.pending.insert(path);
55    }
56
57    pub fn is_empty(&self) -> bool {
58        self.pending.is_empty()
59    }
60
61    /// How long until the current batch is ready, if one is open.
62    pub fn time_remaining(&self, now: Instant) -> Option<Duration> {
63        let opened = self.opened_at?;
64        Some(self.window.saturating_sub(now.duration_since(opened)))
65    }
66
67    /// Take the batch if the window has elapsed. Returns `None` while it is still open,
68    /// so a steady trickle of events still gets released on schedule rather than being
69    /// deferred forever by each new arrival.
70    pub fn take_if_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
71        let opened = self.opened_at?;
72        if now.duration_since(opened) < self.window {
73            return None;
74        }
75        Some(self.take())
76    }
77
78    /// Take whatever is pending regardless of the window (used on shutdown).
79    pub fn take(&mut self) -> Vec<PathBuf> {
80        self.opened_at = None;
81        std::mem::take(&mut self.pending).into_iter().collect()
82    }
83}
84
85/// Paths that are noise no matter what the ignore rules say: editor swap and backup
86/// files, which appear and vanish around every save (ADR-0005 §5).
87pub fn is_editor_noise(path: &Path) -> bool {
88    let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
89    name.ends_with('~')
90        || name.ends_with(".swp")
91        || name.ends_with(".swx")
92        || name.ends_with(".tmp")
93        // Vim's fsync probe file.
94        || name == "4913"
95        // Emacs lock files.
96        || name.starts_with(".#")
97}
98
99/// Whether a raw watch event is worth waking the tree for.
100pub fn is_relevant(path: &Path, rules: &IgnoreRules) -> bool {
101    if is_editor_noise(path) {
102        return false;
103    }
104    // We cannot stat the path (it may already be gone), so ask about it as a file *and*
105    // as a directory; only drop it when it is ignored either way.
106    !(rules.is_hidden(path, false) && rules.is_hidden(path, true))
107}
108
109/// A running recursive watch over one root.
110///
111/// Owns both the `notify` watcher and the debounce thread; dropping it stops both.
112pub struct RootWatcher {
113    _watcher: ActiveWatcher,
114    stop: mpsc::Sender<()>,
115    handle: Option<std::thread::JoinHandle<()>>,
116}
117
118impl RootWatcher {
119    /// Start watching `root` recursively, emitting coalesced path batches to `sink`.
120    ///
121    /// Returns `None` if the OS refuses the watch (too many watches, unreadable root) —
122    /// a workspace without live updates is degraded, not broken, so the caller carries on.
123    pub fn start<F>(root: &Path, window: Duration, filter: RelevanceFilter, sink: F) -> Option<Self>
124    where
125        F: Fn(Vec<PathBuf>) + Send + 'static,
126    {
127        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
128        let event_handler = move |res: notify::Result<notify::Event>| {
129            if let Ok(event) = res {
130                for path in event.paths {
131                    // A dead receiver means we are shutting down; nothing to do.
132                    let _ = raw_tx.send(path);
133                }
134            }
135        };
136        #[cfg(not(test))]
137        let mut watcher = notify::recommended_watcher(event_handler).ok()?;
138        // Sandboxed CI environments can suppress host event facilities such as macOS
139        // FSEvents even though watcher registration succeeds. Polling in unit-test
140        // builds keeps this integration test deterministic while production continues
141        // to use the platform-recommended event backend.
142        #[cfg(test)]
143        let mut watcher = notify::PollWatcher::new(
144            event_handler,
145            notify::Config::default().with_poll_interval(window),
146        )
147        .ok()?;
148        watcher.watch(root, RecursiveMode::Recursive).ok()?;
149
150        let (stop, stop_rx) = mpsc::channel::<()>();
151        let handle = std::thread::Builder::new()
152            .name("termesh-fs-watch".into())
153            .spawn(move || {
154                let mut coalescer = Coalescer::new(window);
155                loop {
156                    if stop_rx.try_recv().is_ok() {
157                        return;
158                    }
159                    // Wait only as long as the open batch has left to run, so a batch is
160                    // released on time even if no further events arrive.
161                    let wait = coalescer.time_remaining(Instant::now()).unwrap_or(window);
162                    match raw_rx.recv_timeout(wait) {
163                        Ok(path) => {
164                            if filter.accepts(&path) {
165                                coalescer.push(path, Instant::now());
166                            }
167                        }
168                        Err(RecvTimeoutError::Timeout) => {}
169                        // The watcher is gone; flush anything held and stop.
170                        Err(RecvTimeoutError::Disconnected) => {
171                            if !coalescer.is_empty() {
172                                sink(coalescer.take());
173                            }
174                            return;
175                        }
176                    }
177                    if let Some(batch) = coalescer.take_if_ready(Instant::now()) {
178                        if !batch.is_empty() {
179                            sink(batch);
180                        }
181                    }
182                }
183            })
184            .ok()?;
185
186        Some(Self { _watcher: watcher, stop, handle: Some(handle) })
187    }
188}
189
190impl Drop for RootWatcher {
191    fn drop(&mut self) {
192        let _ = self.stop.send(());
193        if let Some(h) = self.handle.take() {
194            let _ = h.join();
195        }
196    }
197}
198
199/// Decides which raw watch paths reach the coalescer.
200///
201/// A boxed predicate rather than the `IgnoreRules` itself, because the rules are not
202/// `Send` and the watch thread needs something it can own.
203pub struct RelevanceFilter(Box<dyn Fn(&Path) -> bool + Send>);
204
205impl RelevanceFilter {
206    pub fn new<F: Fn(&Path) -> bool + Send + 'static>(f: F) -> Self {
207        Self(Box::new(f))
208    }
209
210    /// Drop only editor noise. Used when no ignore rules are anchored yet.
211    pub fn noise_only() -> Self {
212        Self::new(|p| !is_editor_noise(p))
213    }
214
215    pub fn accepts(&self, path: &Path) -> bool {
216        (self.0)(path)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn window() -> Duration {
225        Duration::from_millis(100)
226    }
227
228    #[test]
229    fn nothing_is_released_before_the_window_elapses() {
230        let t0 = Instant::now();
231        let mut c = Coalescer::new(window());
232        c.push("/r/a.rs".into(), t0);
233        assert_eq!(c.take_if_ready(t0 + Duration::from_millis(50)), None);
234    }
235
236    #[test]
237    fn the_batch_is_released_once_the_window_elapses() {
238        let t0 = Instant::now();
239        let mut c = Coalescer::new(window());
240        c.push("/r/a.rs".into(), t0);
241        assert_eq!(
242            c.take_if_ready(t0 + Duration::from_millis(120)),
243            Some(vec![PathBuf::from("/r/a.rs")])
244        );
245    }
246
247    #[test]
248    fn repeated_events_for_one_path_collapse_to_a_single_entry() {
249        let t0 = Instant::now();
250        let mut c = Coalescer::new(window());
251        for _ in 0..50 {
252            c.push("/r/a.rs".into(), t0);
253        }
254        let batch = c.take_if_ready(t0 + Duration::from_millis(120)).unwrap();
255        assert_eq!(batch, vec![PathBuf::from("/r/a.rs")], "one save, one entry");
256    }
257
258    #[test]
259    fn a_save_storm_across_files_becomes_one_batch() {
260        let t0 = Instant::now();
261        let mut c = Coalescer::new(window());
262        // An editor's save dance plus a formatter touching siblings.
263        for (i, p) in ["/r/a.rs", "/r/b.rs", "/r/c.rs", "/r/a.rs"].iter().enumerate() {
264            c.push(PathBuf::from(p), t0 + Duration::from_millis(i as u64 * 10));
265        }
266        let batch = c.take_if_ready(t0 + Duration::from_millis(120)).unwrap();
267        assert_eq!(batch.len(), 3, "three distinct paths, one batch");
268    }
269
270    #[test]
271    fn a_steady_trickle_is_not_deferred_forever() {
272        // The window runs from the *first* event, not the most recent, so continuous
273        // activity still gets flushed on schedule rather than starving the tree.
274        let t0 = Instant::now();
275        let mut c = Coalescer::new(window());
276        c.push("/r/a.rs".into(), t0);
277        for i in 1..20 {
278            c.push(format!("/r/f{i}.rs").into(), t0 + Duration::from_millis(i * 10));
279        }
280        assert!(c.take_if_ready(t0 + Duration::from_millis(101)).is_some());
281    }
282
283    #[test]
284    fn the_window_restarts_for_the_next_batch() {
285        let t0 = Instant::now();
286        let mut c = Coalescer::new(window());
287        c.push("/r/a.rs".into(), t0);
288        assert!(c.take_if_ready(t0 + Duration::from_millis(120)).is_some());
289        assert!(c.is_empty());
290
291        let t1 = t0 + Duration::from_millis(500);
292        c.push("/r/b.rs".into(), t1);
293        assert_eq!(c.take_if_ready(t1 + Duration::from_millis(50)), None, "fresh window");
294        assert!(c.take_if_ready(t1 + Duration::from_millis(120)).is_some());
295    }
296
297    #[test]
298    fn an_empty_coalescer_never_reports_ready() {
299        let mut c = Coalescer::new(window());
300        assert_eq!(c.take_if_ready(Instant::now()), None);
301        assert_eq!(c.time_remaining(Instant::now()), None);
302    }
303
304    #[test]
305    fn batches_are_deterministically_ordered() {
306        let t0 = Instant::now();
307        let mut a = Coalescer::new(window());
308        let mut b = Coalescer::new(window());
309        for p in ["/r/c", "/r/a", "/r/b"] {
310            a.push(PathBuf::from(p), t0);
311        }
312        for p in ["/r/b", "/r/c", "/r/a"] {
313            b.push(PathBuf::from(p), t0);
314        }
315        let ready = t0 + Duration::from_millis(120);
316        assert_eq!(a.take_if_ready(ready), b.take_if_ready(ready), "arrival order must not matter");
317    }
318
319    #[test]
320    fn editor_swap_and_backup_files_are_noise() {
321        for p in ["/r/.main.rs.swp", "/r/main.rs~", "/r/4913", "/r/.#main.rs", "/r/build.tmp"] {
322            assert!(is_editor_noise(Path::new(p)), "{p} should be filtered out");
323        }
324    }
325
326    #[test]
327    fn real_source_files_are_not_noise() {
328        for p in ["/r/main.rs", "/r/Cargo.toml", "/r/src/model.rs"] {
329            assert!(!is_editor_noise(Path::new(p)), "{p} must reach the tree");
330        }
331    }
332
333    #[test]
334    fn the_noise_only_filter_passes_real_files() {
335        let f = RelevanceFilter::noise_only();
336        assert!(f.accepts(Path::new("/r/main.rs")));
337        assert!(!f.accepts(Path::new("/r/main.rs~")));
338    }
339}