Skip to main content

fff_search/watcher/
watch.rs

1use crate::error::Error;
2use crate::index::constraints::{GlobPattern, compile_one, glob_matches_into};
3use parking_lot::Mutex;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::mpsc;
8use tracing::{debug, error};
9
10/// Watcher subscription/watch id
11#[repr(transparent)]
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct WatchId(pub u64);
14
15pub(crate) type WatchCallback = Box<dyn Fn(WatchId, &[WatchEvent]) + Send + Sync>;
16
17/// The kind of filesystem change.
18///
19/// Event kinds are normalized on a best-effort basis. Editors and operating
20/// systems may represent the same operation with different native events.
21#[repr(u8)]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum WatchEventKind {
24    Created = 0,
25    Modified = 1,
26    Removed = 2,
27    /// Individual events were lost; rescan the reported path.
28    Rescan = 3,
29}
30
31impl WatchEventKind {
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            WatchEventKind::Created => "created",
35            WatchEventKind::Modified => "modified",
36            WatchEventKind::Removed => "removed",
37            WatchEventKind::Rescan => "rescan",
38        }
39    }
40}
41
42/// A single change notification delivered to subscribers.
43#[derive(Debug, Clone)]
44pub struct WatchEvent {
45    /// Absolute affected path (the indexed base path for `Rescan`).
46    pub path: PathBuf,
47    pub kind: WatchEventKind,
48}
49
50/// Per-subscription options.
51#[derive(Debug, Clone, Default)]
52pub struct WatchOptions {
53    /// Additional glob or path-prefix exclusions.
54    pub ignore: Vec<String>,
55}
56
57type WatchMask = u128;
58const MAX_BATCH_EVENTS: usize = WatchMask::BITS as usize;
59
60pub(crate) struct RawWatchEvent {
61    pub(crate) path: PathBuf,
62    pub(crate) kind: WatchEventKind,
63    pub(crate) is_ignored: bool,
64}
65
66enum WatchMatcher {
67    Glob(GlobPattern),
68    Exact(PathBuf),
69    Dir(PathBuf),
70}
71
72impl WatchMatcher {
73    fn new(pattern: &str, base: &Path) -> Result<Self, Error> {
74        let pattern = pattern.trim();
75        if pattern.is_empty() {
76            return Ok(WatchMatcher::Dir(PathBuf::new()));
77        }
78
79        let Some(relative) = relative_pattern(pattern, base) else {
80            return Err(Error::InvalidGlobPattern {
81                pattern: pattern.to_string(),
82                reason: "watch patterns must be inside the indexed base path".into(),
83            });
84        };
85
86        if fff_query_parser::glob_detect::has_wildcards(pattern) {
87            let glob = relative.to_string_lossy().replace('\\', "/");
88            return compile_one(&glob).map(WatchMatcher::Glob).ok_or_else(|| {
89                Error::InvalidGlobPattern {
90                    pattern: pattern.to_string(),
91                    reason: "failed to compile glob".into(),
92                }
93            });
94        }
95
96        if base.join(&relative).is_dir() {
97            return Ok(WatchMatcher::Dir(relative));
98        }
99
100        Ok(WatchMatcher::Exact(relative))
101    }
102}
103
104#[derive(Default)]
105struct SubIgnore {
106    globs: Vec<GlobPattern>,
107    prefixes: Vec<PathBuf>,
108}
109
110impl SubIgnore {
111    fn prefix_matches(&self, path: &Path) -> bool {
112        self.prefixes.iter().any(|prefix| path.starts_with(prefix))
113    }
114}
115
116fn relative_pattern(pattern: &str, base: &Path) -> Option<PathBuf> {
117    let expanded = crate::path_utils::expand_tilde(pattern);
118    let relative = if expanded.is_absolute() || expanded.has_root() {
119        match expanded.strip_prefix(base) {
120            Ok(rel) => rel,
121            // Windows: the caller may pass an 8.3 short-name or differently
122            // cased path; canonicalize and retry before rejecting.
123            Err(_) => {
124                let canonical = crate::path_utils::canonicalize(&expanded).ok()?;
125                return relative_from_canonical(&canonical, base);
126            }
127        }
128    } else {
129        &expanded
130    };
131
132    reject_parent_components(relative)
133}
134
135fn relative_from_canonical(canonical: &Path, base: &Path) -> Option<PathBuf> {
136    let relative = canonical.strip_prefix(base).ok()?;
137    reject_parent_components(relative)
138}
139
140fn reject_parent_components(path: &Path) -> Option<PathBuf> {
141    if path
142        .components()
143        .any(|component| component == std::path::Component::ParentDir)
144    {
145        return None;
146    }
147
148    Some(path.components().collect())
149}
150
151fn resolve_sub_ignore(patterns: &[String], base: &Path) -> Result<SubIgnore, Error> {
152    let mut ignore = SubIgnore::default();
153
154    for pattern in patterns {
155        let pattern = pattern.trim();
156        if pattern.is_empty() {
157            continue;
158        }
159        let Some(relative) = relative_pattern(pattern, base) else {
160            return Err(Error::InvalidGlobPattern {
161                pattern: pattern.to_string(),
162                reason: "ignore patterns must be inside the indexed base path".into(),
163            });
164        };
165
166        if fff_query_parser::glob_detect::has_wildcards(pattern) {
167            match compile_one(&relative.to_string_lossy().replace('\\', "/")) {
168                Some(compiled) => ignore.globs.push(compiled),
169                None => {
170                    return Err(Error::InvalidGlobPattern {
171                        pattern: pattern.to_string(),
172                        reason: "failed to compile ignore glob".into(),
173                    });
174                }
175            }
176        } else {
177            ignore.prefixes.push(relative);
178        }
179    }
180
181    Ok(ignore)
182}
183
184struct WatchSub {
185    id: WatchId,
186    matcher: WatchMatcher,
187    ignore: SubIgnore,
188    callback: WatchCallback,
189    active: AtomicBool,
190    epoch: AtomicU64,
191}
192
193impl WatchSub {
194    fn filter_mask(&self, paths: &[&str], scratch: &mut Vec<usize>) -> WatchMask {
195        let mut mask = 0;
196
197        match &self.matcher {
198            WatchMatcher::Glob(g) => {
199                scratch.clear();
200                glob_matches_into(g, paths, scratch);
201                for &index in scratch.iter() {
202                    mask |= 1 << index;
203                }
204            }
205            WatchMatcher::Dir(d) => {
206                for (index, path) in paths.iter().enumerate() {
207                    if Path::new(path).starts_with(d) {
208                        mask |= 1 << index;
209                    }
210                }
211            }
212            WatchMatcher::Exact(p) => {
213                for (index, path) in paths.iter().enumerate() {
214                    if Path::new(path) == p {
215                        mask |= 1 << index;
216                    }
217                }
218            }
219        }
220
221        // Subtract per-subscription ignores from the match mask.
222        for g in &self.ignore.globs {
223            scratch.clear();
224            glob_matches_into(g, paths, scratch);
225            for &index in scratch.iter() {
226                mask &= !(1 << index);
227            }
228        }
229        if !self.ignore.prefixes.is_empty() {
230            for (index, path) in paths.iter().enumerate() {
231                if self.ignore.prefix_matches(Path::new(path)) {
232                    mask &= !(1 << index);
233                }
234            }
235        }
236
237        mask
238    }
239}
240
241struct CallbackDelivery {
242    sub: Arc<WatchSub>,
243    events: Vec<WatchEvent>,
244    epoch: u64,
245}
246
247enum CallbackMessage {
248    Deliver(Vec<CallbackDelivery>),
249    // used to drain all the callbacks and close the sender right after
250    Drain(mpsc::Sender<()>),
251    Stop,
252}
253
254#[derive(Default)]
255struct CallbackDispatcherState {
256    sender: Option<mpsc::Sender<CallbackMessage>>,
257    thread: Option<std::thread::JoinHandle<()>>,
258}
259
260#[derive(Default)]
261struct CallbackDispatcher {
262    state: Mutex<CallbackDispatcherState>,
263}
264
265impl CallbackDispatcher {
266    /// We have to use a separate thread becuause the callback is the actual C function pointer which
267    /// can block on the user side, we can not allow our watcher logic to get into deadlocked state
268    fn start(&self) -> Result<(), Error> {
269        let mut state = self.state.lock();
270        if state.sender.is_some() {
271            return Ok(());
272        }
273
274        let (sender, receiver) = mpsc::channel();
275        let thread = std::thread::Builder::new()
276            .name("fff-watch-callback".into())
277            .spawn(move || {
278                while let Ok(message) = receiver.recv() {
279                    match message {
280                        CallbackMessage::Deliver(deliveries) => {
281                            for delivery in deliveries {
282                                if !delivery.sub.active.load(Ordering::Acquire)
283                                    || delivery.sub.epoch.load(Ordering::Acquire) != delivery.epoch
284                                {
285                                    continue;
286                                }
287
288                                let id = delivery.sub.id;
289                                let result =
290                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
291                                        (delivery.sub.callback)(id, &delivery.events)
292                                    }));
293                                if result.is_err() {
294                                    error!(sub = id.0, "watch callback panicked");
295                                }
296                            }
297                        }
298                        CallbackMessage::Drain(done) => {
299                            let _ = done.send(());
300                        }
301                        CallbackMessage::Stop => break,
302                    }
303                }
304            })
305            .map_err(Error::WatchDispatcherStart)?;
306
307        state.sender = Some(sender);
308        state.thread = Some(thread);
309        Ok(())
310    }
311
312    fn deliver(&self, deliveries: Vec<CallbackDelivery>) {
313        if deliveries.is_empty() {
314            return;
315        }
316        let Some(sender) = self.state.lock().sender.clone() else {
317            error!("watch callback dispatcher is not running");
318            return;
319        };
320        if sender.send(CallbackMessage::Deliver(deliveries)).is_err() {
321            error!("watch callback dispatcher stopped unexpectedly");
322        }
323    }
324
325    fn drain(&self) {
326        let (sender, is_dispatch_thread) = {
327            let state = self.state.lock();
328            let Some(sender) = state.sender.as_ref() else {
329                return;
330            };
331            let is_dispatch_thread = state
332                .thread
333                .as_ref()
334                .is_some_and(|thread| thread.thread().id() == std::thread::current().id());
335            (sender.clone(), is_dispatch_thread)
336        };
337
338        if is_dispatch_thread {
339            return;
340        }
341
342        let (done_tx, done_rx) = mpsc::channel();
343        if sender.send(CallbackMessage::Drain(done_tx)).is_ok() {
344            let _ = done_rx.recv();
345        }
346    }
347}
348
349impl Drop for CallbackDispatcher {
350    fn drop(&mut self) {
351        let state = self.state.get_mut();
352        if let Some(sender) = state.sender.take() {
353            let _ = sender.send(CallbackMessage::Stop);
354        }
355
356        if let Some(thread) = state.thread.take()
357            && thread.thread().id() != std::thread::current().id()
358        {
359            let _ = thread.join();
360        }
361    }
362}
363
364#[derive(Default)]
365struct WatchRegistryState {
366    subs: Vec<Arc<WatchSub>>,
367    base_path: Option<PathBuf>,
368    epoch: u64,
369}
370
371// External subscribers for one SharedFilePicker.
372#[derive(Default)]
373pub(crate) struct WatchRegistry {
374    state: Mutex<WatchRegistryState>,
375    dispatcher: CallbackDispatcher,
376}
377
378// Process-wide ids let FFI clients route all instances through one map.
379static NEXT_WATCH_ID: AtomicU64 = AtomicU64::new(1);
380
381impl WatchRegistry {
382    #[inline]
383    pub(crate) fn is_active(&self) -> bool {
384        !self.state.lock().subs.is_empty()
385    }
386
387    pub(crate) fn subscribe(
388        &self,
389        base_path: &Path,
390        pattern: &str,
391        options: WatchOptions,
392        callback: WatchCallback,
393    ) -> Result<WatchId, Error> {
394        let matcher = WatchMatcher::new(pattern, base_path)?;
395        let ignore = resolve_sub_ignore(&options.ignore, base_path)?;
396
397        let mut state = self.state.lock();
398        if state.base_path.as_deref() != Some(base_path) {
399            return Err(Error::WatchBaseChanged);
400        }
401        self.dispatcher.start()?;
402
403        let id = WatchId(NEXT_WATCH_ID.fetch_add(1, Ordering::Relaxed));
404        let sub = Arc::new(WatchSub {
405            id,
406            matcher,
407            ignore,
408            callback,
409            active: AtomicBool::new(true),
410            epoch: AtomicU64::new(state.epoch),
411        });
412
413        state.subs.push(sub);
414        Ok(id)
415    }
416
417    pub(crate) fn unsubscribe(&self, id: WatchId) -> bool {
418        let mut state = self.state.lock();
419        let Some(idx) = state.subs.iter().position(|s| s.id == id) else {
420            return false;
421        };
422        let sub = state.subs.swap_remove(idx);
423        sub.active.store(false, Ordering::Release);
424        drop(state);
425        drop(sub);
426        true
427    }
428
429    pub(crate) fn contains(&self, id: WatchId) -> bool {
430        self.state.lock().subs.iter().any(|sub| sub.id == id)
431    }
432
433    pub(crate) fn shutdown(&self) {
434        let mut state = self.state.lock();
435        let subs = std::mem::take(&mut state.subs);
436        for sub in &subs {
437            sub.active.store(false, Ordering::Release);
438        }
439        drop(state);
440    }
441
442    pub(crate) fn shutdown_and_wait(&self) {
443        self.shutdown();
444        self.dispatcher.drain();
445    }
446
447    pub(crate) fn rebase(&self, base_path: &Path) {
448        let mut state = self.state.lock();
449        if state.base_path.as_deref() == Some(base_path) {
450            return;
451        }
452
453        state.base_path = Some(base_path.to_path_buf());
454        state.epoch = state.epoch.wrapping_add(1);
455        for sub in &state.subs {
456            sub.epoch.store(state.epoch, Ordering::Release);
457        }
458        drop(state);
459        self.dispatcher.drain();
460    }
461
462    pub(crate) fn dispatch(&self, base_path: &Path, events: Vec<RawWatchEvent>) {
463        if events.is_empty() {
464            return;
465        }
466
467        let state = self.state.lock();
468        if state.subs.is_empty() || state.base_path.as_deref() != Some(base_path) {
469            return;
470        }
471
472        for batch in events.chunks(MAX_BATCH_EVENTS) {
473            let mut paths = Vec::with_capacity(batch.len());
474            let mut visible_mask = 0;
475            let mut rescan_mask = 0;
476
477            for (index, event) in batch.iter().enumerate() {
478                let relative = event
479                    .path
480                    .strip_prefix(base_path)
481                    .expect("watch event path must be inside the indexed base path");
482                paths.push(relative.to_string_lossy().replace('\\', "/"));
483
484                let bit = 1 << index;
485                if event.kind == WatchEventKind::Rescan {
486                    rescan_mask |= bit;
487                } else if !event.is_ignored {
488                    visible_mask |= bit;
489                }
490            }
491
492            let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
493            let mut scratch = Vec::new();
494            let mut deliveries = Vec::with_capacity(state.subs.len());
495            for sub in &state.subs {
496                let matched = sub.filter_mask(&path_refs, &mut scratch);
497                let mut delivery_mask = (matched & visible_mask) | rescan_mask;
498                if delivery_mask == 0 {
499                    continue;
500                }
501
502                let mut filtered = Vec::with_capacity(delivery_mask.count_ones() as usize);
503                while delivery_mask != 0 {
504                    let index = delivery_mask.trailing_zeros() as usize;
505                    let event = &batch[index];
506                    filtered.push(WatchEvent {
507                        path: event.path.clone(),
508                        kind: event.kind,
509                    });
510                    delivery_mask &= delivery_mask - 1;
511                }
512
513                debug!(
514                    sub = sub.id.0,
515                    count = filtered.len(),
516                    "queueing watch events"
517                );
518                deliveries.push(CallbackDelivery {
519                    sub: Arc::clone(sub),
520                    events: filtered,
521                    epoch: state.epoch,
522                });
523            }
524            self.dispatcher.deliver(deliveries);
525        }
526    }
527
528    // Signal that individual events were lost.
529    pub(crate) fn dispatch_rescan(&self, base_path: &Path) {
530        self.dispatch(
531            base_path,
532            vec![RawWatchEvent {
533                path: base_path.to_path_buf(),
534                kind: WatchEventKind::Rescan,
535                is_ignored: false,
536            }],
537        );
538    }
539}
540
541impl Drop for WatchRegistry {
542    fn drop(&mut self) {
543        self.shutdown();
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use parking_lot::{Condvar, Mutex};
551    use std::sync::atomic::{AtomicBool, AtomicUsize};
552    use std::time::Duration;
553
554    fn raw(path: &str, kind: WatchEventKind, is_ignored: bool) -> RawWatchEvent {
555        RawWatchEvent {
556            path: PathBuf::from(path),
557            kind,
558            is_ignored,
559        }
560    }
561
562    fn registry(base: &Path) -> Arc<WatchRegistry> {
563        let registry = Arc::new(WatchRegistry::default());
564        registry.rebase(base);
565        registry
566    }
567
568    type Collected = Arc<Mutex<Vec<WatchEvent>>>;
569
570    // Appends every delivered event.
571    fn collector() -> (WatchCallback, Collected) {
572        let collected: Collected = Arc::new(Mutex::new(Vec::new()));
573        let sink = Arc::clone(&collected);
574        let cb: WatchCallback = Box::new(move |_id, events| sink.lock().extend_from_slice(events));
575        (cb, collected)
576    }
577
578    // Dispatch is asynchronous.
579    fn wait_events(collected: &Collected, n: usize) -> Vec<WatchEvent> {
580        for _ in 0..1000 {
581            if collected.lock().len() >= n {
582                break;
583            }
584            std::thread::sleep(std::time::Duration::from_millis(5));
585        }
586        collected.lock().clone()
587    }
588
589    #[test]
590    fn dispatcher_starts_on_first_subscription() {
591        let base = Path::new("/repo");
592        let registry = WatchRegistry::default();
593        registry.rebase(base);
594
595        assert!(registry.dispatcher.state.lock().thread.is_none());
596        let (cb, _) = collector();
597        registry
598            .subscribe(base, "**", WatchOptions::default(), cb)
599            .unwrap();
600        assert!(registry.dispatcher.state.lock().thread.is_some());
601    }
602
603    #[test]
604    fn resolve_relative_glob() {
605        let base = Path::new("/repo");
606        assert!(matches!(
607            WatchMatcher::new("./**/*.rs", base).unwrap(),
608            WatchMatcher::Glob(_)
609        ));
610        assert!(matches!(
611            WatchMatcher::new("src/*.ts", base).unwrap(),
612            WatchMatcher::Glob(_)
613        ));
614    }
615
616    #[test]
617    fn reject_absolute_glob_outside_base() {
618        assert!(WatchMatcher::new("/other/**/*.rs", Path::new("/repo")).is_err());
619    }
620
621    #[test]
622    fn resolve_exact_paths() {
623        let base = std::env::temp_dir();
624        let inside = base.join("some_file.txt");
625        match WatchMatcher::new(inside.to_str().unwrap(), &base).unwrap() {
626            WatchMatcher::Exact(path) => assert_eq!(path, Path::new("some_file.txt")),
627            _ => panic!("expected exact"),
628        }
629        match WatchMatcher::new("relative_file.txt", &base).unwrap() {
630            WatchMatcher::Exact(path) => assert_eq!(path, Path::new("relative_file.txt")),
631            _ => panic!("expected exact"),
632        }
633        assert!(WatchMatcher::new("../outside", &base).is_err());
634    }
635
636    #[test]
637    fn resolve_existing_dir_as_subtree() {
638        let tmp = tempfile::TempDir::new().unwrap();
639        let base = tmp.path().to_path_buf();
640        std::fs::create_dir(base.join("src")).unwrap();
641
642        match WatchMatcher::new("src", &base).unwrap() {
643            WatchMatcher::Dir(path) => assert_eq!(path, Path::new("src")),
644            _ => panic!("expected dir"),
645        }
646        match WatchMatcher::new(base.to_str().unwrap(), &base).unwrap() {
647            WatchMatcher::Dir(path) => assert!(path.as_os_str().is_empty()),
648            _ => panic!("expected dir"),
649        }
650    }
651
652    #[test]
653    fn resolve_empty_pattern_as_whole_tree() {
654        let tmp = tempfile::TempDir::new().unwrap();
655        let base = tmp.path().to_path_buf();
656
657        for pattern in ["", "   "] {
658            match WatchMatcher::new(pattern, &base).unwrap() {
659                WatchMatcher::Dir(path) => assert!(path.as_os_str().is_empty()),
660                _ => panic!("expected dir"),
661            }
662        }
663    }
664
665    #[test]
666    fn mixed_batch_dispatch_preserves_order_and_filters() {
667        let base = Path::new("/repo");
668        let registry = registry(base);
669
670        let (glob_cb, glob_events) = collector();
671        registry
672            .subscribe(
673                base,
674                "**/*.rs",
675                WatchOptions {
676                    ignore: vec!["src/vendor".into(), "*.gen.rs".into()],
677                },
678                glob_cb,
679            )
680            .unwrap();
681        let (dir_cb, dir_events) = collector();
682        registry
683            .subscribe(base, "src/**", WatchOptions::default(), dir_cb)
684            .unwrap();
685        let (exact_cb, exact_events) = collector();
686        registry
687            .subscribe(base, "dist/out.js", WatchOptions::default(), exact_cb)
688            .unwrap();
689
690        registry.dispatch(
691            base,
692            vec![
693                raw("/repo/src/a.rs", WatchEventKind::Created, false),
694                raw("/repo/src/b.gen.rs", WatchEventKind::Modified, false),
695                raw("/repo/src/vendor/c.rs", WatchEventKind::Modified, false),
696                raw("/repo/lib/d.rs", WatchEventKind::Removed, false),
697                raw("/repo/dist/out.js", WatchEventKind::Modified, true), // index-ignored
698                raw("/repo/src/e.txt", WatchEventKind::Created, true),    // index-ignored
699            ],
700        );
701
702        let glob = wait_events(&glob_events, 2);
703        let paths: Vec<_> = glob.iter().map(|e| e.path.clone()).collect();
704        assert_eq!(
705            paths,
706            vec![
707                PathBuf::from("/repo/src/a.rs"),
708                PathBuf::from("/repo/lib/d.rs"),
709            ]
710        );
711
712        let dir = wait_events(&dir_events, 3);
713        let paths: Vec<_> = dir.iter().map(|e| e.path.clone()).collect();
714        assert_eq!(
715            paths,
716            vec![
717                PathBuf::from("/repo/src/a.rs"),
718                PathBuf::from("/repo/src/b.gen.rs"),
719                PathBuf::from("/repo/src/vendor/c.rs"),
720            ]
721        );
722
723        assert!(exact_events.lock().is_empty());
724    }
725
726    #[test]
727    fn rescan_is_broadcast_to_every_subscription() {
728        let base = Path::new("/repo");
729        let registry = registry(base);
730        let (glob_cb, glob_events) = collector();
731        let (exact_cb, exact_events) = collector();
732        registry
733            .subscribe(base, "src/**", WatchOptions::default(), glob_cb)
734            .unwrap();
735        registry
736            .subscribe(base, "dist/out.js", WatchOptions::default(), exact_cb)
737            .unwrap();
738
739        registry.dispatch_rescan(base);
740
741        for events in [&glob_events, &exact_events] {
742            let events = wait_events(events, 1);
743            assert_eq!(events.len(), 1);
744            assert_eq!(events[0].path, base);
745            assert_eq!(events[0].kind, WatchEventKind::Rescan);
746        }
747    }
748
749    #[test]
750    fn registry_dispatch_matches_glob_and_batches() {
751        let base = Path::new("/repo");
752        let registry = registry(base);
753        let hits = Arc::new(Mutex::new(Vec::<WatchEvent>::new()));
754        let calls = Arc::new(AtomicUsize::new(0));
755
756        let hits_cb = Arc::clone(&hits);
757        let calls_cb = Arc::clone(&calls);
758        let id = registry
759            .subscribe(
760                base,
761                "**/*.rs",
762                WatchOptions::default(),
763                Box::new(move |_id, events| {
764                    calls_cb.fetch_add(1, Ordering::SeqCst);
765                    hits_cb.lock().extend_from_slice(events);
766                }),
767            )
768            .unwrap();
769
770        registry.dispatch(
771            base,
772            vec![
773                raw("/repo/src/a.rs", WatchEventKind::Modified, false),
774                raw("/repo/src/b.ts", WatchEventKind::Modified, false),
775                raw("/repo/target/c.rs", WatchEventKind::Created, true),
776            ],
777        );
778
779        for _ in 0..400 {
780            if calls.load(Ordering::SeqCst) == 1 {
781                break;
782            }
783            std::thread::sleep(Duration::from_millis(5));
784        }
785        let events = hits.lock();
786        // ignored + non-matching + out-of-tree are all filtered, in ONE call
787        assert_eq!(calls.load(Ordering::SeqCst), 1);
788        assert_eq!(events.len(), 1);
789        assert_eq!(events[0].path, PathBuf::from("/repo/src/a.rs"));
790
791        assert!(registry.unsubscribe(id));
792        assert!(!registry.is_active());
793        assert!(!registry.unsubscribe(id));
794    }
795
796    #[test]
797    fn large_batch_is_delivered_without_coalescing() {
798        let base = Path::new("/repo");
799        let registry = registry(base);
800
801        let collected: Collected = Arc::new(Mutex::new(Vec::new()));
802        let batch_sizes = Arc::new(Mutex::new(Vec::new()));
803        let collected_cb = Arc::clone(&collected);
804        let batch_sizes_cb = Arc::clone(&batch_sizes);
805        registry
806            .subscribe(
807                base,
808                "**/*.rs",
809                WatchOptions::default(),
810                Box::new(move |_, events| {
811                    batch_sizes_cb.lock().push(events.len());
812                    collected_cb.lock().extend_from_slice(events);
813                }),
814            )
815            .unwrap();
816
817        let events: Vec<RawWatchEvent> = (0..257)
818            .map(|i| {
819                raw(
820                    &format!("/repo/src/f{i}.rs"),
821                    WatchEventKind::Modified,
822                    false,
823                )
824            })
825            .collect();
826        registry.dispatch(base, events);
827
828        let delivered = wait_events(&collected, 257);
829        assert_eq!(delivered.len(), 257);
830        assert!(
831            delivered
832                .iter()
833                .all(|event| event.kind == WatchEventKind::Modified)
834        );
835        assert_eq!(*batch_sizes.lock(), vec![128, 128, 1]);
836    }
837
838    #[test]
839    fn duplicate_paths_are_delivered_in_order() {
840        let base = Path::new("/repo");
841        let registry = registry(base);
842        let (cb, collected) = collector();
843        registry
844            .subscribe(base, "**", WatchOptions::default(), cb)
845            .unwrap();
846
847        registry.dispatch(
848            base,
849            vec![
850                raw("/repo/a.rs", WatchEventKind::Created, false),
851                raw("/repo/a.rs", WatchEventKind::Modified, false),
852                raw("/repo/a.rs", WatchEventKind::Removed, false),
853            ],
854        );
855
856        let delivered = wait_events(&collected, 3);
857        let kinds: Vec<_> = delivered.iter().map(|event| event.kind).collect();
858        assert_eq!(
859            kinds,
860            vec![
861                WatchEventKind::Created,
862                WatchEventKind::Modified,
863                WatchEventKind::Removed,
864            ]
865        );
866    }
867
868    #[test]
869    fn rebase_keeps_relative_subscriptions() {
870        let old_base = Path::new("/old");
871        let new_base = Path::new("/new");
872        let registry = registry(old_base);
873        let (cb, collected) = collector();
874        let id = registry
875            .subscribe(old_base, "src/**", WatchOptions::default(), cb)
876            .unwrap();
877
878        registry.dispatch(
879            old_base,
880            vec![raw("/old/src/a.rs", WatchEventKind::Created, false)],
881        );
882        assert_eq!(wait_events(&collected, 1).len(), 1);
883
884        registry.rebase(new_base);
885        assert!(registry.contains(id));
886        registry.dispatch(
887            old_base,
888            vec![raw("/old/src/stale.rs", WatchEventKind::Created, false)],
889        );
890        registry.dispatch(
891            new_base,
892            vec![raw("/new/src/b.rs", WatchEventKind::Created, false)],
893        );
894
895        let delivered = wait_events(&collected, 2);
896        let paths: Vec<_> = delivered.iter().map(|event| event.path.clone()).collect();
897        assert_eq!(
898            paths,
899            vec![
900                PathBuf::from("/old/src/a.rs"),
901                PathBuf::from("/new/src/b.rs")
902            ]
903        );
904    }
905
906    #[test]
907    fn rebase_from_callback_skips_queued_old_events() {
908        let old_base = Path::new("/old");
909        let new_base = PathBuf::from("/new");
910        let registry = registry(old_base);
911        let collected: Collected = Arc::new(Mutex::new(Vec::new()));
912        let sink = Arc::clone(&collected);
913        let weak = Arc::downgrade(&registry);
914        let callback_base = new_base.clone();
915
916        registry
917            .subscribe(
918                old_base,
919                "**",
920                WatchOptions::default(),
921                Box::new(move |_, events| {
922                    sink.lock().extend_from_slice(events);
923                    if let Some(registry) = weak.upgrade() {
924                        registry.rebase(&callback_base);
925                    }
926                }),
927            )
928            .unwrap();
929
930        registry.dispatch(
931            old_base,
932            (0..129)
933                .map(|index| {
934                    raw(
935                        &format!("/old/file-{index}"),
936                        WatchEventKind::Modified,
937                        false,
938                    )
939                })
940                .collect(),
941        );
942
943        let old_events = wait_events(&collected, 128);
944        assert_eq!(old_events.len(), 128);
945        assert!(
946            !old_events
947                .iter()
948                .any(|event| event.path == Path::new("/old/file-128"))
949        );
950
951        registry.dispatch(
952            &new_base,
953            vec![raw("/new/current", WatchEventKind::Created, false)],
954        );
955        let events = wait_events(&collected, 129);
956        assert_eq!(events.last().unwrap().path, Path::new("/new/current"));
957    }
958
959    #[test]
960    fn callback_panic_does_not_stop_dispatcher() {
961        let base = Path::new("/repo");
962        let registry = registry(base);
963        registry
964            .subscribe(
965                base,
966                "**",
967                WatchOptions::default(),
968                Box::new(|_, _| panic!("test callback panic")),
969            )
970            .unwrap();
971        let (cb, collected) = collector();
972        registry
973            .subscribe(base, "**", WatchOptions::default(), cb)
974            .unwrap();
975
976        registry.dispatch(
977            base,
978            vec![raw("/repo/a.rs", WatchEventKind::Created, false)],
979        );
980        assert_eq!(wait_events(&collected, 1).len(), 1);
981    }
982
983    #[test]
984    fn shutdown_and_wait_joins_in_flight_callback() {
985        let base = Path::new("/repo");
986        let registry = registry(base);
987        let (started_tx, started_rx) = mpsc::channel();
988        let release = Arc::new((Mutex::new(false), Condvar::new()));
989        let release_cb = Arc::clone(&release);
990        registry
991            .subscribe(
992                base,
993                "**",
994                WatchOptions::default(),
995                Box::new(move |_, _| {
996                    let _ = started_tx.send(());
997                    let (released, ready) = &*release_cb;
998                    ready.wait_while(&mut released.lock(), |released| !*released);
999                }),
1000            )
1001            .unwrap();
1002        registry.dispatch(
1003            base,
1004            vec![raw("/repo/a.rs", WatchEventKind::Created, false)],
1005        );
1006        started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1007
1008        let registry_wait = Arc::clone(&registry);
1009        let waiting = std::thread::spawn(move || registry_wait.shutdown_and_wait());
1010        std::thread::sleep(Duration::from_millis(20));
1011        assert!(!waiting.is_finished());
1012
1013        let (released, ready) = &*release;
1014        *released.lock() = true;
1015        ready.notify_all();
1016        waiting.join().unwrap();
1017        assert!(!registry.is_active());
1018    }
1019
1020    #[test]
1021    fn index_ignored_events_are_never_delivered() {
1022        let base = Path::new("/repo");
1023        let registry = registry(base);
1024
1025        let (cb, events) = collector();
1026        registry
1027            .subscribe(base, "dist/**", WatchOptions::default(), cb)
1028            .unwrap();
1029
1030        registry.dispatch(
1031            base,
1032            vec![
1033                raw("/repo/dist/bundle.js", WatchEventKind::Created, true),
1034                raw("/repo/dist/keep.js", WatchEventKind::Created, false),
1035            ],
1036        );
1037
1038        let got = wait_events(&events, 1);
1039        assert_eq!(got.len(), 1);
1040        assert_eq!(got[0].path, PathBuf::from("/repo/dist/keep.js"));
1041    }
1042
1043    #[test]
1044    fn callback_receives_its_subscription_id_and_ids_are_unique() {
1045        let base = Path::new("/repo");
1046        let a = registry(base);
1047        let b = registry(base);
1048
1049        let seen_id = Arc::new(Mutex::new(None::<WatchId>));
1050        let seen_cb = Arc::clone(&seen_id);
1051        let id_a = a
1052            .subscribe(
1053                base,
1054                "**",
1055                WatchOptions::default(),
1056                Box::new(move |id, _| {
1057                    *seen_cb.lock() = Some(id);
1058                }),
1059            )
1060            .unwrap();
1061        let (b_cb, _b_events) = collector();
1062        let id_b = b
1063            .subscribe(base, "**", WatchOptions::default(), b_cb)
1064            .unwrap();
1065
1066        // ids are process-wide unique, even across registries (instances)
1067        assert_ne!(id_a, id_b);
1068
1069        a.dispatch(
1070            base,
1071            vec![raw("/repo/a.rs", WatchEventKind::Modified, false)],
1072        );
1073        for _ in 0..200 {
1074            if seen_id.lock().is_some() {
1075                break;
1076            }
1077            std::thread::sleep(Duration::from_millis(5));
1078        }
1079        assert_eq!(*seen_id.lock(), Some(id_a));
1080    }
1081
1082    #[test]
1083    fn shutdown_quiesces_and_allows_restart() {
1084        let base = Path::new("/repo");
1085        let registry = registry(base);
1086        let calls = Arc::new(AtomicUsize::new(0));
1087
1088        let calls_cb = Arc::clone(&calls);
1089        registry
1090            .subscribe(
1091                base,
1092                "**",
1093                WatchOptions::default(),
1094                Box::new(move |_, _| {
1095                    calls_cb.fetch_add(1, Ordering::SeqCst);
1096                }),
1097            )
1098            .unwrap();
1099
1100        registry.shutdown();
1101        assert!(!registry.is_active());
1102
1103        // after shutdown: no deliveries
1104        registry.dispatch(
1105            base,
1106            vec![raw("/repo/a.rs", WatchEventKind::Modified, false)],
1107        );
1108        std::thread::sleep(std::time::Duration::from_millis(100));
1109        assert_eq!(calls.load(Ordering::SeqCst), 0);
1110
1111        // shutdown is idempotent and the registry restarts on next subscribe
1112        registry.shutdown();
1113        let (cb, events) = collector();
1114        registry
1115            .subscribe(base, "**", WatchOptions::default(), cb)
1116            .unwrap();
1117        registry.dispatch(
1118            base,
1119            vec![raw("/repo/b.rs", WatchEventKind::Created, false)],
1120        );
1121        assert_eq!(wait_events(&events, 1).len(), 1);
1122    }
1123
1124    #[test]
1125    fn unsubscribe_from_inside_callback_does_not_deadlock() {
1126        let base = Path::new("/repo");
1127        let registry = registry(base);
1128        let unsubscribed = Arc::new(AtomicBool::new(false));
1129
1130        let registry_cb = Arc::downgrade(&registry);
1131        let unsub_cb = Arc::clone(&unsubscribed);
1132        // one-shot pattern: the callback removes its own subscription
1133        registry
1134            .subscribe(
1135                base,
1136                "**",
1137                WatchOptions::default(),
1138                Box::new(move |id, _| {
1139                    if let Some(registry) = registry_cb.upgrade() {
1140                        registry.unsubscribe(id);
1141                        unsub_cb.store(true, Ordering::SeqCst);
1142                    }
1143                }),
1144            )
1145            .unwrap();
1146
1147        registry.dispatch(
1148            base,
1149            vec![raw("/repo/a.rs", WatchEventKind::Modified, false)],
1150        );
1151
1152        for _ in 0..400 {
1153            if unsubscribed.load(Ordering::SeqCst) {
1154                break;
1155            }
1156            std::thread::sleep(Duration::from_millis(5));
1157        }
1158        assert!(
1159            unsubscribed.load(Ordering::SeqCst),
1160            "self-unsubscribe from the callback deadlocked"
1161        );
1162        assert!(!registry.is_active());
1163    }
1164}