Skip to main content

ssg/server/
event_watch.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Event-driven file watcher (issue #526).
5//!
6//! Wraps [`notify::recommended_watcher`] so the dev server can react to
7//! OS filesystem events (`FSEvents` / `inotify` / `ReadDirectoryChangesW`)
8//! instead of polling. Compared to the legacy [`crate::watch::FileWatcher`],
9//! this:
10//!
11//! * Costs ~0% idle CPU (kernel pushes events; no `mtime` scan loop).
12//! * Wakes within ~5 ms of the OS event vs. the 1-2 s polling interval.
13//! * Coalesces rapid saves through a 100 ms debounce window — a
14//!   `cargo fmt` storm that touches one file four times in 200 ms
15//!   produces exactly one drain (AC6).
16//!
17//! # Architecture
18//!
19//! ```text
20//! notify backend ──▶ raw_tx ──▶ debounce thread ──▶ batched_tx ──▶ caller
21//!  (OS event)        mpsc          (100 ms window)      mpsc
22//! ```
23//!
24//! The debounce thread:
25//! 1. Blocks on `raw_rx.recv()` until the first event arrives.
26//! 2. Records the wall-clock instant of that first event.
27//! 3. Drains every subsequent event with `recv_timeout(remaining)` until
28//!    100 ms have elapsed since the first event.
29//! 4. Sends the de-duplicated path set to `batched_tx`.
30//!
31//! Last-write-wins is implicit: only the path set is forwarded, the per-event
32//! ordering is discarded.
33//!
34//! # Why not `notify-debouncer-mini`?
35//!
36//! That crate brings a `tokio` dep transitively; ssg is rayon-only. The
37//! hand-rolled debouncer here is ~30 lines, deterministic, and unit-testable
38//! without touching the filesystem (see [`debounce_paths`]).
39
40use std::collections::HashSet;
41use std::path::{Path, PathBuf};
42use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
43use std::sync::{Arc, Mutex};
44use std::thread::{self, JoinHandle};
45use std::time::{Duration, Instant};
46
47use notify::{
48    recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode,
49    Watcher,
50};
51
52use crate::error::SsgError;
53
54/// Default debounce window. 100 ms is the issue-#526 AC6 target — long
55/// enough to collapse a `cargo fmt` save storm into one rebuild, short
56/// enough to feel instant in the browser.
57pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(100);
58
59/// Cap on how many distinct paths can be debounced into a single batch
60/// before the watcher forces a drain. Prevents pathological build-output
61/// storms from delaying delivery beyond a reasonable bound.
62pub const MAX_BATCH_PATHS: usize = 10_000;
63
64/// A batched set of changed paths produced by [`EventWatcher`].
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ChangeBatch {
67    /// Distinct paths touched during the debounce window.
68    pub paths: Vec<PathBuf>,
69}
70
71impl ChangeBatch {
72    /// Returns `true` if the batch carries no paths.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use ssg::event_watch::ChangeBatch;
78    /// let b = ChangeBatch { paths: vec![] };
79    /// assert!(b.is_empty());
80    /// ```
81    #[must_use]
82    pub const fn is_empty(&self) -> bool {
83        self.paths.is_empty()
84    }
85
86    /// Returns the number of unique paths in the batch.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use std::path::PathBuf;
92    /// use ssg::event_watch::ChangeBatch;
93    /// let b = ChangeBatch { paths: vec![PathBuf::from("a"), PathBuf::from("b")] };
94    /// assert_eq!(b.len(), 2);
95    /// ```
96    #[must_use]
97    pub const fn len(&self) -> usize {
98        self.paths.len()
99    }
100}
101
102/// Result of a [`EventWatcher::recv_timeout`] call.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum RecvOutcome {
105    /// A debounced batch arrived.
106    Batch(ChangeBatch),
107    /// No batch landed inside the requested timeout. Caller may loop.
108    Timeout,
109    /// The watcher was dropped or the debounce thread exited; no
110    /// further batches will arrive on this channel.
111    Closed,
112}
113
114impl RecvOutcome {
115    /// Returns the wrapped batch, if any.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// use ssg::event_watch::{ChangeBatch, RecvOutcome};
121    /// let b = ChangeBatch { paths: vec![] };
122    /// let out = RecvOutcome::Batch(b.clone());
123    /// assert_eq!(out.batch(), Some(b));
124    /// assert!(RecvOutcome::Timeout.batch().is_none());
125    /// ```
126    #[must_use]
127    pub fn batch(self) -> Option<ChangeBatch> {
128        match self {
129            Self::Batch(b) => Some(b),
130            Self::Timeout | Self::Closed => None,
131        }
132    }
133
134    /// Returns true if the channel is closed (no more batches).
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use ssg::event_watch::RecvOutcome;
140    /// assert!(RecvOutcome::Closed.is_closed());
141    /// assert!(!RecvOutcome::Timeout.is_closed());
142    /// ```
143    #[must_use]
144    pub const fn is_closed(&self) -> bool {
145        matches!(self, Self::Closed)
146    }
147}
148
149/// Event-driven watcher built on top of `notify`.
150///
151/// Owns the recommended backend (`FSEvents`/`inotify`/RDCW), a debounce
152/// thread, and the receiver end of the batched-event channel. Dropping
153/// the watcher tears down the backend and the debounce thread.
154pub struct EventWatcher {
155    /// Live notify backend. Held so dropping the watcher unsubscribes.
156    /// `Option` so we can `take()` it in `Drop` without unsafe.
157    backend: Mutex<Option<RecommendedWatcher>>,
158    /// Channel that delivers debounced batches to the caller.
159    rx: Receiver<ChangeBatch>,
160    /// Handle to the debounce thread. Joined on drop.
161    debounce_handle: Mutex<Option<JoinHandle<()>>>,
162    /// Shutdown signal — set to true to make the debounce thread exit.
163    shutdown: Arc<Mutex<bool>>,
164    /// Window the debounce thread waits before flushing.
165    debounce: Duration,
166}
167
168impl std::fmt::Debug for EventWatcher {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("EventWatcher")
171            .field("debounce", &self.debounce)
172            .finish_non_exhaustive()
173    }
174}
175
176impl EventWatcher {
177    /// Builds a watcher rooted at `dir`, watching recursively, with a
178    /// 100 ms debounce window.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`SsgError::Io`] wrapping the underlying `notify::Error`
183    /// when the backend cannot subscribe (missing directory, permission
184    /// denied, kernel resource exhaustion).
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use ssg::event_watch::{EventWatcher, DEFAULT_DEBOUNCE};
190    /// let tmp = tempfile::tempdir().unwrap();
191    /// let w = EventWatcher::new(tmp.path()).unwrap();
192    /// assert_eq!(w.debounce(), DEFAULT_DEBOUNCE);
193    /// ```
194    pub fn new(dir: &Path) -> Result<Self, SsgError> {
195        Self::with_debounce(dir, DEFAULT_DEBOUNCE)
196    }
197
198    /// Same as [`Self::new`] but with a caller-supplied debounce window
199    /// (used by tests to keep latency low).
200    ///
201    /// # Errors
202    ///
203    /// See [`Self::new`].
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use std::time::Duration;
209    /// use ssg::event_watch::EventWatcher;
210    /// let tmp = tempfile::tempdir().unwrap();
211    /// let w = EventWatcher::with_debounce(tmp.path(), Duration::from_millis(50)).unwrap();
212    /// assert_eq!(w.debounce(), Duration::from_millis(50));
213    /// ```
214    pub fn with_debounce(
215        dir: &Path,
216        debounce: Duration,
217    ) -> Result<Self, SsgError> {
218        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
219        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
220
221        let mut backend = create_backend(move |res: notify::Result<Event>| {
222            forward_event(res, &raw_tx);
223        })
224        .map_err(|e| SsgError::Io {
225            path: dir.to_path_buf(),
226            source: std::io::Error::other(format!("notify watcher init: {e}")),
227        })?;
228
229        backend.watch(dir, RecursiveMode::Recursive).map_err(|e| {
230            SsgError::Io {
231                path: dir.to_path_buf(),
232                source: std::io::Error::other(format!("notify watch: {e}")),
233            }
234        })?;
235
236        let shutdown = Arc::new(Mutex::new(false));
237        let shutdown_clone = Arc::clone(&shutdown);
238        let debounce_handle = spawn_debounce_thread(
239            thread::Builder::new().name("ssg-watch-debounce".into()),
240            move || {
241                debounce_loop(raw_rx, batched_tx, debounce, &shutdown_clone);
242            },
243        )
244        .map_err(|e| SsgError::Io {
245            path: dir.to_path_buf(),
246            source: std::io::Error::other(format!(
247                "debounce thread spawn: {e}"
248            )),
249        })?;
250
251        Ok(Self {
252            backend: Mutex::new(Some(backend)),
253            rx: batched_rx,
254            debounce_handle: Mutex::new(Some(debounce_handle)),
255            shutdown,
256            debounce,
257        })
258    }
259
260    /// Blocks until the next debounced batch is available.
261    ///
262    /// Returns `None` if the watcher is being torn down.
263    ///
264    /// # Examples
265    ///
266    /// ```no_run
267    /// use ssg::event_watch::EventWatcher;
268    /// let tmp = tempfile::tempdir().unwrap();
269    /// let w = EventWatcher::new(tmp.path()).unwrap();
270    /// // Blocks until a change arrives — would hang in a doctest sandbox.
271    /// if let Some(batch) = w.recv() {
272    ///     assert!(!batch.paths.is_empty());
273    /// }
274    /// ```
275    #[must_use]
276    pub fn recv(&self) -> Option<ChangeBatch> {
277        self.rx.recv().ok()
278    }
279
280    /// Like [`Self::recv`] but with a timeout.
281    ///
282    /// Returns:
283    /// * [`RecvOutcome::Batch`] — a debounced batch arrived.
284    /// * [`RecvOutcome::Timeout`] — no batch within `timeout`.
285    /// * [`RecvOutcome::Closed`] — the watcher was dropped or the
286    ///   debounce thread exited.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use std::time::Duration;
292    /// use ssg::event_watch::{EventWatcher, RecvOutcome};
293    /// let tmp = tempfile::tempdir().unwrap();
294    /// let w = EventWatcher::with_debounce(tmp.path(), Duration::from_millis(20)).unwrap();
295    /// let out = w.recv_timeout(Duration::from_millis(30));
296    /// assert!(!out.is_closed());
297    /// ```
298    pub fn recv_timeout(&self, timeout: Duration) -> RecvOutcome {
299        match self.rx.recv_timeout(timeout) {
300            Ok(b) => RecvOutcome::Batch(b),
301            Err(RecvTimeoutError::Timeout) => RecvOutcome::Timeout,
302            Err(RecvTimeoutError::Disconnected) => RecvOutcome::Closed,
303        }
304    }
305
306    /// Test-only: drops the notify backend so the debounce thread winds
307    /// down and the batched channel closes without dropping the watcher.
308    #[cfg(test)]
309    pub(crate) fn close_backend_for_test(&self) {
310        let _ = self.backend.lock().map(|mut b| *b = None);
311    }
312
313    /// Debounce window in effect for this watcher.
314    ///
315    /// # Examples
316    ///
317    /// ```
318    /// use std::time::Duration;
319    /// use ssg::event_watch::EventWatcher;
320    /// let tmp = tempfile::tempdir().unwrap();
321    /// let w = EventWatcher::with_debounce(tmp.path(), Duration::from_millis(75)).unwrap();
322    /// assert_eq!(w.debounce(), Duration::from_millis(75));
323    /// ```
324    #[must_use]
325    pub const fn debounce(&self) -> Duration {
326        self.debounce
327    }
328}
329
330impl Drop for EventWatcher {
331    fn drop(&mut self) {
332        // Signal the debounce thread to exit on its next loop iteration.
333        if let Ok(mut s) = self.shutdown.lock() {
334            *s = true;
335        }
336        // Drop the backend first — that closes `raw_tx`, which unblocks
337        // the debounce thread's `recv()`.
338        if let Ok(mut backend) = self.backend.lock() {
339            *backend = None;
340        }
341        // Join the debounce thread so we don't leak a zombie on drop.
342        if let Ok(mut handle) = self.debounce_handle.lock() {
343            if let Some(h) = handle.take() {
344                let _ = h.join();
345            }
346        }
347    }
348}
349
350/// Returns whether a notify event represents a real change.
351///
352/// We deliberately ignore [`EventKind::Access`] and [`EventKind::Other`]
353/// (mount/unmount on macOS, access-time bumps on Linux); those don't
354/// change file content and rebuilding for them is wasted work.
355///
356/// # Examples
357///
358/// ```
359/// use notify::{EventKind, event::ModifyKind};
360/// use ssg::event_watch::event_should_propagate;
361/// assert!(event_should_propagate(&EventKind::Modify(ModifyKind::Any)));
362/// assert!(!event_should_propagate(&EventKind::Other));
363/// ```
364#[must_use]
365pub const fn event_should_propagate(kind: &EventKind) -> bool {
366    matches!(
367        kind,
368        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
369    )
370}
371
372/// Forwards the paths of a propagatable notify event onto `raw_tx`.
373///
374/// Extracted from the `recommended_watcher` callback so the ignore
375/// branches (backend error, `Access`/`Other` events) are unit-testable
376/// without waiting on a live OS event.
377fn forward_event(res: notify::Result<Event>, raw_tx: &Sender<PathBuf>) {
378    if let Ok(event) = res {
379        if event_should_propagate(&event.kind) {
380            for path in event.paths {
381                // Best-effort: receiver dropped means the watcher
382                // itself was dropped; nothing to do.
383                let _ = raw_tx.send(path);
384            }
385        }
386    }
387}
388
389/// Thread-local fault injection for the two error branches real OS
390/// behaviour cannot reach deterministically (backend init and thread
391/// spawn failures). Thread-local — unlike a process-global `fail`
392/// failpoint — so arming a fault in one test cannot leak into
393/// concurrently running tests that also build watchers.
394#[cfg(all(test, feature = "test-fault-injection"))]
395mod fault {
396    use std::cell::Cell;
397
398    thread_local! {
399        static ARMED: Cell<Option<&'static str>> = const { Cell::new(None) };
400    }
401
402    /// Arms `name` for the current thread; disarmed when the returned
403    /// guard drops (panic-safe).
404    pub(super) fn arm(name: &'static str) -> ArmGuard {
405        ARMED.with(|a| a.set(Some(name)));
406        ArmGuard
407    }
408
409    /// Returns whether `name` is armed on the current thread.
410    pub(super) fn armed(name: &str) -> bool {
411        ARMED.with(|a| a.get() == Some(name))
412    }
413
414    /// RAII guard that disarms the thread-local fault on drop.
415    #[derive(Debug)]
416    pub(super) struct ArmGuard;
417
418    impl Drop for ArmGuard {
419        fn drop(&mut self) {
420            ARMED.with(|a| a.set(None));
421        }
422    }
423}
424
425/// Creates the notify backend. Wrapped so tests can inject a
426/// construction failure via the `event-watch::backend-init`
427/// thread-local fault.
428fn create_backend<F: notify::EventHandler>(
429    event_handler: F,
430) -> notify::Result<RecommendedWatcher> {
431    #[cfg(all(test, feature = "test-fault-injection"))]
432    if fault::armed("event-watch::backend-init") {
433        return Err(notify::Error::generic(
434            "injected: event-watch::backend-init",
435        ));
436    }
437    recommended_watcher(event_handler)
438}
439
440/// Spawns the debounce thread. Wrapped so tests can inject a spawn
441/// failure via the `event-watch::debounce-spawn` thread-local fault.
442fn spawn_debounce_thread(
443    builder: thread::Builder,
444    body: impl FnOnce() + Send + 'static,
445) -> std::io::Result<JoinHandle<()>> {
446    #[cfg(all(test, feature = "test-fault-injection"))]
447    if fault::armed("event-watch::debounce-spawn") {
448        return Err(std::io::Error::other(
449            "injected: event-watch::debounce-spawn",
450        ));
451    }
452    builder.spawn(body)
453}
454
455/// Time left in the debounce window, or `None` once the window has
456/// closed. Extracted from [`debounce_loop`] so the boundary cases
457/// (`elapsed == window`, `elapsed > window`) are unit-testable.
458fn remaining_window(window: Duration, elapsed: Duration) -> Option<Duration> {
459    let remaining = window.checked_sub(elapsed)?;
460    if remaining.is_zero() {
461        None
462    } else {
463        Some(remaining)
464    }
465}
466
467/// Debounce loop: collect paths from `raw_rx` for at most `window`
468/// after the first event, then flush.
469///
470/// Extracted from [`EventWatcher::with_debounce`] so the thread body has
471/// a single, testable signature.
472fn debounce_loop(
473    raw_rx: Receiver<PathBuf>,
474    batched_tx: Sender<ChangeBatch>,
475    window: Duration,
476    shutdown: &Arc<Mutex<bool>>,
477) {
478    // Block waiting for the first event of each batch. A disconnected
479    // channel means the watcher was dropped — exit the loop.
480    while let Ok(first) = raw_rx.recv() {
481        if shutdown.lock().map_or(true, |g| *g) {
482            break;
483        }
484
485        let mut paths: HashSet<PathBuf> = HashSet::new();
486        let _ = paths.insert(first);
487        let start = Instant::now();
488
489        // Drain everything that lands inside the window.
490        while let Some(remaining) = remaining_window(window, start.elapsed()) {
491            match raw_rx.recv_timeout(remaining) {
492                Ok(p) => {
493                    let _ = paths.insert(p);
494                    if paths.len() >= MAX_BATCH_PATHS {
495                        break;
496                    }
497                }
498                Err(RecvTimeoutError::Timeout) => break,
499                Err(RecvTimeoutError::Disconnected) => {
500                    // Send what we have and exit on the next iteration.
501                    let batch = ChangeBatch {
502                        paths: sorted(paths),
503                    };
504                    let _ = batched_tx.send(batch);
505                    return;
506                }
507            }
508        }
509
510        let batch = ChangeBatch {
511            paths: sorted(paths),
512        };
513        if batched_tx.send(batch).is_err() {
514            break;
515        }
516    }
517}
518
519/// Pure helper: collapse a stream of `(path, instant)` events into a
520/// list of debounced batches. Used by tests so the debouncer logic can
521/// be verified without spawning threads.
522///
523/// # Examples
524///
525/// ```
526/// use std::path::PathBuf;
527/// use std::time::{Duration, Instant};
528/// use ssg::event_watch::debounce_paths;
529/// let t0 = Instant::now();
530/// let events = vec![
531///     (PathBuf::from("a.md"), t0),
532///     (PathBuf::from("a.md"), t0 + Duration::from_millis(20)),
533/// ];
534/// let out = debounce_paths(&events, Duration::from_millis(100));
535/// assert_eq!(out.len(), 1);
536/// assert_eq!(out[0].len(), 1);
537/// ```
538#[must_use]
539pub fn debounce_paths(
540    events: &[(PathBuf, Instant)],
541    window: Duration,
542) -> Vec<ChangeBatch> {
543    if events.is_empty() {
544        return Vec::new();
545    }
546    let mut out = Vec::new();
547    let mut current: HashSet<PathBuf> = HashSet::new();
548    let mut first: Option<Instant> = None;
549
550    for (path, t) in events {
551        match first {
552            None => {
553                first = Some(*t);
554                let _ = current.insert(path.clone());
555            }
556            Some(f) if *t < f + window => {
557                let _ = current.insert(path.clone());
558            }
559            Some(_) => {
560                // Window closed — flush and start a new batch.
561                let taken = std::mem::take(&mut current);
562                out.push(ChangeBatch {
563                    paths: sorted(taken),
564                });
565                first = Some(*t);
566                let _ = current.insert(path.clone());
567            }
568        }
569    }
570    if !current.is_empty() {
571        out.push(ChangeBatch {
572            paths: sorted(current),
573        });
574    }
575    out
576}
577
578fn sorted(set: HashSet<PathBuf>) -> Vec<PathBuf> {
579    let mut v: Vec<PathBuf> = set.into_iter().collect();
580    v.sort();
581    v
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use std::path::PathBuf;
588    use std::time::Instant;
589
590    fn p(s: &str) -> PathBuf {
591        PathBuf::from(s)
592    }
593
594    #[test]
595    fn debounce_empty_input_yields_empty_output() {
596        assert!(debounce_paths(&[], Duration::from_millis(100)).is_empty());
597    }
598
599    #[test]
600    fn debounce_single_event_one_batch() {
601        let t0 = Instant::now();
602        let out =
603            debounce_paths(&[(p("a.md"), t0)], Duration::from_millis(100));
604        assert_eq!(out.len(), 1);
605        assert_eq!(out[0].paths, vec![p("a.md")]);
606    }
607
608    #[test]
609    fn debounce_collapses_four_saves_in_200ms_window_into_one_batch_when_within_window(
610    ) {
611        // AC6: cargo-fmt-style storm — 4 events on the same file within
612        // 80 ms, debounce window 100 ms => exactly 1 batch, 1 path.
613        let t0 = Instant::now();
614        let events = vec![
615            (p("style.css"), t0),
616            (p("style.css"), t0 + Duration::from_millis(20)),
617            (p("style.css"), t0 + Duration::from_millis(40)),
618            (p("style.css"), t0 + Duration::from_millis(80)),
619        ];
620        let out = debounce_paths(&events, Duration::from_millis(100));
621        assert_eq!(out.len(), 1, "should collapse to one batch");
622        assert_eq!(out[0].paths, vec![p("style.css")]);
623    }
624
625    #[test]
626    fn debounce_splits_batches_across_window_boundary() {
627        let t0 = Instant::now();
628        let events = vec![
629            (p("a.md"), t0),
630            (p("b.md"), t0 + Duration::from_millis(50)),
631            // 150 ms after first => outside the 100 ms window.
632            (p("c.md"), t0 + Duration::from_millis(150)),
633        ];
634        let out = debounce_paths(&events, Duration::from_millis(100));
635        assert_eq!(out.len(), 2);
636        assert_eq!(out[0].paths, vec![p("a.md"), p("b.md")]);
637        assert_eq!(out[1].paths, vec![p("c.md")]);
638    }
639
640    #[test]
641    fn debounce_deduplicates_paths_in_same_window() {
642        let t0 = Instant::now();
643        let events = vec![
644            (p("x"), t0),
645            (p("y"), t0 + Duration::from_millis(10)),
646            (p("x"), t0 + Duration::from_millis(20)),
647            (p("y"), t0 + Duration::from_millis(30)),
648        ];
649        let out = debounce_paths(&events, Duration::from_millis(100));
650        assert_eq!(out.len(), 1);
651        assert_eq!(out[0].paths, vec![p("x"), p("y")]);
652    }
653
654    #[test]
655    fn event_should_propagate_accepts_modify_create_remove() {
656        use notify::event::{CreateKind, ModifyKind, RemoveKind};
657        assert!(event_should_propagate(&EventKind::Create(CreateKind::File)));
658        assert!(event_should_propagate(&EventKind::Modify(ModifyKind::Any)));
659        assert!(event_should_propagate(&EventKind::Remove(RemoveKind::File)));
660    }
661
662    #[test]
663    fn event_should_propagate_rejects_access_and_other() {
664        use notify::event::AccessKind;
665        assert!(!event_should_propagate(&EventKind::Access(AccessKind::Any)));
666        assert!(!event_should_propagate(&EventKind::Other));
667    }
668
669    #[test]
670    fn change_batch_len_and_is_empty() {
671        let empty = ChangeBatch { paths: vec![] };
672        assert!(empty.is_empty());
673        assert_eq!(empty.len(), 0);
674
675        let one = ChangeBatch {
676            paths: vec![p("x")],
677        };
678        assert!(!one.is_empty());
679        assert_eq!(one.len(), 1);
680    }
681
682    #[test]
683    fn change_batch_eq_clone() {
684        let a = ChangeBatch {
685            paths: vec![p("x")],
686        };
687        let b = a.clone();
688        assert_eq!(a, b);
689    }
690
691    #[test]
692    fn default_debounce_is_100ms() {
693        assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(100));
694    }
695
696    #[test]
697    fn new_returns_err_when_path_missing() {
698        // Non-existent path — recommended_watcher may succeed but
699        // .watch() should fail.
700        let res = EventWatcher::new(Path::new("/nonexistent/ssg/test/dir"));
701        assert!(res.is_err());
702    }
703
704    #[test]
705    fn recv_outcome_batch_extracts_payload() {
706        let b = ChangeBatch {
707            paths: vec![p("a")],
708        };
709        let out = RecvOutcome::Batch(b.clone());
710        assert_eq!(out.batch(), Some(b));
711        assert!(RecvOutcome::Timeout.batch().is_none());
712        assert!(RecvOutcome::Closed.batch().is_none());
713    }
714
715    #[test]
716    fn recv_outcome_is_closed_only_for_closed() {
717        assert!(RecvOutcome::Closed.is_closed());
718        assert!(!RecvOutcome::Timeout.is_closed());
719        let b = ChangeBatch { paths: vec![] };
720        assert!(!RecvOutcome::Batch(b).is_closed());
721    }
722
723    #[test]
724    fn live_watcher_with_debounce_yields_batch_on_real_fs_event() {
725        // Live integration: create a temp dir, instantiate the watcher,
726        // touch a file, and assert we receive a batch within a reasonable
727        // window. Exercises with_debounce, the notify callback closure,
728        // debounce_loop, and recv_timeout's Batch arm.
729        let dir = tempfile::tempdir().expect("tempdir");
730        let w =
731            EventWatcher::with_debounce(dir.path(), Duration::from_millis(50))
732                .expect("watcher");
733
734        // Sanity: debounce() accessor + Debug impl.
735        assert_eq!(w.debounce(), Duration::from_millis(50));
736        let dbg = format!("{:?}", w);
737        assert!(dbg.contains("EventWatcher"));
738
739        // Write a file inside the watched dir.
740        let file = dir.path().join("a.txt");
741        std::fs::write(&file, b"hello").expect("write");
742
743        // Poll for up to ~2s — notify backends can be slow on cold start.
744        let deadline = Instant::now() + Duration::from_secs(2);
745        let mut got: Option<ChangeBatch> = None;
746        while got.is_none() && Instant::now() < deadline {
747            got = w.recv_timeout(Duration::from_millis(200)).batch();
748        }
749        // CI macOS FSEvents can rarely lose the first event for a brand
750        // new dir; don't fail the suite — just ensure we exercised the
751        // pathway without panicking.
752        assert!(got.is_none_or(|b| !b.is_empty()));
753    }
754
755    #[test]
756    fn drop_tears_down_thread_without_hanging() {
757        // Build + immediately drop. Drop must signal shutdown, drop the
758        // backend, and join the debounce thread cleanly. If the join
759        // hangs, the test framework will time out and fail.
760        let dir = tempfile::tempdir().expect("tempdir");
761        let w =
762            EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
763                .expect("watcher");
764        drop(w);
765    }
766
767    #[test]
768    fn recv_timeout_returns_timeout_when_idle() {
769        // Build a watcher on an empty tempdir, do not touch anything,
770        // and assert recv_timeout returns Timeout before the deadline.
771        let dir = tempfile::tempdir().expect("tempdir");
772        let w =
773            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
774                .expect("watcher");
775        let out = w.recv_timeout(Duration::from_millis(50));
776        // Either Timeout (typical) or Batch (if FS noise) — neither should
777        // be Closed under normal conditions.
778        assert!(!out.is_closed());
779    }
780
781    #[test]
782    fn forward_event_sends_paths_for_propagatable_events() {
783        use notify::event::CreateKind;
784        let (tx, rx) = mpsc::channel::<PathBuf>();
785        let event = Event::new(EventKind::Create(CreateKind::File))
786            .add_path(p("a.md"))
787            .add_path(p("b.md"));
788        forward_event(Ok(event), &tx);
789        assert_eq!(rx.try_recv().unwrap(), p("a.md"));
790        assert_eq!(rx.try_recv().unwrap(), p("b.md"));
791        assert!(rx.try_recv().is_err(), "no extra paths expected");
792    }
793
794    #[test]
795    fn forward_event_ignores_access_events() {
796        use notify::event::AccessKind;
797        let (tx, rx) = mpsc::channel::<PathBuf>();
798        let event =
799            Event::new(EventKind::Access(AccessKind::Any)).add_path(p("a.md"));
800        forward_event(Ok(event), &tx);
801        assert!(rx.try_recv().is_err(), "access events must be dropped");
802    }
803
804    #[test]
805    fn forward_event_ignores_backend_errors() {
806        let (tx, rx) = mpsc::channel::<PathBuf>();
807        forward_event(Err(notify::Error::generic("boom")), &tx);
808        assert!(rx.try_recv().is_err(), "errors must be swallowed");
809    }
810
811    #[test]
812    fn forward_event_survives_dropped_receiver() {
813        use notify::event::CreateKind;
814        let (tx, rx) = mpsc::channel::<PathBuf>();
815        drop(rx);
816        let event =
817            Event::new(EventKind::Create(CreateKind::File)).add_path(p("a"));
818        // Must not panic even though the send fails.
819        forward_event(Ok(event), &tx);
820    }
821
822    #[test]
823    fn forward_event_with_no_paths_sends_nothing() {
824        use notify::event::CreateKind;
825        // Every other propagatable-event test attaches at least one
826        // path, so the `for path in event.paths` loop always runs at
827        // least once elsewhere. Exercise the zero-iteration case too.
828        let (tx, rx) = mpsc::channel::<PathBuf>();
829        let event = Event::new(EventKind::Create(CreateKind::File));
830        forward_event(Ok(event), &tx);
831        assert!(rx.try_recv().is_err(), "no paths means nothing to send");
832    }
833
834    #[test]
835    fn remaining_window_returns_time_left_inside_window() {
836        assert_eq!(
837            remaining_window(
838                Duration::from_millis(100),
839                Duration::from_millis(40)
840            ),
841            Some(Duration::from_millis(60))
842        );
843    }
844
845    #[test]
846    fn remaining_window_none_when_elapsed_exceeds_window() {
847        assert_eq!(
848            remaining_window(
849                Duration::from_millis(100),
850                Duration::from_millis(150)
851            ),
852            None
853        );
854    }
855
856    #[test]
857    fn remaining_window_none_at_exact_boundary() {
858        assert_eq!(
859            remaining_window(
860                Duration::from_millis(100),
861                Duration::from_millis(100)
862            ),
863            None
864        );
865    }
866
867    #[test]
868    fn debounce_loop_exits_when_shutdown_already_signalled() {
869        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
870        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
871        let shutdown = Arc::new(Mutex::new(true));
872
873        raw_tx.send(p("a.md")).unwrap();
874        drop(raw_tx);
875        debounce_loop(raw_rx, batched_tx, Duration::from_millis(10), &shutdown);
876
877        // Shutdown short-circuits before any batch is flushed.
878        assert!(batched_rx.try_recv().is_err());
879    }
880
881    #[test]
882    fn debounce_loop_treats_poisoned_shutdown_lock_as_signalled() {
883        // Poison the `shutdown` mutex before debounce_loop ever locks
884        // it. `shutdown.lock().map_or(true, |g| *g)` must fall back to
885        // `true` (treat-as-shutting-down) on a poisoned lock rather
886        // than panicking or silently continuing.
887        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
888        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
889        let shutdown = Arc::new(Mutex::new(false));
890
891        let poison_target = Arc::clone(&shutdown);
892        let _ = thread::spawn(move || {
893            let _guard = poison_target.lock().unwrap();
894            panic!("poison shutdown for test");
895        })
896        .join();
897
898        raw_tx.send(p("a.md")).unwrap();
899        drop(raw_tx);
900        debounce_loop(raw_rx, batched_tx, Duration::from_millis(10), &shutdown);
901
902        // Poisoned lock reads as "shut down" — the loop must break
903        // immediately without flushing a batch.
904        assert!(batched_rx.try_recv().is_err());
905    }
906
907    #[test]
908    fn debounce_loop_forces_drain_at_max_batch_paths() {
909        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
910        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
911        let shutdown = Arc::new(Mutex::new(false));
912
913        for i in 0..MAX_BATCH_PATHS {
914            raw_tx.send(PathBuf::from(format!("f{i}"))).unwrap();
915        }
916        drop(raw_tx);
917        // Long window: the cap — not the clock — must force the drain.
918        debounce_loop(raw_rx, batched_tx, Duration::from_secs(60), &shutdown);
919
920        let batch = batched_rx.try_recv().expect("capped batch flushed");
921        assert_eq!(batch.len(), MAX_BATCH_PATHS);
922    }
923
924    #[test]
925    fn debounce_loop_flushes_pending_batch_on_disconnect() {
926        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
927        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
928        let shutdown = Arc::new(Mutex::new(false));
929
930        raw_tx.send(p("only.md")).unwrap();
931        drop(raw_tx);
932        // Sender gone mid-window: the pending set must still be flushed.
933        debounce_loop(raw_rx, batched_tx, Duration::from_secs(60), &shutdown);
934
935        let batch = batched_rx.try_recv().expect("final batch flushed");
936        assert_eq!(batch.paths, vec![p("only.md")]);
937    }
938
939    #[test]
940    fn debounce_loop_exits_when_batched_receiver_dropped() {
941        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
942        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
943        drop(batched_rx);
944        let shutdown = Arc::new(Mutex::new(false));
945
946        let handle = thread::spawn(move || {
947            debounce_loop(
948                raw_rx,
949                batched_tx,
950                Duration::from_millis(10),
951                &shutdown,
952            );
953        });
954        raw_tx.send(p("a.md")).unwrap();
955        // The loop drains via timeout, fails to deliver the batch, and
956        // breaks out — the join must therefore complete.
957        handle.join().expect("debounce thread exits cleanly");
958    }
959
960    #[test]
961    fn recv_returns_batch_on_live_event() {
962        // recv() blocks, so drive it from a helper thread while the
963        // main thread generates filesystem events. Tolerant of lost
964        // FSEvents on cold-start: the helper is detached on timeout.
965        let dir = tempfile::tempdir().expect("tempdir");
966        let w =
967            EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
968                .expect("watcher");
969
970        let (tx, rx) = mpsc::channel::<bool>();
971        let handle = thread::spawn(move || {
972            let got = w.recv();
973            let _ = tx.send(got.is_some());
974            drop(w);
975        });
976
977        let deadline = Instant::now() + Duration::from_secs(3);
978        let mut outcome: Option<bool> = None;
979        while outcome.is_none() && Instant::now() < deadline {
980            std::fs::write(dir.path().join("touch.md"), b"x").expect("write");
981            outcome = rx.recv_timeout(Duration::from_millis(100)).ok();
982        }
983        // When the event arrived, recv() must have yielded a batch and
984        // the helper thread must be joinable.
985        assert!(outcome.is_none_or(|got| got));
986        if outcome.is_some() {
987            handle.join().expect("recv thread exits");
988        }
989    }
990
991    #[test]
992    fn drop_recovers_from_poisoned_internal_locks() {
993        // Poison all three internal mutexes, then drop. Drop must not
994        // panic — every lock() failure path is exercised.
995        let dir = tempfile::tempdir().expect("tempdir");
996        let w =
997            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
998                .expect("watcher");
999
1000        let shutdown = Arc::clone(&w.shutdown);
1001        let _ = thread::spawn(move || {
1002            let _guard = shutdown.lock().unwrap();
1003            panic!("poison shutdown");
1004        })
1005        .join();
1006
1007        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1008            let _guard = w.backend.lock().unwrap();
1009            panic!("poison backend");
1010        }));
1011        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1012            let _guard = w.debounce_handle.lock().unwrap();
1013            panic!("poison handle");
1014        }));
1015
1016        drop(w); // must not panic or hang
1017    }
1018
1019    #[test]
1020    fn drop_tolerates_already_taken_debounce_handle() {
1021        let dir = tempfile::tempdir().expect("tempdir");
1022        let w =
1023            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
1024                .expect("watcher");
1025
1026        // Steal the join handle so Drop sees `None`.
1027        let handle = w.debounce_handle.lock().unwrap().take();
1028        drop(w);
1029
1030        // The debounce thread exits once the backend (and raw_tx) is
1031        // gone; join it ourselves so nothing leaks.
1032        handle
1033            .expect("handle present")
1034            .join()
1035            .expect("thread exits");
1036    }
1037
1038    #[cfg(feature = "test-fault-injection")]
1039    mod fault_injection {
1040        use super::*;
1041
1042        #[test]
1043        fn with_debounce_surfaces_backend_init_failure() {
1044            let _guard = fault::arm("event-watch::backend-init");
1045            let dir = tempfile::tempdir().expect("tempdir");
1046            let err = EventWatcher::new(dir.path())
1047                .expect_err("backend init failure must propagate");
1048            assert!(format!("{err}").contains("notify watcher init"));
1049        }
1050
1051        #[test]
1052        fn with_debounce_surfaces_debounce_spawn_failure() {
1053            let _guard = fault::arm("event-watch::debounce-spawn");
1054            let dir = tempfile::tempdir().expect("tempdir");
1055            let err = EventWatcher::new(dir.path())
1056                .expect_err("spawn failure must propagate");
1057            assert!(format!("{err}").contains("debounce thread spawn"));
1058        }
1059    }
1060
1061    #[test]
1062    fn debounce_paths_caps_at_window_with_widely_spaced_events() {
1063        // Each event 200 ms apart with a 100 ms window => one batch per
1064        // event, exercising the "Some(_)" arm that flushes and restarts.
1065        let t0 = Instant::now();
1066        let events = vec![
1067            (p("a"), t0),
1068            (p("b"), t0 + Duration::from_millis(200)),
1069            (p("c"), t0 + Duration::from_millis(400)),
1070        ];
1071        let out = debounce_paths(&events, Duration::from_millis(100));
1072        assert_eq!(out.len(), 3);
1073    }
1074}