Skip to main content

dynamic_config/
watch.rs

1//! The filesystem watcher behind hot reload.
2
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use std::sync::mpsc;
6use std::sync::Mutex;
7use std::thread;
8use std::time::Duration;
9
10use notify::{Event, EventKind, RecursiveMode, Watcher};
11
12use crate::discovery;
13use crate::error::Error;
14use crate::log::{info, warning};
15use crate::source::LoadSpec;
16
17/// Pause after the debounce window, before the files are read back.
18///
19/// An atomic save writes a temporary file and renames it into place. The rename
20/// can be observed a hair before the new inode is visible, so a short grace
21/// period avoids reading a file that is about to be replaced.
22const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
23
24/// How to detect changes.
25///
26/// The native backend is right almost everywhere and wrong in one important
27/// place: inotify and its equivalents do not fire on many network and overlay
28/// filesystems — NFS, some Docker bind mounts, some CI runners. The failure is
29/// silent, because the watch registers successfully and simply never delivers
30/// anything, so there is nothing to detect and fall back from. It has to be
31/// chosen deliberately.
32#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum WatchMode {
35    /// The platform's notification backend. Efficient, and the default.
36    #[default]
37    Native,
38    /// Re-stat the files on an interval. Works anywhere, at the cost of the
39    /// interval's worth of latency and a periodic wake-up.
40    Poll {
41        /// How often to look.
42        interval: Duration,
43    },
44}
45
46/// Names that already have a watcher, so a second `spawn` is a no-op.
47static STARTED: Mutex<BTreeSet<&'static str>> = Mutex::new(BTreeSet::new());
48
49/// Keeps a watcher alive. Dropping it stops watching.
50///
51/// The handle owns the notification backend, and the background thread owns
52/// only the receiving end. Dropping the handle closes the channel, which is
53/// what ends the thread — no flag to poll, no wake-up latency.
54///
55/// A server usually wants the watcher to outlive everything, which is what
56/// [`detach`](Self::detach) is for. Anything with a lifecycle — a test, a
57/// library, a subcommand — should hold the handle instead, so watching stops
58/// when the thing being configured goes away.
59#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
60              to watch for the rest of the process"]
61pub struct WatchHandle {
62    name: &'static str,
63    /// `None` only while `detach` is dismantling the handle.
64    watcher: Option<Backend>,
65}
66
67/// The two backends, kept as one owner so the handle is a single type.
68enum Backend {
69    Native(notify::RecommendedWatcher),
70    Poll(notify::PollWatcher),
71}
72
73impl WatchHandle {
74    /// Watches for the remainder of the process.
75    ///
76    /// Leaks the backend on purpose: a watcher that must never stop has no
77    /// owner to hold it, and pretending otherwise is how the handle ends up
78    /// dropped at the end of `main`'s first statement.
79    pub fn detach(mut self) {
80        if let Some(watcher) = self.watcher.take() {
81            std::mem::forget(watcher);
82        }
83
84        // The name stays registered, so a later `spawn` is still a no-op.
85        std::mem::forget(self);
86    }
87
88    /// Stops watching. The same as dropping it, spelled out.
89    pub fn stop(self) {}
90
91    /// The type name this watcher was started for.
92    #[must_use]
93    pub fn name(&self) -> &'static str {
94        self.name
95    }
96}
97
98impl Drop for WatchHandle {
99    fn drop(&mut self) {
100        // A handle from a duplicate `spawn` owns nothing; freeing the name here
101        // would let a third call start a *second* watcher alongside the one
102        // still running.
103        let Some(watcher) = self.watcher.take() else {
104            return;
105        };
106
107        // Dropping the backend closes the channel and ends the thread. Freeing
108        // the name lets a later `spawn` start a fresh one — which is what makes
109        // this usable from tests.
110        drop(watcher);
111
112        // Recovered from poisoning rather than skipped: skipping would leak
113        // the name forever, and the set has no invariant a panic could break —
114        // the same policy every other lock in the crate follows.
115        STARTED
116            .lock()
117            .unwrap_or_else(std::sync::PoisonError::into_inner)
118            .remove(self.name);
119    }
120}
121
122impl std::fmt::Debug for WatchHandle {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("WatchHandle")
125            .field("name", &self.name)
126            // The backend is a notify watcher, which has no rendering worth
127            // printing and would drown the one field that matters.
128            .finish_non_exhaustive()
129    }
130}
131
132/// Starts a background thread that runs `reload` whenever one of `files` changes.
133///
134/// Calling this twice with the same `name` is a no-op: the second call returns
135/// a handle that owns nothing, so dropping it does not stop the first watcher.
136///
137/// `reload` is expected to swap in a new snapshot. Returning `Some(summary)`
138/// replaces the generic "reloaded" line with something more specific — which is
139/// how `diff` reports the keys that moved without logging twice.
140///
141/// Its error is reported and discarded — an invalid or half-written file must
142/// degrade to "no change", never to a crash, because the previous snapshot is
143/// still perfectly good.
144///
145/// The watch is registered *before* this function returns, so an edit that
146/// lands immediately afterwards cannot slip through the gap. Registering it on
147/// the background thread instead would leave a window — short, but reliably hit
148/// by anything that writes configuration during startup.
149///
150/// # Errors
151///
152/// If the notification backend cannot be created, if none of the directories
153/// holding `files` can be watched, or if the thread cannot be spawned. A
154/// directory that fails while others succeed is reported and skipped.
155///
156pub fn spawn(
157    name: &'static str,
158    spec: LoadSpec<'static>,
159    debounce: Duration,
160    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
161) -> std::io::Result<WatchHandle> {
162    spawn_with(name, spec, debounce, WatchMode::default(), reload)
163}
164
165/// [`spawn`], with the detection strategy chosen explicitly.
166///
167/// # Errors
168///
169/// As [`spawn`].
170///
171pub fn spawn_with(
172    name: &'static str,
173    spec: LoadSpec<'static>,
174    debounce: Duration,
175    mode: WatchMode,
176    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
177) -> std::io::Result<WatchHandle> {
178    if !STARTED
179        .lock()
180        .unwrap_or_else(std::sync::PoisonError::into_inner)
181        .insert(name)
182    {
183        return Ok(WatchHandle {
184            name,
185            watcher: None,
186        });
187    }
188
189    // The insertion above is what makes two concurrent `spawn` calls mutually
190    // exclusive, so it has to come first — and therefore a failure below has
191    // to undo it. Without the rollback, every later `start_watch()` for this
192    // type would find the name taken and return a success handle that owns
193    // nothing and watches nothing, silently.
194    let registered = Registered { name, armed: true };
195
196    let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
197
198    let mut backend = match mode {
199        WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
200        WatchMode::Poll { interval } => Backend::Poll(
201            notify::PollWatcher::new(
202                sender,
203                notify::Config::default().with_poll_interval(interval),
204            )
205            .map_err(to_io)?,
206        ),
207    };
208
209    match &mut backend {
210        Backend::Native(watcher) => watch_directories(name, watcher, &spec)?,
211        Backend::Poll(watcher) => watch_directories(name, watcher, &spec)?,
212    }
213
214    thread::Builder::new()
215        .name(format!("config-watch-{name}"))
216        .spawn(move || run(name, spec, debounce, reload, &receiver))?;
217
218    // Everything that could fail has succeeded; from here the *handle* owns
219    // the registration and frees it on drop.
220    registered.defuse();
221
222    Ok(WatchHandle {
223        name,
224        watcher: Some(backend),
225    })
226}
227
228/// Rolls the name registration back unless the spawn completed.
229///
230/// Every `?` between the insertion and the end of `spawn_with` — the backend,
231/// the directory watches, the thread — runs through this on the way out.
232struct Registered {
233    name: &'static str,
234    armed: bool,
235}
236
237impl Registered {
238    /// The spawn completed; the registration now belongs to the handle.
239    fn defuse(mut self) {
240        self.armed = false;
241    }
242}
243
244impl Drop for Registered {
245    fn drop(&mut self) {
246        if self.armed {
247            STARTED
248                .lock()
249                .unwrap_or_else(std::sync::PoisonError::into_inner)
250                .remove(self.name);
251        }
252    }
253}
254
255fn to_io(error: notify::Error) -> std::io::Error {
256    std::io::Error::new(std::io::ErrorKind::Other, error)
257}
258
259fn run(
260    name: &'static str,
261    spec: LoadSpec<'static>,
262    debounce: Duration,
263    reload: impl Fn() -> Result<Option<String>, Error>,
264    receiver: &mpsc::Receiver<notify::Result<Event>>,
265) {
266    loop {
267        let Some(batch) = collect_batch(receiver, name, debounce) else {
268            // The watcher was dropped, so no further events can arrive.
269            return;
270        };
271
272        if !touches_configured_file(&batch, &spec) {
273            continue;
274        }
275
276        thread::sleep(ATOMIC_SAVE_GRACE);
277
278        match reload() {
279            Ok(Some(summary)) => info!("{name}: reloaded, {summary}"),
280            Ok(None) => info!("{name}: reloaded"),
281            Err(error) => warning!("{name}: reload failed, keeping the previous snapshot: {error}"),
282        }
283    }
284}
285
286/// Watches the *directories* holding the files, not the files themselves.
287///
288/// Editors and `mv`-based atomic saves replace the inode, which silently
289/// detaches a file-level watch. Watching the parent directory survives that —
290/// and is also what makes a Kubernetes ConfigMap update, delivered as a `..data`
291/// symlink swap, visible at all.
292///
293/// Fails when nothing could be watched, rather than parking a thread on a
294/// channel that will never produce an event.
295fn watch_directories(
296    name: &'static str,
297    watcher: &mut impl Watcher,
298    spec: &LoadSpec<'static>,
299) -> std::io::Result<()> {
300    let mut directories = Vec::<PathBuf>::new();
301
302    {
303        let mut push = |directory: PathBuf| {
304            if !directories.contains(&directory) {
305                directories.push(directory);
306            }
307        };
308
309        for file in spec.sources.iter().filter_map(|source| source.path()) {
310            push(
311                Path::new(file)
312                    .parent()
313                    .filter(|parent| !parent.as_os_str().is_empty())
314                    .unwrap_or_else(|| Path::new("."))
315                    .to_path_buf(),
316            );
317        }
318
319        // Every searched directory, whether or not it holds a file today: a
320        // config file appearing later is exactly the event worth catching.
321        if let Some(search) = &spec.search {
322            for directory in discovery::search_directories(search) {
323                push(directory);
324            }
325        }
326    }
327
328    let mut watched = 0usize;
329    let mut last_error = None;
330
331    for directory in &directories {
332        match watcher.watch(directory, RecursiveMode::NonRecursive) {
333            Ok(()) => watched += 1,
334            Err(error) => {
335                warning!("{name}: could not watch {}: {error}", directory.display());
336                last_error = Some(error);
337            }
338        }
339    }
340
341    if watched == 0 {
342        return Err(last_error.map_or_else(
343            || {
344                std::io::Error::new(
345                    std::io::ErrorKind::NotFound,
346                    format!("{name}: no configuration file to watch"),
347                )
348            },
349            to_io,
350        ));
351    }
352
353    Ok(())
354}
355
356/// Blocks for the first event, then drains until the stream goes quiet.
357///
358/// One editor save typically emits several events; reloading once per event
359/// would re-read a file mid-write.
360///
361/// Returns `None` once the channel is disconnected.
362fn collect_batch(
363    receiver: &mpsc::Receiver<notify::Result<Event>>,
364    name: &'static str,
365    debounce: Duration,
366) -> Option<Vec<Event>> {
367    let mut batch = Vec::new();
368
369    loop {
370        match receiver.recv() {
371            Ok(Ok(event)) => {
372                batch.push(event);
373                break;
374            }
375            Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
376            Err(mpsc::RecvError) => return None,
377        }
378    }
379
380    loop {
381        match receiver.recv_timeout(debounce) {
382            Ok(Ok(event)) => batch.push(event),
383            Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
384            Err(mpsc::RecvTimeoutError::Timeout) => return Some(batch),
385            Err(mpsc::RecvTimeoutError::Disconnected) => return None,
386        }
387    }
388}
389
390/// Whether a batch is about one of our files.
391///
392/// The whole directory is watched, so most batches are about something else.
393/// Paths are compared in both directions because event paths are absolute while
394/// configured paths are usually relative to the working directory; a rare false
395/// positive costs one redundant reload, which is harmless.
396fn touches_configured_file(batch: &[Event], spec: &LoadSpec<'static>) -> bool {
397    batch.iter().any(|event| {
398        matches!(
399            event.kind,
400            EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
401        ) && event.paths.iter().any(|changed| is_ours(changed, spec))
402    })
403}
404
405fn is_ours(changed: &Path, spec: &LoadSpec<'static>) -> bool {
406    let explicit = spec
407        .sources
408        .iter()
409        .filter_map(|source| source.path())
410        .any(|file| {
411            let configured = Path::new(file);
412
413            changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
414        });
415
416    if explicit {
417        return true;
418    }
419
420    // Only the searched directories are watched, so matching on the file name
421    // alone is enough — and it catches a `config.toml` that did not exist when
422    // the watcher started.
423    if spec
424        .search
425        .as_ref()
426        .is_some_and(|search| discovery::is_candidate(changed, search.name))
427    {
428        return true;
429    }
430
431    is_mount_marker(changed, spec)
432}
433
434/// Whether `changed` is the bookkeeping entry of an atomically remounted
435/// directory holding one of our files.
436///
437/// Kubernetes updates a ConfigMap by writing a new timestamped directory and
438/// swinging a `..data` symlink at it. The configuration file's own path never
439/// receives an event — only `..data` and `..2026_08_09_12_00_00` do — so
440/// matching on the file alone sees a ConfigMap update as silence. Entries
441/// beginning with `..` are the kubelet's convention and effectively nothing
442/// else's, which keeps this from firing on ordinary files.
443fn is_mount_marker(changed: &Path, spec: &LoadSpec<'static>) -> bool {
444    let is_marker = changed
445        .file_name()
446        .and_then(|name| name.to_str())
447        .is_some_and(|name| name.starts_with(".."));
448
449    if !is_marker {
450        return false;
451    }
452
453    let Some(directory) = changed.parent() else {
454        return false;
455    };
456
457    let mut watched = spec
458        .sources
459        .iter()
460        .filter_map(|source| source.path())
461        .filter_map(|file| Path::new(file).parent());
462
463    if watched.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
464        return true;
465    }
466
467    spec.search.as_ref().is_some_and(|search| {
468        discovery::search_directories(search)
469            .iter()
470            .any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
471    })
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use notify::event::{CreateKind, ModifyKind};
478
479    /// A spec that names one file explicitly and searches nowhere.
480    fn explicit_spec() -> LoadSpec<'static> {
481        static SOURCES: &[crate::Source<'static>] =
482            &[crate::Source::file("config.toml", crate::Format::Toml)];
483
484        LoadSpec::new("app", SOURCES)
485    }
486
487    fn event(kind: EventKind, path: &str) -> Event {
488        Event {
489            kind,
490            paths: vec![PathBuf::from(path)],
491            attrs: Default::default(),
492        }
493    }
494
495    #[test]
496    fn an_absolute_event_path_matches_a_relative_configured_path() {
497        let batch = [event(
498            EventKind::Modify(ModifyKind::Any),
499            "/srv/app/config.toml",
500        )];
501
502        assert!(touches_configured_file(&batch, &explicit_spec()));
503    }
504
505    #[test]
506    fn a_discovered_name_matches_even_though_no_file_was_listed() {
507        let paths: &'static [&'static str] = &["/srv/app"];
508        let spec = LoadSpec::new("db", &[]).with_search("config", paths);
509
510        let batch = [event(
511            EventKind::Create(CreateKind::File),
512            "/srv/app/config.toml",
513        )];
514        assert!(touches_configured_file(&batch, &spec));
515
516        let batch = [event(
517            EventKind::Create(CreateKind::File),
518            "/srv/app/other.toml",
519        )];
520        assert!(!touches_configured_file(&batch, &spec));
521    }
522
523    #[test]
524    fn an_unrelated_file_in_the_same_directory_is_ignored() {
525        let batch = [event(
526            EventKind::Modify(ModifyKind::Any),
527            "/srv/app/notes.txt",
528        )];
529
530        assert!(!touches_configured_file(&batch, &explicit_spec()));
531    }
532
533    #[test]
534    fn access_events_do_not_trigger_a_reload() {
535        let batch = [event(
536            EventKind::Access(notify::event::AccessKind::Read),
537            "/srv/app/config.toml",
538        )];
539
540        assert!(!touches_configured_file(&batch, &explicit_spec()));
541    }
542
543    #[test]
544    fn a_duplicate_handle_owns_nothing_and_frees_nothing() {
545        let spec = explicit_spec();
546
547        let first = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
548            Ok(None)
549        })
550        .expect("the first spawn should start a watcher");
551        let second = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
552            Ok(None)
553        })
554        .expect("the second spawn should be a no-op");
555
556        // Dropping the duplicate must not deregister the name out from under
557        // the watcher that is actually running.
558        drop(second);
559        assert!(
560            STARTED.lock().unwrap().contains("DuplicateTest"),
561            "the running watcher should still hold its name"
562        );
563
564        drop(first);
565        assert!(
566            !STARTED.lock().unwrap().contains("DuplicateTest"),
567            "dropping the owning handle should free the name for a restart"
568        );
569    }
570
571    /// A failed spawn must free its name, or every retry afterwards returns a
572    /// success handle that owns nothing and watches nothing — silently, which
573    /// is the exact path a program hits when its config directory does not
574    /// exist yet at startup.
575    #[test]
576    fn a_failed_spawn_frees_its_name_for_a_retry() {
577        static BAD: &[crate::Source<'static>] = &[crate::Source::file(
578            "/nonexistent-dynamic-config-test-dir/config.toml",
579            crate::Format::Toml,
580        )];
581
582        let bad = LoadSpec::new("app", BAD);
583
584        assert!(
585            spawn("FailedSpawnTest", bad, Duration::from_millis(10), || Ok(
586                None
587            ))
588            .is_err(),
589            "watching a directory that does not exist should fail"
590        );
591        assert!(
592            !STARTED.lock().unwrap().contains("FailedSpawnTest"),
593            "a failed spawn must not keep its name registered"
594        );
595
596        // And the retry gets a *real* watcher, proven by its drop freeing the
597        // name — a do-nothing duplicate handle would leave it registered.
598        let handle = spawn(
599            "FailedSpawnTest",
600            explicit_spec(),
601            Duration::from_millis(10),
602            || Ok(None),
603        )
604        .expect("the name is free, so the retry starts a watcher");
605
606        drop(handle);
607
608        assert!(
609            !STARTED.lock().unwrap().contains("FailedSpawnTest"),
610            "the retry owned a real watcher, whose drop frees the name"
611        );
612    }
613
614    #[test]
615    fn creation_and_removal_both_count_as_changes() {
616        for kind in [
617            EventKind::Create(CreateKind::File),
618            EventKind::Remove(notify::event::RemoveKind::File),
619        ] {
620            let batch = [event(kind, "config.toml")];
621
622            assert!(
623                touches_configured_file(&batch, &explicit_spec()),
624                "{kind:?}"
625            );
626        }
627    }
628}