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!(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 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 (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 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 let dir = tempfile::tempdir().expect("tempdir");
730 let w =
731 EventWatcher::with_debounce(dir.path(), Duration::from_millis(50))
732 .expect("watcher");
733
734 assert_eq!(w.debounce(), Duration::from_millis(50));
736 let dbg = format!("{:?}", w);
737 assert!(dbg.contains("EventWatcher"));
738
739 let file = dir.path().join("a.txt");
741 std::fs::write(&file, b"hello").expect("write");
742
743 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 assert!(got.is_none_or(|b| !b.is_empty()));
753 }
754
755 #[test]
756 fn drop_tears_down_thread_without_hanging() {
757 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 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 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 forward_event(Ok(event), &tx);
820 }
821
822 #[test]
823 fn forward_event_with_no_paths_sends_nothing() {
824 use notify::event::CreateKind;
825 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 assert!(batched_rx.try_recv().is_err());
879 }
880
881 #[test]
882 fn debounce_loop_treats_poisoned_shutdown_lock_as_signalled() {
883 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 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 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 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 handle.join().expect("debounce thread exits cleanly");
958 }
959
960 #[test]
961 fn recv_returns_batch_on_live_event() {
962 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 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 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); }
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 let handle = w.debounce_handle.lock().unwrap().take();
1028 drop(w);
1029
1030 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 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}