Skip to main content

common_config/
file_watcher.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Common file watching utilities for configuration hot-reloading.
16//!
17//! This module provides a generic file watcher that can be used to watch
18//! files for changes and trigger callbacks when changes occur.
19//!
20//! The watcher monitors the parent directory of each file rather than the
21//! file itself. This ensures that file deletions and recreations are properly
22//! tracked, which is common with editors that use atomic saves or when
23//! configuration files are replaced.
24//!
25//! If the path goes through symlinks (the file itself or any ancestor
26//! directory), the directory holding each symlink is watched as well, so that
27//! swapping a symlink anywhere in the chain (e.g. `ln -sfn secrets.2 secrets`,
28//! as done by secret managers like sops-nix or Kubernetes volume mounts) is
29//! detected. The chain is re-resolved after every relevant event and the set
30//! of watched directories is updated accordingly.
31
32use 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/// Configuration for the file watcher behavior.
43#[derive(Debug, Clone, Default)]
44pub struct FileWatcherConfig {
45    /// Whether to include Remove events in addition to Modify and Create.
46    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
60/// A builder for creating file watchers with flexible configuration.
61///
62/// The watcher monitors the parent directory of each file to handle file
63/// deletion and recreation properly. Events are filtered to only trigger
64/// callbacks for the specific files being watched (or the symlinks leading
65/// to them).
66pub struct FileWatcherBuilder {
67    config: FileWatcherConfig,
68    /// Paths of files to watch, as configured. Symlinks in them are resolved
69    /// when spawning and again after every relevant event.
70    file_paths: Vec<PathBuf>,
71}
72
73impl FileWatcherBuilder {
74    /// Create a new builder with default configuration.
75    pub fn new() -> Self {
76        Self {
77            config: FileWatcherConfig::default(),
78            file_paths: Vec::new(),
79        }
80    }
81
82    /// Set the watcher configuration.
83    pub fn config(mut self, config: FileWatcherConfig) -> Self {
84        self.config = config;
85        self
86    }
87
88    /// Add a file path to watch.
89    ///
90    /// Returns an error if the path is a directory.
91    /// Symlinks in the path are followed, see [`FileWatcherBuilder::spawn`].
92    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    /// Add multiple file paths to watch.
106    ///
107    /// Returns an error if any path is a directory.
108    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    /// Build and spawn the file watcher with the given callback.
119    ///
120    /// The callback is invoked when relevant file events are detected for
121    /// the watched files. The watcher monitors the parent directories to
122    /// handle file deletion and recreation properly. When a path goes through
123    /// symlinks, the directory holding each symlink is watched too, and the
124    /// watches are re-armed after every relevant event, so the callback also
125    /// fires when a symlink anywhere in the chain is replaced.
126    ///
127    /// The spawned watcher thread runs for the lifetime of the process.
128    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                // Only the directories holding the files are required. A
142                // directory holding a symlink may be unreadable (e.g. mode
143                // 0711); it is skipped and retried by `rearm_watches`.
144                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            // Keep watcher alive in the thread
173            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                        // A symlink in the chain may have been replaced, so
189                        // re-resolve and re-arm the watches before reloading.
190                        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
212/// Maximum number of symlinks followed while resolving a single path. This
213/// mirrors Linux's `MAXSYMLINKS` and guards against symlink loops.
214const MAX_SYMLINK_HOPS: usize = 40;
215
216/// Directories to watch and the directory entries whose events are relevant.
217#[derive(Debug, Default)]
218struct WatchTargets {
219    /// Directories holding a symlink of a chain, or the final file.
220    dirs: HashSet<PathBuf>,
221    /// The subset of `dirs` holding the final files.
222    file_dirs: HashSet<PathBuf>,
223    /// The symlinks of every chain and the final files, as they appear
224    /// inside `dirs`, normalized with [`entry_key`].
225    entries: HashSet<PathBuf>,
226}
227
228/// Key used to compare an event path with [`WatchTargets::entries`].
229///
230/// The entries are built from the configured spelling, which may differ in
231/// case from what is on disk (and reported in events) on the usually
232/// case-insensitive filesystems of macOS and Windows, so compare lowercased
233/// there. At worst this lets a few extra events through on a case-sensitive
234/// volume.
235fn 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
246/// Resolves every path component by component, following symlinks, and
247/// collects what has to be watched to notice any change of what the path
248/// points to.
249///
250/// For each symlink met on the way (the file itself or any ancestor
251/// directory), the directory holding it is watched, so replacing the symlink
252/// is noticed. The directory holding the final file is watched too, so
253/// in-place edits are noticed. The collected directories are free of
254/// symlinks; the collected entries are the symlinks themselves and the final
255/// files, located inside those directories.
256///
257/// If a component is missing (e.g. a symlink being swapped non-atomically),
258/// resolution of that path stops and its containing directory is watched, so
259/// the watch is re-armed once the component shows up again.
260fn 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    // Always free of symlinks, since each component is resolved before
278    // being appended.
279    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                        // Relative targets resolve against `resolved`; absolute
305                        // ones reset it through their root component.
306                        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
325/// Re-resolves `file_paths` and updates the watched directories, returning
326/// the new targets. Failures are logged and retried on the next event that
327/// matches a chain entry.
328fn 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        // The directory may already be gone; nothing to do then.
337        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
358/// Warns when the root directory is watched, which happens when a path goes
359/// through a symlink directly under it (e.g. `/tmp`, `/var` or `/etc` on
360/// macOS). On macOS the FSEvents backend then receives every event on the
361/// volume and filters them in user space.
362fn 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
372/// Check if an event kind is relevant based on the configuration.
373fn 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    /// Polls `cond` until it holds or `TIMEOUT` elapses.
394    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    /// Spawns a watcher on `path` and returns a counter of callback calls.
406    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    /// Spawns a watcher on `path` whose callback stores the content read
421    /// through `path`, like the real reload callbacks do.
422    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        // Watches are registered before `spawn` returns, so no need to wait.
447        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        // Recreate the file - this should still be detected because we watch the directory
468        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        // Write the watched file afterwards: once its event is seen, the
488        // earlier unrelated event has been processed too.
489        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        // Only unrelated activity from now on.
495        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        // real/users <- secrets (relative dir symlink) <- conf/users (relative
515        // file symlink going through `..`).
516        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        // A plain file only needs its parent directory.
538        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        // A dangling link keeps the directory where the target should appear.
543        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 loops terminate.
552        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        // Swap `current` atomically, like `ln -sfn gen2 current`.
577        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        // Let any remaining events of the swap drain, so they can't read the
584        // edit below by accident.
585        std::thread::sleep(Duration::from_millis(200));
586
587        // The watch follows the chain to the new target directory.
588        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    /// A directory holding a symlink of the chain may be traversable but not
596    /// readable. It can't be watched, but that must not prevent watching the
597    /// file itself.
598    #[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        /// Restores a readable mode on drop, even if the test panics: the
613        /// temp dir can't be removed while `locked` is unreadable, since
614        /// removing it requires listing its entries.
615        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        // Declared after `dir`, so it drops (restoring the mode) first.
623        let _restore = RestoreMode(base.join("locked"));
624        // Traversable (so `locked/file` still resolves) but not readable.
625        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            // Running as root (or with CAP_DAC_OVERRIDE): nothing to test.
629            return;
630        }
631
632        // Would panic if watching `locked` were fatal.
633        let content = spawn_reader(&base.join("locked/file"));
634
635        // The directory of the final file is still watched.
636        std::fs::write(base.join("real/file"), "2").unwrap();
637        assert!(wait_until(|| *content.lock().unwrap() == "2"));
638    }
639}