1use 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
54pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(100);
58
59pub const MAX_BATCH_PATHS: usize = 10_000;
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ChangeBatch {
67 pub paths: Vec<PathBuf>,
69}
70
71impl ChangeBatch {
72 #[must_use]
82 pub const fn is_empty(&self) -> bool {
83 self.paths.is_empty()
84 }
85
86 #[must_use]
97 pub const fn len(&self) -> usize {
98 self.paths.len()
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum RecvOutcome {
105 Batch(ChangeBatch),
107 Timeout,
109 Closed,
112}
113
114impl RecvOutcome {
115 #[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 #[must_use]
144 pub const fn is_closed(&self) -> bool {
145 matches!(self, Self::Closed)
146 }
147}
148
149pub struct EventWatcher {
155 backend: Mutex<Option<RecommendedWatcher>>,
158 rx: Receiver<ChangeBatch>,
160 debounce_handle: Mutex<Option<JoinHandle<()>>>,
162 shutdown: Arc<Mutex<bool>>,
164 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 pub fn new(dir: &Path) -> Result<Self, SsgError> {
195 Self::with_debounce(dir, DEFAULT_DEBOUNCE)
196 }
197
198 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 #[must_use]
276 pub fn recv(&self) -> Option<ChangeBatch> {
277 self.rx.recv().ok()
278 }
279
280 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 #[cfg(test)]
309 pub(crate) fn close_backend_for_test(&self) {
310 let _ = self.backend.lock().map(|mut b| *b = None);
311 }
312
313 #[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 if let Ok(mut s) = self.shutdown.lock() {
334 *s = true;
335 }
336 if let Ok(mut backend) = self.backend.lock() {
339 *backend = None;
340 }
341 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#[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
372fn 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 let _ = raw_tx.send(path);
384 }
385 }
386 }
387}
388
389#[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 pub(super) fn arm(name: &'static str) -> ArmGuard {
405 ARMED.with(|a| a.set(Some(name)));
406 ArmGuard
407 }
408
409 pub(super) fn armed(name: &str) -> bool {
411 ARMED.with(|a| a.get() == Some(name))
412 }
413
414 #[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
425fn 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
440fn 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
455fn 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
467fn debounce_loop(
473 raw_rx: Receiver<PathBuf>,
474 batched_tx: Sender<ChangeBatch>,
475 window: Duration,
476 shutdown: &Arc<Mutex<bool>>,
477) {
478 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 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 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#[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 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 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 (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 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 let dir = tempfile::tempdir().expect("tempdir");
734 let w =
735 EventWatcher::with_debounce(dir.path(), Duration::from_millis(50))
736 .expect("watcher");
737
738 assert_eq!(w.debounce(), Duration::from_millis(50));
740 let dbg = format!("{:?}", w);
741 assert!(dbg.contains("EventWatcher"));
742
743 let file = dir.path().join("a.txt");
745 std::fs::write(&file, b"hello").expect("write");
746
747 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 assert!(got.is_none_or(|b| !b.is_empty()));
757 }
758
759 #[test]
760 fn drop_tears_down_thread_without_hanging() {
761 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 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 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 forward_event(Ok(event), &tx);
824 }
825
826 #[test]
827 fn forward_event_with_no_paths_sends_nothing() {
828 use notify::event::CreateKind;
829 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 assert!(batched_rx.try_recv().is_err());
883 }
884
885 #[test]
886 fn debounce_loop_treats_poisoned_shutdown_lock_as_signalled() {
887 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 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 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 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 handle.join().expect("debounce thread exits cleanly");
962 }
963
964 #[test]
965 fn recv_returns_batch_on_live_event() {
966 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 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 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); }
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 let handle = w.debounce_handle.lock().unwrap().take();
1032 drop(w);
1033
1034 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 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}