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