Skip to main content

alba_engine/watch/
notify.rs

1//! The real [`Watcher`]: `notify` behind `notify-debouncer-full`.
2//!
3//! The debouncer runs its own thread and calls the handler with each
4//! coalesced batch; the handler forwards into an unbounded channel the
5//! async side reads. The debouncer must stay alive as long as the
6//! watcher — dropping it silently stops all delivery — so it rides along
7//! in the struct. Watcher errors (overflow included) become
8//! [`WatchBatch::Rescan`]: the loop treats "something changed but I
9//! cannot say what" as a trigger and lets the cache absorb the
10//! imprecision.
11
12use std::path::PathBuf;
13use std::time::Duration;
14
15use notify::RecursiveMode;
16use notify_debouncer_full::{Debouncer, NoCache};
17use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
18
19use super::{WatchBatch, Watcher};
20
21pub struct NotifyWatcher {
22    batches: UnboundedReceiver<WatchBatch>,
23    // Kept alive only for its `Drop`: dropping the debouncer stops the
24    // background thread that feeds `batches`, so this field is never
25    // read, only held.
26    _debouncer: Debouncer<notify::RecommendedWatcher, NoCache>,
27}
28
29impl NotifyWatcher {
30    /// The built-in debounce window: long enough to coalesce an editor's
31    /// save burst or a `git checkout`, short enough to feel immediate.
32    pub const DEBOUNCE: Duration = Duration::from_millis(200);
33
34    /// Starts watching every root recursively. An error here is fatal to
35    /// the session before it starts — the CLI turns it into exit code 2.
36    ///
37    /// [`NoCache`] rather than the debouncer's default cache, which on
38    /// macOS and Windows is a file-ID map: arming a root would walk the
39    /// whole tree under it and `stat` every entry, before the watcher —
40    /// and so before anything the session draws — exists. A project root
41    /// holding a build directory makes that walk enormous (a Rust
42    /// `target/` reaches hundreds of thousands of files), and the session
43    /// looks frozen for as long as it runs. The cache buys only rename
44    /// stitching for back ends that emit no rename cookie, and a batch is
45    /// read here for its paths alone: a rename reported as two unrelated
46    /// paths triggers exactly the same rehash as one reported as a pair.
47    /// Linux already defaults to this for unrelated reasons.
48    pub fn new(roots: &[PathBuf]) -> Result<Self, notify::Error> {
49        let (sender, batches) = unbounded_channel();
50        let mut debouncer = notify_debouncer_full::new_debouncer_opt(
51            Self::DEBOUNCE,
52            None,
53            move |result: notify_debouncer_full::DebounceEventResult| {
54                let batch = match result {
55                    Ok(events) => WatchBatch::Paths(
56                        events
57                            .into_iter()
58                            .flat_map(|event| event.paths.clone())
59                            .collect(),
60                    ),
61                    Err(_) => WatchBatch::Rescan,
62                };
63                // A send failure means the session is gone; nothing to do.
64                let _ = sender.send(batch);
65            },
66            NoCache::new(),
67            notify::Config::default(),
68        )?;
69        for root in roots {
70            debouncer.watch(root, RecursiveMode::Recursive)?;
71        }
72        Ok(Self {
73            batches,
74            _debouncer: debouncer,
75        })
76    }
77}
78
79#[async_trait::async_trait]
80impl Watcher for NotifyWatcher {
81    async fn next_batch(&mut self) -> Option<WatchBatch> {
82        self.batches.recv().await
83    }
84}