Skip to main content

dynamic_config/
watch.rs

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