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}