1use std::collections::{HashSet, VecDeque};
33use std::path::{Component, Path, PathBuf};
34use std::sync::mpsc::channel;
35
36use common_telemetry::{error, info, warn};
37use notify::{EventKind, RecursiveMode, Watcher};
38use snafu::ResultExt;
39
40use crate::error::{FileWatchSnafu, InvalidPathSnafu, Result};
41
42#[derive(Debug, Clone, Default)]
44pub struct FileWatcherConfig {
45 pub include_remove_events: bool,
47}
48
49impl FileWatcherConfig {
50 pub fn new() -> Self {
51 Default::default()
52 }
53
54 pub fn include_remove_events(mut self) -> Self {
55 self.include_remove_events = true;
56 self
57 }
58}
59
60pub struct FileWatcherBuilder {
67 config: FileWatcherConfig,
68 file_paths: Vec<PathBuf>,
71}
72
73impl FileWatcherBuilder {
74 pub fn new() -> Self {
76 Self {
77 config: FileWatcherConfig::default(),
78 file_paths: Vec::new(),
79 }
80 }
81
82 pub fn config(mut self, config: FileWatcherConfig) -> Self {
84 self.config = config;
85 self
86 }
87
88 pub fn watch_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
93 let path = path.as_ref();
94 snafu::ensure!(
95 path.is_file(),
96 InvalidPathSnafu {
97 path: path.display().to_string(),
98 }
99 );
100
101 self.file_paths.push(path.to_path_buf());
102 Ok(self)
103 }
104
105 pub fn watch_paths<P: AsRef<Path>, I: IntoIterator<Item = P>>(
109 mut self,
110 paths: I,
111 ) -> Result<Self> {
112 for path in paths {
113 self = self.watch_path(path)?;
114 }
115 Ok(self)
116 }
117
118 pub fn spawn<F>(self, callback: F) -> Result<()>
129 where
130 F: Fn() + Send + 'static,
131 {
132 let (tx, rx) = channel::<notify::Result<notify::Event>>();
133 let mut watcher =
134 notify::recommended_watcher(tx).context(FileWatchSnafu { path: "<none>" })?;
135
136 let mut targets = resolve_watch_targets(&self.file_paths);
137 let mut skipped = Vec::new();
138 for dir in &targets.dirs {
139 match watcher.watch(dir, RecursiveMode::NonRecursive) {
140 Ok(()) => warn_if_root(dir),
141 Err(err) if !targets.file_dirs.contains(dir) => {
145 warn!("Failed to watch {:?}, skipping: {}", dir, err);
146 skipped.push(dir.clone());
147 }
148 Err(err) => {
149 return Err(err).context(FileWatchSnafu {
150 path: dir.display().to_string(),
151 });
152 }
153 }
154 }
155 for dir in skipped {
156 targets.dirs.remove(&dir);
157 }
158
159 let config = self.config;
160 let file_paths = self.file_paths;
161
162 info!(
163 "Spawning file watcher for paths: {:?} (watching directories: {:?})",
164 file_paths
165 .iter()
166 .map(|p| p.display().to_string())
167 .collect::<Vec<_>>(),
168 targets.dirs
169 );
170
171 std::thread::spawn(move || {
172 let mut watcher = watcher;
174
175 while let Ok(res) = rx.recv() {
176 match res {
177 Ok(event) => {
178 if !is_relevant_event(&event.kind, &config)
179 || !event
180 .paths
181 .iter()
182 .any(|p| targets.entries.contains(&entry_key(p)))
183 {
184 continue;
185 }
186
187 info!(?event.kind, ?event.paths, "Detected folder change");
188 targets = rearm_watches(&mut watcher, &file_paths, targets);
191 callback();
192 }
193 Err(err) => {
194 warn!("File watcher error: {}", err);
195 }
196 }
197 }
198
199 error!("File watcher channel closed unexpectedly");
200 });
201
202 Ok(())
203 }
204}
205
206impl Default for FileWatcherBuilder {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212const MAX_SYMLINK_HOPS: usize = 40;
215
216#[derive(Debug, Default)]
218struct WatchTargets {
219 dirs: HashSet<PathBuf>,
221 file_dirs: HashSet<PathBuf>,
223 entries: HashSet<PathBuf>,
226}
227
228fn entry_key(path: &Path) -> PathBuf {
236 #[cfg(any(target_os = "macos", windows))]
237 {
238 PathBuf::from(path.to_string_lossy().to_lowercase())
239 }
240 #[cfg(not(any(target_os = "macos", windows)))]
241 {
242 path.to_path_buf()
243 }
244}
245
246fn resolve_watch_targets(paths: &[PathBuf]) -> WatchTargets {
261 let mut targets = WatchTargets::default();
262 for path in paths {
263 resolve_one(path, &mut targets);
264 }
265 targets
266}
267
268fn resolve_one(path: &Path, targets: &mut WatchTargets) {
269 let Ok(path) = std::path::absolute(path) else {
270 return;
271 };
272
273 let mut pending: VecDeque<PathBuf> = path
274 .components()
275 .map(|c| PathBuf::from(c.as_os_str()))
276 .collect();
277 let mut resolved = PathBuf::new();
280 let mut hops = 0;
281
282 while let Some(component) = pending.pop_front() {
283 match component.components().next() {
284 Some(Component::Prefix(_) | Component::RootDir) => resolved.push(&component),
285 Some(Component::CurDir) | None => {}
286 Some(Component::ParentDir) => {
287 resolved.pop();
288 }
289 Some(Component::Normal(name)) => {
290 let candidate = resolved.join(name);
291 let is_last = pending.is_empty();
292 match std::fs::symlink_metadata(&candidate) {
293 Ok(meta) if meta.file_type().is_symlink() => {
294 targets.dirs.insert(resolved.clone());
295 targets.entries.insert(entry_key(&candidate));
296 hops += 1;
297 let Ok(link) = std::fs::read_link(&candidate) else {
298 return;
299 };
300 if hops > MAX_SYMLINK_HOPS {
301 warn!("Too many levels of symlinks while resolving {:?}", path);
302 return;
303 }
304 for c in link.components().rev() {
307 pending.push_front(PathBuf::from(c.as_os_str()));
308 }
309 }
310 Ok(_) if !is_last => resolved = candidate,
311 Ok(_) | Err(_) => {
312 if is_last {
313 targets.file_dirs.insert(resolved.clone());
314 }
315 targets.dirs.insert(resolved);
316 targets.entries.insert(entry_key(&candidate));
317 return;
318 }
319 }
320 }
321 }
322 }
323}
324
325fn rearm_watches(
329 watcher: &mut impl Watcher,
330 file_paths: &[PathBuf],
331 old: WatchTargets,
332) -> WatchTargets {
333 let mut new = resolve_watch_targets(file_paths);
334
335 for dir in old.dirs.difference(&new.dirs) {
336 let _ = watcher.unwatch(dir);
338 }
339 let mut failed = Vec::new();
340 for dir in new.dirs.difference(&old.dirs) {
341 match watcher.watch(dir, RecursiveMode::NonRecursive) {
342 Ok(()) => {
343 info!("File watcher now watching {:?}", dir);
344 warn_if_root(dir);
345 }
346 Err(err) => {
347 warn!("Failed to watch {:?}: {}", dir, err);
348 failed.push(dir.clone());
349 }
350 }
351 }
352 for dir in failed {
353 new.dirs.remove(&dir);
354 }
355 new
356}
357
358fn warn_if_root(dir: &Path) {
363 if dir.parent().is_none() {
364 warn!(
365 "File watcher is watching the root directory {:?} because a watched path goes \
366 through a symlink in it; on macOS this receives every event on the volume",
367 dir
368 );
369 }
370}
371
372fn is_relevant_event(kind: &EventKind, config: &FileWatcherConfig) -> bool {
374 match kind {
375 EventKind::Modify(_) | EventKind::Create(_) => true,
376 EventKind::Remove(_) => config.include_remove_events,
377 _ => false,
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use std::sync::atomic::{AtomicUsize, Ordering};
384 use std::sync::{Arc, Mutex};
385 use std::time::{Duration, Instant};
386
387 use common_test_util::temp_dir::create_temp_dir;
388
389 use super::*;
390
391 const TIMEOUT: Duration = Duration::from_secs(10);
392
393 fn wait_until(cond: impl Fn() -> bool) -> bool {
395 let deadline = Instant::now() + TIMEOUT;
396 while Instant::now() < deadline {
397 if cond() {
398 return true;
399 }
400 std::thread::sleep(Duration::from_millis(20));
401 }
402 cond()
403 }
404
405 fn spawn_counter(path: &Path) -> Arc<AtomicUsize> {
407 let counter = Arc::new(AtomicUsize::new(0));
408 let counter_clone = counter.clone();
409 FileWatcherBuilder::new()
410 .watch_path(path)
411 .unwrap()
412 .config(FileWatcherConfig::new())
413 .spawn(move || {
414 counter_clone.fetch_add(1, Ordering::SeqCst);
415 })
416 .unwrap();
417 counter
418 }
419
420 fn spawn_reader(path: &Path) -> Arc<Mutex<String>> {
423 let content = Arc::new(Mutex::new(String::new()));
424 let content_clone = content.clone();
425 let path_owned = path.to_path_buf();
426 FileWatcherBuilder::new()
427 .watch_path(path)
428 .unwrap()
429 .spawn(move || {
430 if let Ok(c) = std::fs::read_to_string(&path_owned) {
431 *content_clone.lock().unwrap() = c;
432 }
433 })
434 .unwrap();
435 content
436 }
437
438 #[test]
439 fn test_file_watcher_detects_changes() {
440 common_telemetry::init_default_ut_logging();
441
442 let dir = create_temp_dir("test_file_watcher");
443 let file_path = dir.path().join("test_file.txt");
444 std::fs::write(&file_path, "initial content").unwrap();
445
446 let counter = spawn_counter(&file_path);
448 std::fs::write(&file_path, "modified content").unwrap();
449
450 assert!(
451 wait_until(|| counter.load(Ordering::SeqCst) >= 1),
452 "Watcher should have detected at least one change"
453 );
454 }
455
456 #[test]
457 fn test_file_watcher_detects_delete_and_recreate() {
458 common_telemetry::init_default_ut_logging();
459
460 let dir = create_temp_dir("test_file_watcher_recreate");
461 let file_path = dir.path().join("test_file.txt");
462 std::fs::write(&file_path, "initial content").unwrap();
463
464 let content = spawn_reader(&file_path);
465
466 std::fs::remove_file(&file_path).unwrap();
467 std::fs::write(&file_path, "recreated content").unwrap();
469
470 assert!(
471 wait_until(|| *content.lock().unwrap() == "recreated content"),
472 "Watcher should have detected file recreation"
473 );
474 }
475
476 #[test]
477 fn test_file_watcher_ignores_unrelated_files() {
478 common_telemetry::init_default_ut_logging();
479
480 let dir = create_temp_dir("test_file_watcher_unrelated");
481 let file_path = dir.path().join("watched.txt");
482 std::fs::write(&file_path, "initial content").unwrap();
483
484 let counter = spawn_counter(&file_path);
485
486 std::fs::write(dir.path().join("unrelated.txt"), "noise").unwrap();
487 std::fs::write(&file_path, "modified content").unwrap();
490 assert!(wait_until(|| counter.load(Ordering::SeqCst) >= 1));
491 std::thread::sleep(Duration::from_millis(200));
492 let seen = counter.load(Ordering::SeqCst);
493
494 std::fs::write(dir.path().join("unrelated.txt"), "more noise").unwrap();
496 std::fs::remove_file(dir.path().join("unrelated.txt")).unwrap();
497 std::thread::sleep(Duration::from_millis(500));
498 assert_eq!(
499 counter.load(Ordering::SeqCst),
500 seen,
501 "Unrelated files in the watched directory must not trigger the callback"
502 );
503 }
504
505 #[cfg(unix)]
506 #[test]
507 fn test_resolve_watch_targets_symlink_chain() {
508 use std::os::unix::fs::symlink;
509
510 let dir = create_temp_dir("test_resolve_watch_targets");
511 let base = dir.path().canonicalize().unwrap();
512 let keys = |paths: &[PathBuf]| paths.iter().map(|p| entry_key(p)).collect::<HashSet<_>>();
513
514 std::fs::create_dir(base.join("real")).unwrap();
517 std::fs::write(base.join("real/users"), "").unwrap();
518 symlink("real", base.join("secrets")).unwrap();
519 std::fs::create_dir(base.join("conf")).unwrap();
520 symlink("../secrets/users", base.join("conf/users")).unwrap();
521
522 let targets = resolve_watch_targets(&[base.join("conf/users")]);
523 assert_eq!(
524 targets.dirs,
525 HashSet::from([base.clone(), base.join("conf"), base.join("real")])
526 );
527 assert_eq!(targets.file_dirs, HashSet::from([base.join("real")]));
528 assert_eq!(
529 targets.entries,
530 keys(&[
531 base.join("conf/users"),
532 base.join("secrets"),
533 base.join("real/users"),
534 ])
535 );
536
537 let targets = resolve_watch_targets(&[base.join("real/users")]);
539 assert_eq!(targets.dirs, HashSet::from([base.join("real")]));
540 assert_eq!(targets.entries, keys(&[base.join("real/users")]));
541
542 std::fs::remove_file(base.join("secrets")).unwrap();
544 let targets = resolve_watch_targets(&[base.join("conf/users")]);
545 assert_eq!(
546 targets.dirs,
547 HashSet::from([base.clone(), base.join("conf")])
548 );
549 assert!(targets.entries.contains(&entry_key(&base.join("secrets"))));
550
551 symlink("loop", base.join("loop")).unwrap();
553 let targets = resolve_watch_targets(&[base.join("loop")]);
554 assert_eq!(targets.dirs, HashSet::from([base.clone()]));
555 }
556
557 #[cfg(unix)]
558 #[test]
559 fn test_file_watcher_detects_dir_symlink_swap() {
560 use std::os::unix::fs::symlink;
561
562 common_telemetry::init_default_ut_logging();
563
564 let dir = create_temp_dir("test_file_watcher_symlink_swap");
565 let base = dir.path();
566 std::fs::create_dir(base.join("gen1")).unwrap();
567 std::fs::write(base.join("gen1/file"), "1").unwrap();
568 std::fs::create_dir(base.join("gen2")).unwrap();
569 std::fs::write(base.join("gen2/file"), "2").unwrap();
570 symlink(base.join("gen1"), base.join("current")).unwrap();
571 std::fs::create_dir(base.join("conf")).unwrap();
572 symlink(base.join("current/file"), base.join("conf/file")).unwrap();
573
574 let content = spawn_reader(&base.join("conf/file"));
575
576 symlink(base.join("gen2"), base.join("current.tmp")).unwrap();
578 std::fs::rename(base.join("current.tmp"), base.join("current")).unwrap();
579 assert!(
580 wait_until(|| *content.lock().unwrap() == "2"),
581 "Watcher should have detected the swap"
582 );
583 std::thread::sleep(Duration::from_millis(200));
586
587 std::fs::write(base.join("gen2/file"), "22").unwrap();
589 assert!(
590 wait_until(|| *content.lock().unwrap() == "22"),
591 "Watcher should have detected the edit of the new target"
592 );
593 }
594
595 #[cfg(unix)]
599 #[test]
600 fn test_file_watcher_skips_unreadable_link_dir() {
601 use std::os::unix::fs::{PermissionsExt, symlink};
602
603 common_telemetry::init_default_ut_logging();
604
605 let dir = create_temp_dir("test_file_watcher_unreadable_dir");
606 let base = dir.path();
607 std::fs::create_dir(base.join("real")).unwrap();
608 std::fs::write(base.join("real/file"), "1").unwrap();
609 std::fs::create_dir(base.join("locked")).unwrap();
610 symlink(base.join("real/file"), base.join("locked/file")).unwrap();
611
612 struct RestoreMode(PathBuf);
616 impl Drop for RestoreMode {
617 fn drop(&mut self) {
618 let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
619 }
620 }
621
622 let _restore = RestoreMode(base.join("locked"));
624 std::fs::set_permissions(base.join("locked"), std::fs::Permissions::from_mode(0o311))
626 .unwrap();
627 if std::fs::read_dir(base.join("locked")).is_ok() {
628 return;
630 }
631
632 let content = spawn_reader(&base.join("locked/file"));
634
635 std::fs::write(base.join("real/file"), "2").unwrap();
637 assert!(wait_until(|| *content.lock().unwrap() == "2"));
638 }
639}