1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2#[cfg(any(target_os = "macos", target_os = "linux", test))]
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
6#[cfg(test)]
7use std::sync::OnceLock;
8use std::sync::{mpsc, Arc, Mutex, RwLock};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant};
11
12use crossbeam_channel::{Receiver, SendTimeoutError, Sender};
13use ignore::gitignore::Gitignore;
14
15pub type SharedGitignore = Arc<RwLock<Option<Arc<Gitignore>>>>;
16
17pub const WATCHER_FLUSH_WINDOW: Duration = Duration::from_millis(250);
18pub const WATCHER_MAX_BATCH_PATHS: usize = 1024;
19pub const WATCHER_DISPATCH_CHANNEL_CAPACITY: usize = 1024;
20#[cfg(any(target_os = "macos", target_os = "linux", test))]
21pub(crate) const WATCHER_EXCLUSION_LIMIT: usize = 8;
22const ROOT_DELETED_CHECK_INTERVAL: Duration = Duration::from_millis(250);
23const GITIGNORE_REBUILD_POLL_INTERVAL: Duration = Duration::from_millis(10);
24const DISPATCH_SEND_POLL_INTERVAL: Duration = Duration::from_millis(50);
25const WATCHER_ATTRIBUTION_RING_CAPACITY: usize = 512;
26const WATCHER_OVERFLOW_PREFIX_LIMIT: usize = 5;
27const WATCHER_OBSERVED_EXCLUSION_LIMIT: usize = 32;
28
29#[derive(Debug, Clone)]
30pub struct WatcherFilterConfig {
31 pub project_root: PathBuf,
32 pub git_common_dir: Option<PathBuf>,
33 counters: Arc<crate::context::WatcherCounters>,
34}
35
36impl WatcherFilterConfig {
37 pub fn new(project_root: PathBuf, git_common_dir: Option<PathBuf>) -> Self {
38 let counters = crate::context::watcher_counters_for_root(&project_root);
39 Self {
40 project_root,
41 git_common_dir,
42 counters,
43 }
44 }
45
46 fn git_info_exclude_path(&self) -> PathBuf {
47 self.git_common_dir
48 .clone()
49 .unwrap_or_else(|| self.project_root.join(".git"))
50 .join("info")
51 .join("exclude")
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum RescanReason {
57 BufferOverflow,
58 KernelDropped,
59 UserDropped,
60 Unknown,
61}
62
63impl RescanReason {
64 fn from_event_info(info: Option<&str>) -> Self {
65 match info {
66 Some("rescan: buffer overflow") => Self::BufferOverflow,
67 Some("rescan: kernel dropped") => Self::KernelDropped,
68 Some("rescan: user dropped") => Self::UserDropped,
69 _ => Self::Unknown,
70 }
71 }
72
73 pub(crate) fn as_str(self) -> &'static str {
74 match self {
75 Self::BufferOverflow => "buffer_overflow",
76 Self::KernelDropped => "kernel_dropped",
77 Self::UserDropped => "user_dropped",
78 Self::Unknown => "unknown",
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum WatcherDispatchEvent {
85 Paths(Vec<PathBuf>),
86 RescanRequired(RescanReason),
87 IgnoreRulesChanged { path: PathBuf },
88 RootDeleted,
89 Error(String),
90}
91
92pub struct WatcherThreadHandle {
93 shutdown: Arc<AtomicBool>,
94 join: Option<JoinHandle<()>>,
95}
96
97pub enum WatcherJoinOutcome {
99 Joined,
100 TimedOut(JoinHandle<()>),
101}
102
103impl WatcherThreadHandle {
104 pub fn new(shutdown: Arc<AtomicBool>, join: JoinHandle<()>) -> Self {
105 Self {
106 shutdown,
107 join: Some(join),
108 }
109 }
110
111 pub fn request_shutdown(&self) {
112 self.shutdown.store(true, Ordering::SeqCst);
113 }
114
115 pub fn is_finished(&self) -> bool {
116 self.join.as_ref().is_none_or(|join| join.is_finished())
117 }
118
119 pub fn shutdown_and_join(mut self) {
120 self.request_shutdown();
121 if let Some(join) = self.join.take() {
122 let _ = join.join();
123 }
124 }
125
126 pub fn shutdown_and_join_timeout(mut self, timeout: Duration) -> WatcherJoinOutcome {
130 self.request_shutdown();
131 let Some(join) = self.join.take() else {
132 return WatcherJoinOutcome::Joined;
133 };
134 let deadline = Instant::now() + timeout;
135 while !join.is_finished() && Instant::now() < deadline {
136 thread::sleep(Duration::from_millis(10));
137 }
138 if join.is_finished() {
139 let _ = join.join();
140 WatcherJoinOutcome::Joined
141 } else {
142 WatcherJoinOutcome::TimedOut(join)
143 }
144 }
145}
146
147impl Drop for WatcherThreadHandle {
148 fn drop(&mut self) {
149 self.request_shutdown();
150 }
151}
152
153pub fn watcher_dispatch_channel() -> (Sender<WatcherDispatchEvent>, Receiver<WatcherDispatchEvent>)
154{
155 crossbeam_channel::bounded(WATCHER_DISPATCH_CHANNEL_CAPACITY)
156}
157
158pub fn watcher_event_invalidates(kind: ¬ify::EventKind) -> bool {
161 use notify::event::{MetadataKind, ModifyKind};
162 use notify::EventKind;
163 match kind {
164 EventKind::Create(_) | EventKind::Remove(_) => true,
165 EventKind::Modify(ModifyKind::Metadata(meta)) => !matches!(
166 meta,
167 MetadataKind::AccessTime
168 | MetadataKind::Permissions
169 | MetadataKind::Ownership
170 | MetadataKind::Extended
171 ),
172 EventKind::Modify(_) => true,
173 _ => false,
174 }
175}
176
177pub fn watcher_path_is_infra_skip(path: &Path) -> bool {
178 path.components().any(|c| {
179 matches!(c, Component::Normal(name) if matches!(
180 name.to_str().unwrap_or(""),
181 ".git" | ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
182 ))
183 })
184}
185
186fn watcher_path_is_high_churn_infra(path: &Path) -> bool {
200 path.components().any(|c| {
201 matches!(c, Component::Normal(name) if matches!(
202 name.to_str().unwrap_or(""),
203 ".opencode" | ".alfonso" | ".gsd" | "node_modules" | "target"
204 ))
205 })
206}
207
208fn watcher_path_is_ignore_file(path: &Path) -> bool {
209 path.file_name()
210 .map(|n| n == ".gitignore" || n == ".aftignore")
211 .unwrap_or(false)
212}
213
214fn watcher_same_path(path: &Path, target: &Path) -> bool {
215 if path == target {
216 return true;
217 }
218
219 std::fs::canonicalize(target)
220 .map(|target| path == target)
221 .unwrap_or(false)
222}
223
224fn watcher_path_is_git_head_metadata(config: &WatcherFilterConfig, path: &Path) -> bool {
225 crate::alias::capture_git_head_metadata(&config.project_root, config.git_common_dir.as_deref())
226 .is_ok_and(|metadata| metadata.matches_path(path))
227}
228
229fn watcher_path_is_git_info_exclude(config: &WatcherFilterConfig, path: &Path) -> bool {
230 watcher_same_path(path, &config.git_info_exclude_path())
231}
232
233fn watcher_path_is_global_gitignore(path: &Path) -> bool {
234 ignore::gitignore::gitconfig_excludes_path()
235 .as_deref()
236 .is_some_and(|global_ignore| watcher_same_path(path, global_ignore))
237}
238
239fn watcher_path_can_change_corpus_ignore(config: &WatcherFilterConfig, path: &Path) -> bool {
240 if watcher_path_is_global_gitignore(path) {
241 return true;
242 }
243 if watcher_path_is_git_info_exclude(config, path) {
244 return true;
245 }
246 if !path.starts_with(&config.project_root) {
247 return false;
248 }
249
250 watcher_path_is_ignore_file(path) && !watcher_path_is_infra_skip(path)
251}
252
253pub fn canonicalize_watcher_path(path: PathBuf) -> PathBuf {
254 if let Ok(canonical) = std::fs::canonicalize(&path) {
255 return canonical;
256 }
257
258 let parent = path.parent().map(Path::to_path_buf);
259 let file_name = path.file_name().map(std::ffi::OsStr::to_os_string);
260 match (parent, file_name) {
261 (Some(parent), Some(file_name)) => std::fs::canonicalize(parent)
262 .map(|canonical_parent| canonical_parent.join(file_name))
263 .unwrap_or(path),
264 _ => path,
265 }
266}
267
268pub(crate) fn watcher_path_is_ignored_by_matcher(matcher: &SharedGitignore, path: &Path) -> bool {
269 if watcher_path_is_infra_skip(path) {
270 return true;
271 }
272
273 let guard = matcher
274 .read()
275 .unwrap_or_else(|poisoned| poisoned.into_inner());
276 watcher_path_is_ignored(guard.as_deref(), path)
277}
278
279fn watcher_path_is_ignored(matcher: Option<&Gitignore>, path: &Path) -> bool {
280 matcher.is_some_and(|matcher| {
281 path.starts_with(matcher.path())
282 && matcher
283 .matched_path_or_any_parents(path, path.is_dir())
284 .is_ignore()
285 })
286}
287
288#[cfg(any(target_os = "macos", target_os = "linux", test))]
289const WATCHER_EXCLUSION_SEEDS: [&str; 17] = [
290 ".git",
291 "target",
292 "node_modules",
293 "dist",
294 "build",
295 ".next",
296 ".venv",
297 "venv",
298 "__pycache__",
299 ".turbo",
300 ".cache",
301 "coverage",
302 "out",
303 ".gradle",
304 ".dart_tool",
305 "Pods",
306 "DerivedData",
307];
308
309#[cfg(any(target_os = "macos", target_os = "linux", test))]
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub(crate) enum WatcherExclusionSource {
312 Seed,
313 Ranked,
314 Gitignore,
315}
316
317#[cfg(any(target_os = "macos", target_os = "linux", test))]
318impl WatcherExclusionSource {
319 fn priority(self) -> u8 {
320 match self {
321 Self::Seed => 0,
322 Self::Ranked => 1,
323 Self::Gitignore => 2,
324 }
325 }
326
327 #[cfg(any(target_os = "macos", target_os = "linux", test))]
328 pub(crate) fn as_str(self) -> &'static str {
329 match self {
330 Self::Seed => "seed",
331 Self::Ranked => "ranked",
332 Self::Gitignore => "gitignore",
333 }
334 }
335}
336
337#[cfg(any(target_os = "macos", target_os = "linux", test))]
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub(crate) struct WatcherExclusion {
340 path: PathBuf,
341 source: WatcherExclusionSource,
342}
343
344#[cfg(any(target_os = "macos", target_os = "linux", test))]
345impl WatcherExclusion {
346 pub(crate) fn path(&self) -> &Path {
347 &self.path
348 }
349
350 pub(crate) fn source(&self) -> WatcherExclusionSource {
351 self.source
352 }
353}
354
355#[cfg(any(target_os = "macos", target_os = "linux", test))]
356pub(crate) fn watcher_exclusion_paths(exclusions: &[WatcherExclusion]) -> Vec<PathBuf> {
357 exclusions
358 .iter()
359 .map(|exclusion| exclusion.path.clone())
360 .collect()
361}
362
363#[cfg(any(target_os = "macos", target_os = "linux", test))]
364#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
365struct GitignoreOrder {
366 source_priority: u8,
367 source_path: PathBuf,
368 line: usize,
369}
370
371#[cfg(any(target_os = "macos", target_os = "linux", test))]
372impl Default for GitignoreOrder {
373 fn default() -> Self {
374 Self {
375 source_priority: u8::MAX,
376 source_path: PathBuf::new(),
377 line: usize::MAX,
378 }
379 }
380}
381
382#[cfg(any(target_os = "macos", target_os = "linux", test))]
383impl GitignoreOrder {
384 fn for_glob(root: &Path, glob: &ignore::gitignore::Glob) -> Self {
385 let Some(source) = glob.from() else {
386 return Self::default();
387 };
388 let source_path = source
389 .strip_prefix(root)
390 .map(Path::to_path_buf)
391 .unwrap_or_else(|_| source.to_path_buf());
392 let source_priority = if !source.starts_with(root) {
393 0
394 } else if source == root.join(".gitignore") {
395 1
396 } else if source == root.join(".aftignore") {
397 2
398 } else if source.ends_with(Path::new("info/exclude")) {
399 3
400 } else {
401 4
402 };
403 let line = fs::read_to_string(source)
404 .ok()
405 .and_then(|contents| {
406 contents.lines().position(|line| {
407 let normalized = if line.ends_with("\\ ") {
408 line
409 } else {
410 line.trim_end()
411 };
412 normalized == glob.original()
413 })
414 })
415 .unwrap_or(usize::MAX);
416 Self {
417 source_priority,
418 source_path,
419 line,
420 }
421 }
422}
423
424#[cfg(any(target_os = "macos", target_os = "linux", test))]
425fn exclusion_seed_priority(relative: &Path, is_git: bool, is_ignored: bool) -> Option<usize> {
426 let mut components = relative.components();
427 let Component::Normal(name) = components.next()? else {
428 return None;
429 };
430 if components.next().is_some() {
431 return None;
432 }
433 let priority = WATCHER_EXCLUSION_SEEDS
434 .iter()
435 .position(|candidate| name == std::ffi::OsStr::new(candidate))?;
436 (is_git || is_ignored).then_some(priority)
437}
438
439#[cfg(any(target_os = "macos", target_os = "linux", test))]
447pub(crate) fn derive_excluded_subtrees(
448 root: &Path,
449 matcher: &SharedGitignore,
450 max_paths: Option<usize>,
451) -> Vec<WatcherExclusion> {
452 #[derive(Debug)]
453 struct Candidate {
454 path: PathBuf,
455 source: WatcherExclusionSource,
456 seed_priority: usize,
457 observed_count: u64,
458 gitignore_order: GitignoreOrder,
459 }
460
461 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
462 let matcher = matcher
463 .read()
464 .unwrap_or_else(|poisoned| poisoned.into_inner())
465 .clone();
466 let observed = crate::context::watcher_counters_for_root(&root)
467 .observed_exclusion_prefixes()
468 .into_iter()
469 .map(|prefix| (PathBuf::from(prefix.prefix), prefix.count))
470 .collect::<BTreeMap<_, _>>();
471 let root_git = root.join(".git");
472 let mut candidates = Vec::<Candidate>::new();
473 let mut stack = vec![root.clone()];
474
475 while let Some(directory) = stack.pop() {
476 let Ok(entries) = fs::read_dir(&directory) else {
477 continue;
478 };
479 for entry in entries.flatten() {
480 let path = entry.path();
481 if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
482 continue;
483 }
484 let is_git = path == root_git;
485 let matched_glob = matcher.as_deref().and_then(|matcher| {
486 match matcher.matched_path_or_any_parents(&path, true) {
487 ignore::Match::Ignore(glob) => Some(glob),
488 ignore::Match::None | ignore::Match::Whitelist(_) => None,
489 }
490 });
491 let is_ignored = matched_glob.is_some();
492 if is_git || is_ignored {
493 let relative = path.strip_prefix(&root).unwrap_or(&path);
494 let observed_count = observed.get(relative).copied();
495 let seed_priority = exclusion_seed_priority(relative, is_git, is_ignored);
496 let source = if seed_priority.is_some() {
497 WatcherExclusionSource::Seed
498 } else if observed_count.is_some() {
499 WatcherExclusionSource::Ranked
500 } else {
501 WatcherExclusionSource::Gitignore
502 };
503 candidates.push(Candidate {
504 path,
505 source,
506 seed_priority: seed_priority.unwrap_or(usize::MAX),
507 observed_count: observed_count.unwrap_or_default(),
508 gitignore_order: matched_glob
509 .map(|glob| GitignoreOrder::for_glob(&root, glob))
510 .unwrap_or_default(),
511 });
512 } else {
513 stack.push(path);
514 }
515 }
516 }
517
518 candidates.sort_by(|left, right| {
519 left.source
520 .priority()
521 .cmp(&right.source.priority())
522 .then_with(|| match left.source {
523 WatcherExclusionSource::Seed => left.seed_priority.cmp(&right.seed_priority),
524 WatcherExclusionSource::Ranked => right
525 .observed_count
526 .cmp(&left.observed_count)
527 .then_with(|| left.path.cmp(&right.path)),
528 WatcherExclusionSource::Gitignore => left
529 .gitignore_order
530 .cmp(&right.gitignore_order)
531 .then_with(|| left.path.cmp(&right.path)),
532 })
533 });
534 let mut exclusions = candidates
535 .into_iter()
536 .map(|candidate| WatcherExclusion {
537 path: candidate.path,
538 source: candidate.source,
539 })
540 .collect::<Vec<_>>();
541 if let Some(max_paths) = max_paths {
542 exclusions.truncate(max_paths);
543 }
544 exclusions
545}
546
547const WATCHER_OBSERVATION_STATE_PREFIX: &str = "watcher.observed_exclusion_prefixes";
548
549fn watcher_observation_state_key(root: &Path) -> String {
554 let repository_key = crate::search_index::artifact_cache_key_memoized_only(root)
555 .unwrap_or_else(|| crate::search_index::artifact_cache_key(root));
556 format!("{WATCHER_OBSERVATION_STATE_PREFIX}:{repository_key}")
557}
558
559fn valid_observed_exclusion_prefix(prefix: &crate::context::WatcherOverflowPrefix) -> bool {
560 prefix.count > 0
561 && !prefix.prefix.is_empty()
562 && Path::new(&prefix.prefix)
563 .components()
564 .all(|component| matches!(component, Component::Normal(_)))
565}
566
567pub(crate) fn load_watcher_observations(
568 root: &Path,
569 counters: &crate::context::WatcherCounters,
570 db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
571) {
572 let Some(db) = db else {
573 return;
574 };
575 let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
576 let Ok(Some(raw)) =
577 crate::db::state::get_host_state(&conn, &watcher_observation_state_key(root))
578 else {
579 return;
580 };
581 let Ok(mut prefixes) = serde_json::from_str::<Vec<crate::context::WatcherOverflowPrefix>>(&raw)
582 else {
583 return;
584 };
585 prefixes.retain(valid_observed_exclusion_prefix);
586 prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
587 counters.set_observed_exclusion_prefixes(prefixes);
588}
589
590pub(crate) fn persist_watcher_observations(
591 root: &Path,
592 counters: &crate::context::WatcherCounters,
593 db: Option<&Arc<Mutex<crate::db::TrackedConnection>>>,
594) {
595 let Some(db) = db else {
596 return;
597 };
598 let prefixes = counters.observed_exclusion_prefixes();
599 let Ok(value) = serde_json::to_string(&prefixes) else {
600 return;
601 };
602 let now_ms = std::time::SystemTime::now()
603 .duration_since(std::time::UNIX_EPOCH)
604 .unwrap_or_default()
605 .as_millis()
606 .min(i64::MAX as u128) as i64;
607 let conn = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
608 if let Err(error) = crate::db::state::set_host_state(
609 &conn,
610 &watcher_observation_state_key(root),
611 &value,
612 now_ms,
613 ) {
614 crate::slog_warn!(
615 "failed to persist watcher overflow prefixes for {}: {}",
616 root.display(),
617 error
618 );
619 }
620}
621
622#[derive(Debug, Default, Clone, PartialEq, Eq)]
623pub struct FilteredWatcherPaths {
624 pub changed: BTreeSet<PathBuf>,
625 pub ignore_file_changed: bool,
626}
627
628fn filter_canonical_paths(
629 config: &WatcherFilterConfig,
630 matcher: &SharedGitignore,
631 raw_paths: BTreeSet<PathBuf>,
632) -> FilteredWatcherPaths {
633 let ignore_file_changed = raw_paths
634 .iter()
635 .any(|path| watcher_path_can_change_corpus_ignore(config, path));
636
637 let changed = raw_paths
638 .into_iter()
639 .filter(|path| {
640 if watcher_path_is_git_head_metadata(config, path) {
641 return true;
642 }
643 if watcher_path_is_infra_skip(path) {
644 return false;
645 }
646
647 if watcher_path_is_global_gitignore(path)
648 || watcher_path_is_git_info_exclude(config, path)
649 {
650 return false;
651 }
652
653 if watcher_path_is_ignored_by_matcher(matcher, path) {
654 return false;
655 }
656 true
657 })
658 .collect();
659
660 FilteredWatcherPaths {
661 changed,
662 ignore_file_changed,
663 }
664}
665
666pub fn filter_watcher_raw_paths_for_test<I>(
667 config: &WatcherFilterConfig,
668 matcher: &SharedGitignore,
669 raw_paths: I,
670) -> FilteredWatcherPaths
671where
672 I: IntoIterator<Item = PathBuf>,
673{
674 let raw_paths = raw_paths
675 .into_iter()
676 .map(canonicalize_watcher_path)
677 .collect::<BTreeSet<_>>();
678 filter_canonical_paths(config, matcher, raw_paths)
679}
680
681pub fn run_watcher_thread<W, E, F>(
682 config: WatcherFilterConfig,
683 extra_watch_paths: Vec<PathBuf>,
684 matcher: SharedGitignore,
685 matcher_generation: Arc<AtomicU64>,
686 dispatch_tx: Sender<WatcherDispatchEvent>,
687 shutdown: Arc<AtomicBool>,
688 attach: F,
689) where
690 W: Send + 'static,
691 E: std::fmt::Display,
692 F: FnOnce(PathBuf, Vec<PathBuf>, mpsc::Sender<notify::Result<notify::Event>>) -> Result<W, E>,
693{
694 let (raw_tx, raw_rx) = mpsc::channel();
695 let root_path = config.project_root.clone();
696 match attach(root_path.clone(), extra_watch_paths, raw_tx) {
697 Ok(_watcher) => {
698 if shutdown.load(Ordering::SeqCst) {
699 return;
700 }
701 crate::slog_info!("watcher started: {}", root_path.display());
702 let mut filter = WatcherFilterThread::new(
703 config,
704 matcher,
705 matcher_generation,
706 dispatch_tx,
707 shutdown,
708 );
709 filter.run(raw_rx);
710 }
711 Err(error) => {
712 if !shutdown.load(Ordering::SeqCst) {
713 log::debug!(
714 "watcher init failed: {} — callers will work with stale data",
715 error
716 );
717 let _ = dispatch_tx.send(WatcherDispatchEvent::Error(format!(
718 "watcher init failed: {error}"
719 )));
720 }
721 }
722 }
723}
724
725struct WatcherFilterThread {
726 config: WatcherFilterConfig,
727 matcher: SharedGitignore,
728 matcher_generation: Arc<AtomicU64>,
729 dispatch_tx: Sender<WatcherDispatchEvent>,
730 shutdown: Arc<AtomicBool>,
731 raw_paths: BTreeSet<PathBuf>,
732 recent_paths: VecDeque<(PathBuf, Instant)>,
733 flush_deadline: Option<Instant>,
734}
735
736impl WatcherFilterThread {
737 fn new(
738 config: WatcherFilterConfig,
739 matcher: SharedGitignore,
740 matcher_generation: Arc<AtomicU64>,
741 dispatch_tx: Sender<WatcherDispatchEvent>,
742 shutdown: Arc<AtomicBool>,
743 ) -> Self {
744 Self {
745 config,
746 matcher,
747 matcher_generation,
748 dispatch_tx,
749 shutdown,
750 raw_paths: BTreeSet::new(),
751 recent_paths: VecDeque::with_capacity(WATCHER_ATTRIBUTION_RING_CAPACITY),
752 flush_deadline: None,
753 }
754 }
755
756 fn run(&mut self, raw_rx: mpsc::Receiver<notify::Result<notify::Event>>) {
757 loop {
758 if self.shutdown.load(Ordering::SeqCst) {
759 self.flush_pending();
760 return;
761 }
762 if self.project_root_was_deleted() {
763 self.raw_paths.clear();
764 let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
765 return;
766 }
767 if self.flush_deadline_reached() {
768 if !self.flush_pending() {
769 return;
770 }
771 continue;
772 }
773
774 match raw_rx.recv_timeout(self.next_recv_timeout()) {
775 Ok(Ok(event)) => {
776 self.config.counters.note_raw_event();
777 if event.need_rescan() {
778 let reason = RescanReason::from_event_info(event.info());
779 let during_rescan = self.log_overflow(reason);
780 self.raw_paths.clear();
781 self.flush_deadline = None;
782 if !during_rescan
783 && !self.send_dispatch(WatcherDispatchEvent::RescanRequired(reason))
784 {
785 return;
786 }
787 continue;
788 }
789 self.record_recent_paths(&event.paths);
790 if watcher_event_invalidates(&event.kind) {
791 self.config.counters.note_invalidating_event();
792 if !self.push_raw_paths(event.paths) {
793 return;
794 }
795 }
796 }
797 Ok(Err(error)) => {
798 let _ = self.send_dispatch(WatcherDispatchEvent::Error(error.to_string()));
799 return;
800 }
801 Err(mpsc::RecvTimeoutError::Timeout) => {
802 if !self.flush_pending() {
803 return;
804 }
805 }
806 Err(mpsc::RecvTimeoutError::Disconnected) => {
807 if !self.shutdown.load(Ordering::SeqCst) {
808 let _ = self.send_dispatch(WatcherDispatchEvent::Error(
809 "watcher channel disconnected".to_string(),
810 ));
811 }
812 return;
813 }
814 }
815 }
816 }
817
818 fn project_root_was_deleted(&self) -> bool {
819 !self.config.project_root.exists()
820 }
821
822 fn record_recent_paths(&mut self, paths: &[PathBuf]) {
823 let arrived_at = Instant::now();
824 for path in paths {
825 let relative = path
826 .strip_prefix(&self.config.project_root)
827 .map(Path::to_path_buf)
828 .unwrap_or_else(|_| {
829 PathBuf::from("<external>")
830 .join(path.file_name().unwrap_or_else(|| path.as_os_str()))
831 });
832 if self.recent_paths.len() == WATCHER_ATTRIBUTION_RING_CAPACITY {
833 self.recent_paths.pop_front();
834 }
835 self.recent_paths.push_back((relative, arrived_at));
836 }
837 }
838
839 fn overflow_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
840 let mut counts = BTreeMap::<String, u64>::new();
841 for (path, _) in &self.recent_paths {
842 let prefix = path
848 .components()
849 .filter_map(|component| match component {
850 Component::Normal(name) => Some(name.to_string_lossy()),
851 _ => None,
852 })
853 .take(2)
854 .collect::<Vec<_>>()
855 .join("/");
856 if prefix.is_empty() {
857 continue;
858 }
859 *counts.entry(prefix).or_default() += 1;
860 }
861 let mut prefixes = counts
862 .into_iter()
863 .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
864 .collect::<Vec<_>>();
865 prefixes.sort_by(|left, right| {
866 right
867 .count
868 .cmp(&left.count)
869 .then_with(|| left.prefix.cmp(&right.prefix))
870 });
871 prefixes.truncate(WATCHER_OVERFLOW_PREFIX_LIMIT);
872 prefixes
873 }
874
875 fn observed_exclusion_prefixes(&self) -> Vec<crate::context::WatcherOverflowPrefix> {
876 let matcher = self
877 .matcher
878 .read()
879 .unwrap_or_else(std::sync::PoisonError::into_inner)
880 .clone();
881 let root_git = self.config.project_root.join(".git");
882 let mut counts = BTreeMap::<String, u64>::new();
883 for (path, _) in &self.recent_paths {
884 let mut relative = PathBuf::new();
885 let mut absolute = self.config.project_root.clone();
886 for component in path.components() {
887 let Component::Normal(name) = component else {
888 continue;
889 };
890 relative.push(name);
891 absolute.push(name);
892 if absolute == root_git || watcher_path_is_ignored(matcher.as_deref(), &absolute) {
893 *counts
894 .entry(relative.to_string_lossy().into_owned())
895 .or_default() += 1;
896 break;
897 }
898 }
899 }
900 let mut prefixes = counts
901 .into_iter()
902 .map(|(prefix, count)| crate::context::WatcherOverflowPrefix { prefix, count })
903 .collect::<Vec<_>>();
904 prefixes.sort_by(|left, right| {
905 right
906 .count
907 .cmp(&left.count)
908 .then_with(|| left.prefix.cmp(&right.prefix))
909 });
910 prefixes.truncate(WATCHER_OBSERVED_EXCLUSION_LIMIT);
911 prefixes
912 }
913
914 fn log_overflow(&self, reason: RescanReason) -> bool {
915 let prefixes = self.overflow_prefixes();
916 self.config
917 .counters
918 .set_observed_exclusion_prefixes(self.observed_exclusion_prefixes());
919 let during_rescan = self.config.counters.note_overflow(reason, prefixes.clone());
920 let backend = self.config.counters.backend_exclusions();
921 let exclusions = backend
922 .paths
923 .iter()
924 .map(|path| {
925 path.strip_prefix(&self.config.project_root)
926 .unwrap_or(path)
927 .display()
928 .to_string()
929 })
930 .collect::<Vec<_>>()
931 .join(",");
932 let prefixes = prefixes
933 .iter()
934 .map(|prefix| format!("{}:{}", prefix.prefix, prefix.count))
935 .collect::<Vec<_>>()
936 .join(",");
937 let span_ms = self
938 .recent_paths
939 .front()
940 .zip(self.recent_paths.back())
941 .map(|((_, first), (_, last))| {
942 last.saturating_duration_since(*first)
943 .as_millis()
944 .min(u64::MAX as u128) as u64
945 })
946 .unwrap_or(0);
947 let queue_depth = backend
948 .queue_depth
949 .map(|depth| depth.to_string())
950 .unwrap_or_else(|| "unavailable".to_string());
951 let line = format!(
952 "watcher overflow: reason={} root={} exclusions=[{}] matcher_generation={} top_prefixes=[{}] ring_span_ms={} queue_depth={} rescan_in_progress={}",
953 reason.as_str(),
954 self.config.project_root.display(),
955 exclusions,
956 backend.matcher_generation,
957 prefixes,
958 span_ms,
959 queue_depth,
960 during_rescan
961 );
962 emit_watcher_overflow_log(line);
963 during_rescan
964 }
965
966 fn push_raw_paths(&mut self, paths: Vec<PathBuf>) -> bool {
967 for path in paths {
968 if watcher_path_is_high_churn_infra(&path) {
976 continue;
977 }
978 self.raw_paths.insert(canonicalize_watcher_path(path));
984 }
985 if !self.raw_paths.is_empty() && self.flush_deadline.is_none() {
986 self.flush_deadline = Some(Instant::now() + WATCHER_FLUSH_WINDOW);
987 }
988 if self.raw_paths.len() >= WATCHER_MAX_BATCH_PATHS {
989 return self.flush_pending();
990 }
991 true
992 }
993
994 fn next_recv_timeout(&self) -> Duration {
995 let root_check = ROOT_DELETED_CHECK_INTERVAL;
996 match self.flush_deadline {
997 Some(deadline) => deadline
998 .saturating_duration_since(Instant::now())
999 .min(root_check),
1000 None => root_check,
1001 }
1002 }
1003
1004 fn flush_deadline_reached(&self) -> bool {
1005 self.flush_deadline
1006 .is_some_and(|deadline| Instant::now() >= deadline)
1007 }
1008
1009 fn flush_pending(&mut self) -> bool {
1010 if self.raw_paths.is_empty() {
1011 self.flush_deadline = None;
1012 return true;
1013 }
1014
1015 let raw_paths = std::mem::take(&mut self.raw_paths);
1016 self.flush_deadline = None;
1017 let ignore_path = raw_paths
1018 .iter()
1019 .find(|path| watcher_path_can_change_corpus_ignore(&self.config, path))
1020 .cloned();
1021 let ignore_file_changed = ignore_path.is_some();
1022 if let Some(path) = ignore_path {
1023 let observed_generation = self.matcher_generation.load(Ordering::SeqCst);
1024 if !self.send_dispatch(WatcherDispatchEvent::IgnoreRulesChanged { path }) {
1025 return false;
1026 }
1027 if !self.wait_for_gitignore_rebuild(observed_generation) {
1028 return false;
1029 }
1030 }
1031
1032 let filtered = filter_canonical_paths(&self.config, &self.matcher, raw_paths);
1033 debug_assert_eq!(filtered.ignore_file_changed, ignore_file_changed);
1034 self.config
1035 .counters
1036 .note_paths_after_gitignore(filtered.changed.len());
1037 if filtered.changed.is_empty() {
1038 return true;
1039 }
1040 let paths = filtered.changed.into_iter().collect::<Vec<_>>();
1041 let path_count = paths.len();
1042 if !self.send_dispatch(WatcherDispatchEvent::Paths(paths)) {
1043 return false;
1044 }
1045 self.config.counters.note_paths_dispatched(path_count);
1046 true
1047 }
1048
1049 fn wait_for_gitignore_rebuild(&self, observed_generation: u64) -> bool {
1050 while !self.shutdown.load(Ordering::SeqCst)
1051 && self.matcher_generation.load(Ordering::SeqCst) == observed_generation
1052 {
1053 if self.project_root_was_deleted() {
1054 let _ = self.send_dispatch(WatcherDispatchEvent::RootDeleted);
1055 return false;
1056 }
1057 thread::sleep(GITIGNORE_REBUILD_POLL_INTERVAL);
1058 }
1059 !self.shutdown.load(Ordering::SeqCst)
1060 }
1061
1062 fn send_dispatch(&self, event: WatcherDispatchEvent) -> bool {
1063 let mut event = event;
1064 loop {
1065 match self
1066 .dispatch_tx
1067 .send_timeout(event, DISPATCH_SEND_POLL_INTERVAL)
1068 {
1069 Ok(()) => return true,
1070 Err(SendTimeoutError::Timeout(returned)) => {
1071 if self.shutdown.load(Ordering::SeqCst) {
1072 return false;
1073 }
1074 event = returned;
1075 }
1076 Err(SendTimeoutError::Disconnected(_)) => return false,
1077 }
1078 }
1079 }
1080}
1081
1082fn emit_watcher_overflow_log(line: String) {
1083 crate::slog_warn!("{line}");
1084 #[cfg(test)]
1085 WATCHER_OVERFLOW_LOGS_FOR_TEST
1086 .get_or_init(|| Mutex::new(Vec::new()))
1087 .lock()
1088 .unwrap_or_else(std::sync::PoisonError::into_inner)
1089 .push(line);
1090}
1091
1092#[cfg(test)]
1093static WATCHER_OVERFLOW_LOGS_FOR_TEST: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
1094
1095#[cfg(test)]
1096pub(crate) fn take_watcher_overflow_logs_for_test() -> Vec<String> {
1097 std::mem::take(
1098 &mut *WATCHER_OVERFLOW_LOGS_FOR_TEST
1099 .get_or_init(|| Mutex::new(Vec::new()))
1100 .lock()
1101 .unwrap_or_else(std::sync::PoisonError::into_inner),
1102 )
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use ignore::gitignore::GitignoreBuilder;
1109 use notify::event::{
1110 AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind,
1111 };
1112 use notify::EventKind;
1113 use std::process::Command;
1114 use tempfile::TempDir;
1115
1116 fn shared_matcher(root: &Path) -> SharedGitignore {
1117 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1118 let mut builder = GitignoreBuilder::new(&root);
1119 let ignore = root.join(".gitignore");
1120 if ignore.exists() {
1121 if let Some(error) = builder.add(&ignore) {
1122 panic!("gitignore parse error: {error}");
1123 }
1124 }
1125 let matcher = builder.build().unwrap();
1126 let matcher = (matcher.num_ignores() > 0).then(|| Arc::new(matcher));
1127 Arc::new(RwLock::new(matcher))
1128 }
1129
1130 fn run_git(root: &Path, args: &[&str]) {
1131 assert!(
1132 Command::new("git")
1133 .current_dir(root)
1134 .args(args)
1135 .status()
1136 .expect("run git")
1137 .success(),
1138 "git {args:?} failed in {}",
1139 root.display()
1140 );
1141 }
1142
1143 #[test]
1144 fn overflow_volume_promotes_deep_ignored_prefix_into_next_exclusion_set() {
1145 let root = TempDir::new().unwrap();
1146 std::fs::create_dir(root.path().join(".git")).unwrap();
1147 let fallback = [
1148 "target",
1149 "node_modules",
1150 "dist",
1151 "build",
1152 ".next",
1153 "tmp",
1154 ".bench",
1155 "coverage",
1156 "aaa",
1157 "bbb",
1158 ];
1159 for directory in fallback {
1160 std::fs::create_dir_all(root.path().join(directory)).unwrap();
1161 }
1162 let hot = root.path().join("packages/opencode-plugin/tmp");
1163 std::fs::create_dir_all(&hot).unwrap();
1164 std::fs::write(
1165 root.path().join(".gitignore"),
1166 format!(
1167 "{}packages/*/tmp/\n",
1168 fallback
1169 .iter()
1170 .map(|directory| format!("{directory}/\n"))
1171 .collect::<String>()
1172 ),
1173 )
1174 .unwrap();
1175 let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1176 let hot = std::fs::canonicalize(hot).unwrap();
1177 let matcher = shared_matcher(&canonical_root);
1178 let generation = Arc::new(AtomicU64::new(4));
1179 let shutdown = Arc::new(AtomicBool::new(false));
1180 let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1181 let (raw_tx, raw_rx) = mpsc::channel();
1182 let config = WatcherFilterConfig::new(canonical_root.clone(), None);
1183 let mut filter = WatcherFilterThread::new(
1184 config,
1185 Arc::clone(&matcher),
1186 generation,
1187 dispatch_tx,
1188 Arc::clone(&shutdown),
1189 );
1190 let handle = thread::spawn(move || filter.run(raw_rx));
1191
1192 for index in 0..64 {
1193 raw_tx
1194 .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1195 .add_path(hot.join(format!("host-install-{index}")))))
1196 .unwrap();
1197 }
1198 raw_tx
1199 .send(Ok(
1200 notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1201 ))
1202 .unwrap();
1203 assert_eq!(
1204 dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1205 WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1206 );
1207 shutdown.store(true, Ordering::SeqCst);
1208 drop(raw_tx);
1209 handle.join().unwrap();
1210
1211 let exclusions =
1212 derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1213 assert_eq!(exclusions[0].path(), canonical_root.join(".git"));
1214 assert_eq!(exclusions.last().unwrap().path(), hot);
1215 assert_eq!(
1216 exclusions.last().unwrap().source(),
1217 WatcherExclusionSource::Ranked
1218 );
1219 }
1220
1221 #[test]
1222 fn observed_exclusion_ranking_survives_state_database_reload() {
1223 let root = TempDir::new().unwrap();
1224 let storage = TempDir::new().unwrap();
1225 std::fs::create_dir(root.path().join(".git")).unwrap();
1226 let hot = root.path().join("packages/opencode-plugin/tmp");
1227 std::fs::create_dir_all(&hot).unwrap();
1228 let fallback = [
1229 "target",
1230 "node_modules",
1231 "dist",
1232 "build",
1233 ".next",
1234 "tmp",
1235 ".bench",
1236 "coverage",
1237 ];
1238 for directory in fallback {
1239 std::fs::create_dir(root.path().join(directory)).unwrap();
1240 }
1241 std::fs::write(
1242 root.path().join(".gitignore"),
1243 format!(
1244 "{}packages/*/tmp/\n",
1245 fallback
1246 .iter()
1247 .map(|directory| format!("{directory}/\n"))
1248 .collect::<String>()
1249 ),
1250 )
1251 .unwrap();
1252 let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1253 let hot = std::fs::canonicalize(hot).unwrap();
1254 let matcher = shared_matcher(&canonical_root);
1255 let counters = crate::context::watcher_counters_for_root(&canonical_root);
1256 let db = Arc::new(Mutex::new(
1257 crate::db::open(&storage.path().join("aft.db")).unwrap(),
1258 ));
1259 counters.set_observed_exclusion_prefixes(vec![crate::context::WatcherOverflowPrefix {
1260 prefix: "packages/opencode-plugin/tmp".to_string(),
1261 count: 37,
1262 }]);
1263 persist_watcher_observations(&canonical_root, &counters, Some(&db));
1264 counters.set_observed_exclusion_prefixes(Vec::new());
1265
1266 load_watcher_observations(&canonical_root, &counters, Some(&db));
1267
1268 assert_eq!(
1269 counters.observed_exclusion_prefixes(),
1270 vec![crate::context::WatcherOverflowPrefix {
1271 prefix: "packages/opencode-plugin/tmp".to_string(),
1272 count: 37,
1273 }]
1274 );
1275 let exclusions =
1276 derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1277 assert_eq!(exclusions[0].path(), canonical_root.join(".git"));
1278 assert_eq!(exclusions.last().unwrap().path(), hot);
1279 assert_eq!(
1280 exclusions.last().unwrap().source(),
1281 WatcherExclusionSource::Ranked
1282 );
1283 }
1284
1285 #[test]
1286 fn sibling_worktree_inherits_repository_ranking_on_first_bind() {
1287 let container = TempDir::new().unwrap();
1288 let storage = TempDir::new().unwrap();
1289 let main = container.path().join("main");
1290 let sibling = container.path().join("sibling");
1291 std::fs::create_dir(&main).unwrap();
1292 run_git(&main, &["init"]);
1293 std::fs::write(main.join(".gitignore"), "packages/*/tmp/\n").unwrap();
1294 std::fs::write(main.join("tracked.txt"), "repository identity\n").unwrap();
1295 run_git(&main, &["add", "."]);
1296 run_git(
1297 &main,
1298 &[
1299 "-c",
1300 "user.name=AFT Test",
1301 "-c",
1302 "user.email=aft@example.invalid",
1303 "commit",
1304 "-m",
1305 "fixture",
1306 ],
1307 );
1308 run_git(
1309 &main,
1310 &[
1311 "worktree",
1312 "add",
1313 "-b",
1314 "watcher-sibling",
1315 sibling.to_str().unwrap(),
1316 ],
1317 );
1318
1319 let main = std::fs::canonicalize(main).unwrap();
1320 let sibling = std::fs::canonicalize(sibling).unwrap();
1321 let relative_hot = "packages/opencode-plugin/tmp";
1322 std::fs::create_dir_all(main.join(relative_hot)).unwrap();
1323 std::fs::create_dir_all(sibling.join(relative_hot)).unwrap();
1324 assert_ne!(
1325 crate::path_identity::project_scope_key(&main),
1326 crate::path_identity::project_scope_key(&sibling)
1327 );
1328 assert_eq!(
1329 crate::search_index::artifact_cache_key(&main),
1330 crate::search_index::artifact_cache_key(&sibling)
1331 );
1332
1333 let db = Arc::new(Mutex::new(
1334 crate::db::open(&storage.path().join("aft.db")).unwrap(),
1335 ));
1336 let main_counters = crate::context::watcher_counters_for_root(&main);
1337 main_counters.set_observed_exclusion_prefixes(vec![
1338 crate::context::WatcherOverflowPrefix {
1339 prefix: relative_hot.to_string(),
1340 count: 91,
1341 },
1342 ]);
1343 persist_watcher_observations(&main, &main_counters, Some(&db));
1344
1345 let sibling_counters = crate::context::watcher_counters_for_root(&sibling);
1346 sibling_counters.set_observed_exclusion_prefixes(Vec::new());
1347 load_watcher_observations(&sibling, &sibling_counters, Some(&db));
1348
1349 assert_eq!(
1350 sibling_counters.observed_exclusion_prefixes(),
1351 vec![crate::context::WatcherOverflowPrefix {
1352 prefix: relative_hot.to_string(),
1353 count: 91,
1354 }]
1355 );
1356 let matcher = shared_matcher(&sibling);
1357 let exclusions =
1358 derive_excluded_subtrees(&sibling, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1359 assert_eq!(exclusions[0].path(), sibling.join(relative_hot));
1360 assert_eq!(exclusions[0].source(), WatcherExclusionSource::Ranked);
1361 }
1362
1363 #[test]
1364 fn exclusion_derivation_uses_fixed_priority_caps_and_skips_missing_directories() {
1365 let root = TempDir::new().unwrap();
1366 std::fs::create_dir(root.path().join(".git")).unwrap();
1367 let priorities = [
1368 "target",
1369 "node_modules",
1370 "dist",
1371 "build",
1372 ".next",
1373 ".venv",
1374 "venv",
1375 "__pycache__",
1376 ];
1377 for name in priorities {
1378 std::fs::create_dir(root.path().join(name)).unwrap();
1379 }
1380 std::fs::create_dir(root.path().join("other-generated")).unwrap();
1381 std::fs::write(
1382 root.path().join(".gitignore"),
1383 format!(
1384 "{}other-generated/\nmissing/\n",
1385 priorities
1386 .iter()
1387 .rev()
1388 .map(|name| format!("{name}/\n"))
1389 .collect::<String>()
1390 ),
1391 )
1392 .unwrap();
1393 let matcher = shared_matcher(root.path());
1394
1395 let exclusions =
1396 derive_excluded_subtrees(root.path(), &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1397
1398 assert_eq!(exclusions.len(), WATCHER_EXCLUSION_LIMIT);
1399 assert_eq!(
1400 exclusions[0].path(),
1401 std::fs::canonicalize(root.path().join(".git")).unwrap()
1402 );
1403 assert_eq!(
1404 watcher_exclusion_paths(&exclusions[1..]),
1405 priorities[..WATCHER_EXCLUSION_LIMIT - 1]
1406 .iter()
1407 .map(|name| std::fs::canonicalize(root.path().join(name)).unwrap())
1408 .collect::<Vec<_>>()
1409 );
1410 assert!(!exclusions
1411 .iter()
1412 .any(|exclusion| exclusion.path().ends_with("missing")));
1413 assert!(!exclusions
1414 .iter()
1415 .any(|exclusion| exclusion.path().ends_with("other-generated")));
1416 }
1417
1418 #[test]
1419 fn fresh_root_seeds_heavy_directories_before_late_gitignore_patterns() {
1420 let root = TempDir::new().unwrap();
1421 std::fs::create_dir(root.path().join(".git")).unwrap();
1422 let patterns = [
1423 "generated-01",
1424 "generated-02",
1425 "generated-03",
1426 "generated-04",
1427 "generated-05",
1428 "generated-06",
1429 "generated-07",
1430 "generated-08",
1431 "generated-09",
1432 "build",
1433 "target",
1434 "node_modules",
1435 ];
1436 for pattern in patterns {
1437 std::fs::create_dir(root.path().join(pattern)).unwrap();
1438 }
1439 std::fs::write(
1440 root.path().join(".gitignore"),
1441 patterns
1442 .iter()
1443 .map(|pattern| format!("{pattern}/\n"))
1444 .collect::<String>(),
1445 )
1446 .unwrap();
1447 let canonical_root = std::fs::canonicalize(root.path()).unwrap();
1448 let matcher = shared_matcher(&canonical_root);
1449
1450 let exclusions =
1451 derive_excluded_subtrees(&canonical_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1452
1453 assert_eq!(exclusions.len(), WATCHER_EXCLUSION_LIMIT);
1454 assert_eq!(
1455 watcher_exclusion_paths(&exclusions[..4]),
1456 [".git", "target", "node_modules", "build"]
1457 .iter()
1458 .map(|name| canonical_root.join(name))
1459 .collect::<Vec<_>>()
1460 );
1461 assert!(exclusions[..4]
1462 .iter()
1463 .all(|exclusion| exclusion.source() == WatcherExclusionSource::Seed));
1464 assert_eq!(
1465 watcher_exclusion_paths(&exclusions[4..]),
1466 patterns[..4]
1467 .iter()
1468 .map(|name| canonical_root.join(name))
1469 .collect::<Vec<_>>()
1470 );
1471 assert!(exclusions[4..]
1472 .iter()
1473 .all(|exclusion| exclusion.source() == WatcherExclusionSource::Gitignore));
1474
1475 let source_root = TempDir::new().unwrap();
1476 std::fs::create_dir(source_root.path().join(".git")).unwrap();
1477 std::fs::create_dir(source_root.path().join("build")).unwrap();
1478 std::fs::create_dir(source_root.path().join("ignored")).unwrap();
1479 std::fs::write(source_root.path().join(".gitignore"), "ignored/\n").unwrap();
1480 let source_root = std::fs::canonicalize(source_root.path()).unwrap();
1481 let matcher = shared_matcher(&source_root);
1482 let exclusions =
1483 derive_excluded_subtrees(&source_root, &matcher, Some(WATCHER_EXCLUSION_LIMIT));
1484 assert!(!exclusions
1485 .iter()
1486 .any(|exclusion| exclusion.path() == source_root.join("build")));
1487 }
1488
1489 #[test]
1490 fn event_kind_filter_accepts_content_changes_only() {
1491 assert!(watcher_event_invalidates(&EventKind::Create(
1492 CreateKind::File
1493 )));
1494 assert!(watcher_event_invalidates(&EventKind::Modify(
1495 ModifyKind::Data(DataChange::Content)
1496 )));
1497 assert!(watcher_event_invalidates(&EventKind::Modify(
1498 ModifyKind::Metadata(MetadataKind::WriteTime)
1499 )));
1500 assert!(!watcher_event_invalidates(&EventKind::Modify(
1501 ModifyKind::Metadata(MetadataKind::AccessTime)
1502 )));
1503 assert!(!watcher_event_invalidates(&EventKind::Modify(
1504 ModifyKind::Metadata(MetadataKind::Permissions)
1505 )));
1506 assert!(!watcher_event_invalidates(&EventKind::Access(
1507 AccessKind::Open(AccessMode::Read)
1508 )));
1509 assert!(!watcher_event_invalidates(&EventKind::Other));
1510 }
1511
1512 #[test]
1513 fn high_churn_infra_skip_drops_build_dirs_but_keeps_git_and_source() {
1514 assert!(watcher_path_is_high_churn_infra(Path::new(
1517 "/proj/target/debug/deps/foo.o"
1518 )));
1519 assert!(watcher_path_is_high_churn_infra(Path::new(
1520 "/proj/node_modules/.bin/x"
1521 )));
1522 assert!(watcher_path_is_high_churn_infra(Path::new(
1523 "/proj/.alfonso/notes/x"
1524 )));
1525 assert!(!watcher_path_is_high_churn_infra(Path::new(
1528 "/proj/.git/info/exclude"
1529 )));
1530 assert!(!watcher_path_is_high_churn_infra(Path::new(
1532 "/proj/src/main.rs"
1533 )));
1534 assert!(watcher_path_is_infra_skip(Path::new("/proj/.git/index")));
1536 }
1537
1538 #[test]
1539 fn git_head_and_resolved_ref_bypass_git_infra_filter() {
1540 let tmp = TempDir::new().unwrap();
1541 let root = std::fs::canonicalize(tmp.path()).unwrap();
1542 let git = root.join(".git");
1543 let head = git.join("HEAD");
1544 let resolved_ref = git.join("refs/heads/main");
1545 std::fs::create_dir_all(resolved_ref.parent().unwrap()).unwrap();
1546 std::fs::write(&head, "ref: refs/heads/main\n").unwrap();
1547 std::fs::write(&resolved_ref, "0000000000000000000000000000000000000000\n").unwrap();
1548 std::fs::write(git.join("index"), []).unwrap();
1549 let config = WatcherFilterConfig::new(root.clone(), None);
1550 let matcher = shared_matcher(&root);
1551
1552 let filtered = filter_watcher_raw_paths_for_test(
1553 &config,
1554 &matcher,
1555 [head.clone(), resolved_ref.clone(), git.join("index")],
1556 );
1557
1558 assert_eq!(filtered.changed, BTreeSet::from([head, resolved_ref]));
1559 }
1560
1561 #[test]
1562 fn rescan_event_dispatches_control_and_supersedes_pending_paths() {
1563 let tmp = TempDir::new().unwrap();
1564 let root = std::fs::canonicalize(tmp.path()).unwrap();
1565 let pending = root.join("pending.rs");
1566 std::fs::write(&pending, "fn main() {}\n").unwrap();
1567 let matcher = Arc::new(RwLock::new(None));
1568 let generation = Arc::new(AtomicU64::new(0));
1569 let shutdown = Arc::new(AtomicBool::new(false));
1570 let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1571 let (raw_tx, raw_rx) = mpsc::channel();
1572 let config = WatcherFilterConfig::new(root, None);
1573 let counters = Arc::clone(&config.counters);
1574 let mut filter = WatcherFilterThread::new(
1575 config,
1576 matcher,
1577 generation,
1578 dispatch_tx,
1579 Arc::clone(&shutdown),
1580 );
1581 let handle = thread::spawn(move || filter.run(raw_rx));
1582
1583 let mut granular = notify::Event::new(EventKind::Create(CreateKind::File));
1584 granular.paths.push(pending);
1585 raw_tx.send(Ok(granular)).unwrap();
1586 for (info, expected) in [
1587 (
1588 Some("rescan: buffer overflow"),
1589 RescanReason::BufferOverflow,
1590 ),
1591 (Some("rescan: kernel dropped"), RescanReason::KernelDropped),
1592 (Some("rescan: user dropped"), RescanReason::UserDropped),
1593 (None, RescanReason::Unknown),
1594 ] {
1595 let mut event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan);
1596 if let Some(info) = info {
1597 event = event.set_info(info);
1598 }
1599 raw_tx.send(Ok(event)).unwrap();
1600 assert_eq!(
1601 dispatch_rx
1602 .recv_timeout(Duration::from_secs(2))
1603 .expect("rescan event"),
1604 WatcherDispatchEvent::RescanRequired(expected)
1605 );
1606 }
1607 assert!(
1608 dispatch_rx
1609 .recv_timeout(WATCHER_FLUSH_WINDOW + Duration::from_millis(100))
1610 .is_err(),
1611 "pending granular paths should be cleared by a rescan signal"
1612 );
1613 let snapshot = counters.snapshot();
1614 assert_eq!(snapshot.raw_events_total, 5);
1615 assert_eq!(snapshot.invalidating_events_total, 1);
1616 assert_eq!(snapshot.paths_after_gitignore_total, 0);
1617 assert_eq!(snapshot.paths_dispatched_total, 0);
1618
1619 shutdown.store(true, Ordering::SeqCst);
1620 drop(raw_tx);
1621 handle.join().unwrap();
1622 }
1623
1624 #[test]
1625 fn overflow_log_attributes_excluded_and_nonexcluded_burst_prefixes() {
1626 let tmp = TempDir::new().unwrap();
1627 let root = std::fs::canonicalize(tmp.path()).unwrap();
1628 let target = root.join("target/cache");
1629 let source = root.join("src/generated");
1630 std::fs::create_dir_all(&target).unwrap();
1631 std::fs::create_dir_all(&source).unwrap();
1632 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
1633 let matcher = shared_matcher(&root);
1634 let generation = Arc::new(AtomicU64::new(7));
1635 let shutdown = Arc::new(AtomicBool::new(false));
1636 let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(1);
1637 let (raw_tx, raw_rx) = mpsc::channel();
1638 let config = WatcherFilterConfig::new(root.clone(), None);
1639 config.counters.set_backend_exclusions(
1640 7,
1641 (0..WATCHER_EXCLUSION_LIMIT)
1642 .map(|index| root.join(format!("excluded-{index}")))
1643 .collect(),
1644 );
1645 let counters = Arc::clone(&config.counters);
1646 let mut filter = WatcherFilterThread::new(
1647 config,
1648 matcher,
1649 generation,
1650 dispatch_tx,
1651 Arc::clone(&shutdown),
1652 );
1653 let handle = thread::spawn(move || filter.run(raw_rx));
1654
1655 for index in 0..20 {
1656 raw_tx
1657 .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1658 .add_path(target.join(format!("artifact-{index}")))))
1659 .unwrap();
1660 }
1661 for index in 0..7 {
1662 raw_tx
1663 .send(Ok(notify::Event::new(EventKind::Create(CreateKind::File))
1664 .add_path(source.join(format!("source-{index}.rs")))))
1665 .unwrap();
1666 }
1667 raw_tx
1668 .send(Ok(
1669 notify::Event::new(EventKind::Other).set_flag(Flag::Rescan)
1670 ))
1671 .unwrap();
1672 assert_eq!(
1673 dispatch_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1674 WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)
1675 );
1676
1677 shutdown.store(true, Ordering::SeqCst);
1678 drop(raw_tx);
1679 handle.join().unwrap();
1680
1681 let lines = take_watcher_overflow_logs_for_test();
1682 let line = lines
1683 .iter()
1684 .find(|line| line.contains(&format!("root={}", root.display())))
1685 .unwrap_or_else(|| panic!("missing overflow line for {}: {lines:?}", root.display()));
1686 assert!(line.contains("matcher_generation=7"), "line: {line}");
1687 assert!(line.contains("target/cache:20"), "line: {line}");
1688 assert!(line.contains("src/generated:7"), "line: {line}");
1689 assert!(line.contains("queue_depth=unavailable"), "line: {line}");
1690 assert!(line.contains("rescan_in_progress=false"), "line: {line}");
1691 for index in 0..WATCHER_EXCLUSION_LIMIT {
1692 assert!(line.contains(&format!("excluded-{index}")), "line: {line}");
1693 }
1694 let snapshot = counters.snapshot();
1695 assert_eq!(snapshot.overflows_total, 1);
1696 assert_eq!(snapshot.overflows_during_rescan, 0);
1697 assert_eq!(snapshot.last_overflow_prefixes[0].prefix, "target/cache");
1698 assert_eq!(snapshot.last_overflow_prefixes[0].count, 20);
1699 }
1700
1701 #[test]
1702 fn watcher_thread_records_filter_pipeline_counters() {
1703 let tmp = TempDir::new().unwrap();
1704 let root = std::fs::canonicalize(tmp.path()).unwrap();
1705 let changed = root.join("changed.rs");
1706 std::fs::write(&changed, "fn changed() {}\n").unwrap();
1707 let matcher = Arc::new(RwLock::new(None));
1708 let generation = Arc::new(AtomicU64::new(0));
1709 let shutdown = Arc::new(AtomicBool::new(false));
1710 let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1711 let (raw_tx, raw_rx) = mpsc::channel();
1712 let config = WatcherFilterConfig::new(root, None);
1713 let counters = Arc::clone(&config.counters);
1714 let mut filter = WatcherFilterThread::new(
1715 config,
1716 matcher,
1717 generation,
1718 dispatch_tx,
1719 Arc::clone(&shutdown),
1720 );
1721 let handle = thread::spawn(move || filter.run(raw_rx));
1722
1723 let mut event = notify::Event::new(EventKind::Create(CreateKind::File));
1724 event.paths.push(changed.clone());
1725 raw_tx.send(Ok(event)).unwrap();
1726 assert_eq!(
1727 dispatch_rx
1728 .recv_timeout(Duration::from_secs(2))
1729 .expect("filtered paths"),
1730 WatcherDispatchEvent::Paths(vec![changed])
1731 );
1732
1733 shutdown.store(true, Ordering::SeqCst);
1734 drop(raw_tx);
1735 handle.join().unwrap();
1736
1737 let snapshot = counters.snapshot();
1738 assert_eq!(snapshot.raw_events_total, 1);
1739 assert_eq!(snapshot.raw_events_since_last_rescan, 1);
1740 assert_eq!(snapshot.invalidating_events_total, 1);
1741 assert_eq!(snapshot.invalidating_events_since_last_rescan, 1);
1742 assert_eq!(snapshot.paths_after_gitignore_total, 1);
1743 assert_eq!(snapshot.paths_after_gitignore_since_last_rescan, 1);
1744 assert_eq!(snapshot.paths_dispatched_total, 1);
1745 assert_eq!(snapshot.paths_dispatched_since_last_rescan, 1);
1746 }
1747
1748 #[test]
1749 fn configured_context_and_filter_thread_share_root_counters() {
1750 let tmp = TempDir::new().unwrap();
1751 let root = std::fs::canonicalize(tmp.path()).unwrap();
1752 let ctx = crate::context::AppContext::new(
1753 crate::context::default_language_provider_factory(),
1754 crate::config::Config::default(),
1755 );
1756 ctx.update_config(|config| config.project_root = Some(root.clone()));
1757 let config = WatcherFilterConfig::new(root, None);
1758
1759 config.counters.note_raw_event();
1760
1761 assert_eq!(ctx.watcher_counters().snapshot().raw_events_total, 1);
1762 }
1763
1764 #[test]
1765 fn filters_gitignored_paths_with_shared_matcher() {
1766 let tmp = TempDir::new().unwrap();
1767 let root = std::fs::canonicalize(tmp.path()).unwrap();
1768 std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
1769 std::fs::create_dir_all(root.join("ignored")).unwrap();
1770 std::fs::write(root.join("ignored/file.ts"), "ignored").unwrap();
1771 std::fs::write(root.join("kept.ts"), "kept").unwrap();
1772 let matcher = shared_matcher(&root);
1773 let config = WatcherFilterConfig::new(root.clone(), None);
1774
1775 let filtered = filter_watcher_raw_paths_for_test(
1776 &config,
1777 &matcher,
1778 [root.join("ignored/file.ts"), root.join("kept.ts")],
1779 );
1780
1781 assert!(!filtered.changed.contains(&root.join("ignored/file.ts")));
1782 assert!(filtered.changed.contains(&root.join("kept.ts")));
1783 }
1784
1785 #[test]
1786 fn ignore_rule_paths_are_control_only_for_external_excludes() {
1787 let tmp = TempDir::new().unwrap();
1788 let root = std::fs::canonicalize(tmp.path()).unwrap();
1789 let git_info = root.join(".git").join("info");
1790 std::fs::create_dir_all(&git_info).unwrap();
1791 let exclude = git_info.join("exclude");
1792 std::fs::write(&exclude, "ignored/\n").unwrap();
1793 let matcher = Arc::new(RwLock::new(None));
1794 let config = WatcherFilterConfig::new(root, None);
1795
1796 let filtered = filter_watcher_raw_paths_for_test(&config, &matcher, [exclude]);
1797
1798 assert!(filtered.ignore_file_changed);
1799 assert!(filtered.changed.is_empty());
1800 }
1801
1802 #[test]
1803 fn root_deleted_sends_control_and_exits() {
1804 let tmp = TempDir::new().unwrap();
1805 let root = std::fs::canonicalize(tmp.path()).unwrap();
1806 let matcher = Arc::new(RwLock::new(None));
1807 let generation = Arc::new(AtomicU64::new(0));
1808 let shutdown = Arc::new(AtomicBool::new(false));
1809 let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel();
1810 let (raw_tx, raw_rx) = mpsc::channel();
1811 let config = WatcherFilterConfig::new(root.clone(), None);
1812 let mut filter = WatcherFilterThread::new(
1813 config,
1814 matcher,
1815 generation,
1816 dispatch_tx,
1817 Arc::clone(&shutdown),
1818 );
1819 let handle = thread::spawn(move || filter.run(raw_rx));
1820 let _raw_tx = raw_tx;
1821 std::fs::remove_dir_all(&root).unwrap();
1822
1823 let event = dispatch_rx
1824 .recv_timeout(Duration::from_secs(2))
1825 .expect("root deleted event");
1826 assert_eq!(event, WatcherDispatchEvent::RootDeleted);
1827 shutdown.store(true, Ordering::SeqCst);
1828 handle.join().unwrap();
1829 }
1830}