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!(
597            debounce_paths(&[], Duration::from_millis(100)).is_empty(),
598            "{:?}",
599            debounce_paths(&[], Duration::from_millis(100))
600        );
601    }
602
603    #[test]
604    fn debounce_single_event_one_batch() {
605        let t0 = Instant::now();
606        let out =
607            debounce_paths(&[(p("a.md"), t0)], Duration::from_millis(100));
608        assert_eq!(out.len(), 1);
609        assert_eq!(out[0].paths, vec![p("a.md")]);
610    }
611
612    #[test]
613    fn debounce_collapses_four_saves_in_200ms_window_into_one_batch_when_within_window(
614    ) {
615        // AC6: cargo-fmt-style storm — 4 events on the same file within
616        // 80 ms, debounce window 100 ms => exactly 1 batch, 1 path.
617        let t0 = Instant::now();
618        let events = vec![
619            (p("style.css"), t0),
620            (p("style.css"), t0 + Duration::from_millis(20)),
621            (p("style.css"), t0 + Duration::from_millis(40)),
622            (p("style.css"), t0 + Duration::from_millis(80)),
623        ];
624        let out = debounce_paths(&events, Duration::from_millis(100));
625        assert_eq!(out.len(), 1, "should collapse to one batch");
626        assert_eq!(out[0].paths, vec![p("style.css")]);
627    }
628
629    #[test]
630    fn debounce_splits_batches_across_window_boundary() {
631        let t0 = Instant::now();
632        let events = vec![
633            (p("a.md"), t0),
634            (p("b.md"), t0 + Duration::from_millis(50)),
635            // 150 ms after first => outside the 100 ms window.
636            (p("c.md"), t0 + Duration::from_millis(150)),
637        ];
638        let out = debounce_paths(&events, Duration::from_millis(100));
639        assert_eq!(out.len(), 2);
640        assert_eq!(out[0].paths, vec![p("a.md"), p("b.md")]);
641        assert_eq!(out[1].paths, vec![p("c.md")]);
642    }
643
644    #[test]
645    fn debounce_deduplicates_paths_in_same_window() {
646        let t0 = Instant::now();
647        let events = vec![
648            (p("x"), t0),
649            (p("y"), t0 + Duration::from_millis(10)),
650            (p("x"), t0 + Duration::from_millis(20)),
651            (p("y"), t0 + Duration::from_millis(30)),
652        ];
653        let out = debounce_paths(&events, Duration::from_millis(100));
654        assert_eq!(out.len(), 1);
655        assert_eq!(out[0].paths, vec![p("x"), p("y")]);
656    }
657
658    #[test]
659    fn event_should_propagate_accepts_modify_create_remove() {
660        use notify::event::{CreateKind, ModifyKind, RemoveKind};
661        assert!(event_should_propagate(&EventKind::Create(CreateKind::File)));
662        assert!(event_should_propagate(&EventKind::Modify(ModifyKind::Any)));
663        assert!(event_should_propagate(&EventKind::Remove(RemoveKind::File)));
664    }
665
666    #[test]
667    fn event_should_propagate_rejects_access_and_other() {
668        use notify::event::AccessKind;
669        assert!(!event_should_propagate(&EventKind::Access(AccessKind::Any)));
670        assert!(!event_should_propagate(&EventKind::Other));
671    }
672
673    #[test]
674    fn change_batch_len_and_is_empty() {
675        let empty = ChangeBatch { paths: vec![] };
676        assert!(empty.is_empty());
677        assert_eq!(empty.len(), 0);
678
679        let one = ChangeBatch {
680            paths: vec![p("x")],
681        };
682        assert!(!one.is_empty());
683        assert_eq!(one.len(), 1);
684    }
685
686    #[test]
687    fn change_batch_eq_clone() {
688        let a = ChangeBatch {
689            paths: vec![p("x")],
690        };
691        let b = a.clone();
692        assert_eq!(a, b);
693    }
694
695    #[test]
696    fn default_debounce_is_100ms() {
697        assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(100));
698    }
699
700    #[test]
701    fn new_returns_err_when_path_missing() {
702        // Non-existent path — recommended_watcher may succeed but
703        // .watch() should fail.
704        let res = EventWatcher::new(Path::new("/nonexistent/ssg/test/dir"));
705        assert!(res.is_err());
706    }
707
708    #[test]
709    fn recv_outcome_batch_extracts_payload() {
710        let b = ChangeBatch {
711            paths: vec![p("a")],
712        };
713        let out = RecvOutcome::Batch(b.clone());
714        assert_eq!(out.batch(), Some(b));
715        assert!(RecvOutcome::Timeout.batch().is_none());
716        assert!(RecvOutcome::Closed.batch().is_none());
717    }
718
719    #[test]
720    fn recv_outcome_is_closed_only_for_closed() {
721        assert!(RecvOutcome::Closed.is_closed());
722        assert!(!RecvOutcome::Timeout.is_closed());
723        let b = ChangeBatch { paths: vec![] };
724        assert!(!RecvOutcome::Batch(b).is_closed());
725    }
726
727    #[test]
728    fn live_watcher_with_debounce_yields_batch_on_real_fs_event() {
729        // Live integration: create a temp dir, instantiate the watcher,
730        // touch a file, and assert we receive a batch within a reasonable
731        // window. Exercises with_debounce, the notify callback closure,
732        // debounce_loop, and recv_timeout's Batch arm.
733        let dir = tempfile::tempdir().expect("tempdir");
734        let w =
735            EventWatcher::with_debounce(dir.path(), Duration::from_millis(50))
736                .expect("watcher");
737
738        // Sanity: debounce() accessor + Debug impl.
739        assert_eq!(w.debounce(), Duration::from_millis(50));
740        let dbg = format!("{:?}", w);
741        assert!(dbg.contains("EventWatcher"));
742
743        // Write a file inside the watched dir.
744        let file = dir.path().join("a.txt");
745        std::fs::write(&file, b"hello").expect("write");
746
747        // Poll for up to ~2s — notify backends can be slow on cold start.
748        let deadline = Instant::now() + Duration::from_secs(2);
749        let mut got: Option<ChangeBatch> = None;
750        while got.is_none() && Instant::now() < deadline {
751            got = w.recv_timeout(Duration::from_millis(200)).batch();
752        }
753        // CI macOS FSEvents can rarely lose the first event for a brand
754        // new dir; don't fail the suite — just ensure we exercised the
755        // pathway without panicking.
756        assert!(got.is_none_or(|b| !b.is_empty()));
757    }
758
759    #[test]
760    fn drop_tears_down_thread_without_hanging() {
761        // Build + immediately drop. Drop must signal shutdown, drop the
762        // backend, and join the debounce thread cleanly. If the join
763        // hangs, the test framework will time out and fail.
764        let dir = tempfile::tempdir().expect("tempdir");
765        let w =
766            EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
767                .expect("watcher");
768        drop(w);
769    }
770
771    #[test]
772    fn recv_timeout_returns_timeout_when_idle() {
773        // Build a watcher on an empty tempdir, do not touch anything,
774        // and assert recv_timeout returns Timeout before the deadline.
775        let dir = tempfile::tempdir().expect("tempdir");
776        let w =
777            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
778                .expect("watcher");
779        let out = w.recv_timeout(Duration::from_millis(50));
780        // Either Timeout (typical) or Batch (if FS noise) — neither should
781        // be Closed under normal conditions.
782        assert!(!out.is_closed());
783    }
784
785    #[test]
786    fn forward_event_sends_paths_for_propagatable_events() {
787        use notify::event::CreateKind;
788        let (tx, rx) = mpsc::channel::<PathBuf>();
789        let event = Event::new(EventKind::Create(CreateKind::File))
790            .add_path(p("a.md"))
791            .add_path(p("b.md"));
792        forward_event(Ok(event), &tx);
793        assert_eq!(rx.try_recv().unwrap(), p("a.md"));
794        assert_eq!(rx.try_recv().unwrap(), p("b.md"));
795        assert!(rx.try_recv().is_err(), "no extra paths expected");
796    }
797
798    #[test]
799    fn forward_event_ignores_access_events() {
800        use notify::event::AccessKind;
801        let (tx, rx) = mpsc::channel::<PathBuf>();
802        let event =
803            Event::new(EventKind::Access(AccessKind::Any)).add_path(p("a.md"));
804        forward_event(Ok(event), &tx);
805        assert!(rx.try_recv().is_err(), "access events must be dropped");
806    }
807
808    #[test]
809    fn forward_event_ignores_backend_errors() {
810        let (tx, rx) = mpsc::channel::<PathBuf>();
811        forward_event(Err(notify::Error::generic("boom")), &tx);
812        assert!(rx.try_recv().is_err(), "errors must be swallowed");
813    }
814
815    #[test]
816    fn forward_event_survives_dropped_receiver() {
817        use notify::event::CreateKind;
818        let (tx, rx) = mpsc::channel::<PathBuf>();
819        drop(rx);
820        let event =
821            Event::new(EventKind::Create(CreateKind::File)).add_path(p("a"));
822        // Must not panic even though the send fails.
823        forward_event(Ok(event), &tx);
824    }
825
826    #[test]
827    fn forward_event_with_no_paths_sends_nothing() {
828        use notify::event::CreateKind;
829        // Every other propagatable-event test attaches at least one
830        // path, so the `for path in event.paths` loop always runs at
831        // least once elsewhere. Exercise the zero-iteration case too.
832        let (tx, rx) = mpsc::channel::<PathBuf>();
833        let event = Event::new(EventKind::Create(CreateKind::File));
834        forward_event(Ok(event), &tx);
835        assert!(rx.try_recv().is_err(), "no paths means nothing to send");
836    }
837
838    #[test]
839    fn remaining_window_returns_time_left_inside_window() {
840        assert_eq!(
841            remaining_window(
842                Duration::from_millis(100),
843                Duration::from_millis(40)
844            ),
845            Some(Duration::from_millis(60))
846        );
847    }
848
849    #[test]
850    fn remaining_window_none_when_elapsed_exceeds_window() {
851        assert_eq!(
852            remaining_window(
853                Duration::from_millis(100),
854                Duration::from_millis(150)
855            ),
856            None
857        );
858    }
859
860    #[test]
861    fn remaining_window_none_at_exact_boundary() {
862        assert_eq!(
863            remaining_window(
864                Duration::from_millis(100),
865                Duration::from_millis(100)
866            ),
867            None
868        );
869    }
870
871    #[test]
872    fn debounce_loop_exits_when_shutdown_already_signalled() {
873        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
874        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
875        let shutdown = Arc::new(Mutex::new(true));
876
877        raw_tx.send(p("a.md")).unwrap();
878        drop(raw_tx);
879        debounce_loop(raw_rx, batched_tx, Duration::from_millis(10), &shutdown);
880
881        // Shutdown short-circuits before any batch is flushed.
882        assert!(batched_rx.try_recv().is_err());
883    }
884
885    #[test]
886    fn debounce_loop_treats_poisoned_shutdown_lock_as_signalled() {
887        // Poison the `shutdown` mutex before debounce_loop ever locks
888        // it. `shutdown.lock().map_or(true, |g| *g)` must fall back to
889        // `true` (treat-as-shutting-down) on a poisoned lock rather
890        // than panicking or silently continuing.
891        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
892        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
893        let shutdown = Arc::new(Mutex::new(false));
894
895        let poison_target = Arc::clone(&shutdown);
896        let _ = thread::spawn(move || {
897            let _guard = poison_target.lock().unwrap();
898            panic!("poison shutdown for test");
899        })
900        .join();
901
902        raw_tx.send(p("a.md")).unwrap();
903        drop(raw_tx);
904        debounce_loop(raw_rx, batched_tx, Duration::from_millis(10), &shutdown);
905
906        // Poisoned lock reads as "shut down" — the loop must break
907        // immediately without flushing a batch.
908        assert!(batched_rx.try_recv().is_err());
909    }
910
911    #[test]
912    fn debounce_loop_forces_drain_at_max_batch_paths() {
913        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
914        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
915        let shutdown = Arc::new(Mutex::new(false));
916
917        for i in 0..MAX_BATCH_PATHS {
918            raw_tx.send(PathBuf::from(format!("f{i}"))).unwrap();
919        }
920        drop(raw_tx);
921        // Long window: the cap — not the clock — must force the drain.
922        debounce_loop(raw_rx, batched_tx, Duration::from_secs(60), &shutdown);
923
924        let batch = batched_rx.try_recv().expect("capped batch flushed");
925        assert_eq!(batch.len(), MAX_BATCH_PATHS);
926    }
927
928    #[test]
929    fn debounce_loop_flushes_pending_batch_on_disconnect() {
930        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
931        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
932        let shutdown = Arc::new(Mutex::new(false));
933
934        raw_tx.send(p("only.md")).unwrap();
935        drop(raw_tx);
936        // Sender gone mid-window: the pending set must still be flushed.
937        debounce_loop(raw_rx, batched_tx, Duration::from_secs(60), &shutdown);
938
939        let batch = batched_rx.try_recv().expect("final batch flushed");
940        assert_eq!(batch.paths, vec![p("only.md")]);
941    }
942
943    #[test]
944    fn debounce_loop_exits_when_batched_receiver_dropped() {
945        let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
946        let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
947        drop(batched_rx);
948        let shutdown = Arc::new(Mutex::new(false));
949
950        let handle = thread::spawn(move || {
951            debounce_loop(
952                raw_rx,
953                batched_tx,
954                Duration::from_millis(10),
955                &shutdown,
956            );
957        });
958        raw_tx.send(p("a.md")).unwrap();
959        // The loop drains via timeout, fails to deliver the batch, and
960        // breaks out — the join must therefore complete.
961        handle.join().expect("debounce thread exits cleanly");
962    }
963
964    #[test]
965    fn recv_returns_batch_on_live_event() {
966        // recv() blocks, so drive it from a helper thread while the
967        // main thread generates filesystem events. Tolerant of lost
968        // FSEvents on cold-start: the helper is detached on timeout.
969        let dir = tempfile::tempdir().expect("tempdir");
970        let w =
971            EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
972                .expect("watcher");
973
974        let (tx, rx) = mpsc::channel::<bool>();
975        let handle = thread::spawn(move || {
976            let got = w.recv();
977            let _ = tx.send(got.is_some());
978            drop(w);
979        });
980
981        let deadline = Instant::now() + Duration::from_secs(3);
982        let mut outcome: Option<bool> = None;
983        while outcome.is_none() && Instant::now() < deadline {
984            std::fs::write(dir.path().join("touch.md"), b"x").expect("write");
985            outcome = rx.recv_timeout(Duration::from_millis(100)).ok();
986        }
987        // When the event arrived, recv() must have yielded a batch and
988        // the helper thread must be joinable.
989        assert!(outcome.is_none_or(|got| got));
990        if outcome.is_some() {
991            handle.join().expect("recv thread exits");
992        }
993    }
994
995    #[test]
996    fn drop_recovers_from_poisoned_internal_locks() {
997        // Poison all three internal mutexes, then drop. Drop must not
998        // panic — every lock() failure path is exercised.
999        let dir = tempfile::tempdir().expect("tempdir");
1000        let w =
1001            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
1002                .expect("watcher");
1003
1004        let shutdown = Arc::clone(&w.shutdown);
1005        let _ = thread::spawn(move || {
1006            let _guard = shutdown.lock().unwrap();
1007            panic!("poison shutdown");
1008        })
1009        .join();
1010
1011        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1012            let _guard = w.backend.lock().unwrap();
1013            panic!("poison backend");
1014        }));
1015        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1016            let _guard = w.debounce_handle.lock().unwrap();
1017            panic!("poison handle");
1018        }));
1019
1020        drop(w); // must not panic or hang
1021    }
1022
1023    #[test]
1024    fn drop_tolerates_already_taken_debounce_handle() {
1025        let dir = tempfile::tempdir().expect("tempdir");
1026        let w =
1027            EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
1028                .expect("watcher");
1029
1030        // Steal the join handle so Drop sees `None`.
1031        let handle = w.debounce_handle.lock().unwrap().take();
1032        drop(w);
1033
1034        // The debounce thread exits once the backend (and raw_tx) is
1035        // gone; join it ourselves so nothing leaks.
1036        handle
1037            .expect("handle present")
1038            .join()
1039            .expect("thread exits");
1040    }
1041
1042    #[cfg(feature = "test-fault-injection")]
1043    mod fault_injection {
1044        use super::*;
1045
1046        #[test]
1047        fn with_debounce_surfaces_backend_init_failure() {
1048            let _guard = fault::arm("event-watch::backend-init");
1049            let dir = tempfile::tempdir().expect("tempdir");
1050            let err = EventWatcher::new(dir.path())
1051                .expect_err("backend init failure must propagate");
1052            assert!(format!("{err}").contains("notify watcher init"));
1053        }
1054
1055        #[test]
1056        fn with_debounce_surfaces_debounce_spawn_failure() {
1057            let _guard = fault::arm("event-watch::debounce-spawn");
1058            let dir = tempfile::tempdir().expect("tempdir");
1059            let err = EventWatcher::new(dir.path())
1060                .expect_err("spawn failure must propagate");
1061            assert!(format!("{err}").contains("debounce thread spawn"));
1062        }
1063    }
1064
1065    #[test]
1066    fn debounce_paths_caps_at_window_with_widely_spaced_events() {
1067        // Each event 200 ms apart with a 100 ms window => one batch per
1068        // event, exercising the "Some(_)" arm that flushes and restarts.
1069        let t0 = Instant::now();
1070        let events = vec![
1071            (p("a"), t0),
1072            (p("b"), t0 + Duration::from_millis(200)),
1073            (p("c"), t0 + Duration::from_millis(400)),
1074        ];
1075        let out = debounce_paths(&events, Duration::from_millis(100));
1076        assert_eq!(out.len(), 3);
1077    }
1078}