Skip to main content

dynamic_config/watch/
handle.rs

1//! Starting a watcher, and the handle that stops it.
2//!
3//! The registry (one watcher per [`WatchKey`] — a type, or one `Dynamic`
4//! instance), the spawn that registers *before* returning so no edit slips
5//! through the gap, the rollback that frees the key when a spawn fails
6//! partway, and the directory-level watches — directories, not files,
7//! because editors and atomic saves replace the inode.
8
9use std::any::TypeId;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::{mpsc, Mutex};
13use std::thread;
14use std::time::Duration;
15
16use notify::{Event, RecursiveMode, Watcher};
17
18use crate::error::Error;
19use crate::log::warning;
20
21use super::debounce::run;
22use super::{WatchMode, Watched};
23
24/// What a watcher is watched *as*: one per type, or one per instance.
25///
26/// A type's identity is its [`TypeId`] — the one identity that survives
27/// generics; the display name is kept only for messages, because keyed by
28/// name `Db<Postgres>` and `Db<Mysql>` both stringify to `"Db"` and the
29/// second `start_watch()` would silently watch nothing. A
30/// [`Dynamic`](crate::Dynamic) instance has no usable `TypeId` — every
31/// `Dynamic<Value>` is the same type — so it carries a process-unique
32/// number instead, allocated at construction.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum WatchKey {
35    /// One watcher per configuration *type* — the attribute's contract.
36    Type(TypeId),
37    /// One watcher per [`Dynamic`](crate::Dynamic) *instance*.
38    Instance(u64),
39}
40
41/// Configurations that already have a watcher, by [`WatchKey`].
42pub(super) static STARTED: Mutex<BTreeMap<WatchKey, &'static str>> = Mutex::new(BTreeMap::new());
43
44/// Keeps a watcher alive. Dropping it stops watching.
45///
46/// The handle owns the notification backend, and the background thread owns
47/// only the receiving end. Dropping the handle closes the channel, which is
48/// what ends the thread — no flag to poll, no wake-up latency.
49///
50/// A server usually wants the watcher to outlive everything, which is what
51/// [`detach`](Self::detach) is for. Anything with a lifecycle — a test, a
52/// library, a subcommand — should hold the handle instead, so watching stops
53/// when the thing being configured goes away.
54#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
55              to watch for the rest of the process"]
56pub struct WatchHandle {
57    key: WatchKey,
58    name: &'static str,
59    /// `None` only while `detach` is dismantling the handle.
60    watcher: Option<Backend>,
61}
62
63/// The two backends, kept as one owner so the handle is a single type.
64enum Backend {
65    Native(notify::RecommendedWatcher),
66    Poll(notify::PollWatcher),
67}
68
69impl WatchHandle {
70    /// Watches for the remainder of the process.
71    ///
72    /// Leaks the backend on purpose: a watcher that must never stop has no
73    /// owner to hold it, and pretending otherwise is how the handle ends up
74    /// dropped at the end of `main`'s first statement.
75    pub fn detach(mut self) {
76        if let Some(watcher) = self.watcher.take() {
77            std::mem::forget(watcher);
78        }
79
80        // The registration stays, so a later `spawn` still reports
81        // `AlreadyExists` rather than starting a second watcher.
82        std::mem::forget(self);
83    }
84
85    /// Stops watching. The same as dropping it, spelled out.
86    pub fn stop(self) {}
87
88    /// The type name this watcher was started for.
89    #[must_use]
90    pub fn name(&self) -> &'static str {
91        self.name
92    }
93}
94
95impl Drop for WatchHandle {
96    fn drop(&mut self) {
97        // `None` only mid-`detach`, which forgets the handle before this
98        // could run — but belt and braces costs one branch.
99        let Some(watcher) = self.watcher.take() else {
100            return;
101        };
102
103        // Dropping the backend closes the channel and ends the thread. Freeing
104        // the registration lets a later `spawn` start a fresh one — which is
105        // what makes this usable from tests.
106        drop(watcher);
107
108        // Recovered from poisoning rather than skipped: skipping would leak
109        // the registration forever, and the map has no invariant a panic
110        // could break — the same policy every other lock in the crate follows.
111        STARTED
112            .lock()
113            .unwrap_or_else(std::sync::PoisonError::into_inner)
114            .remove(&self.key);
115    }
116}
117
118impl std::fmt::Debug for WatchHandle {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("WatchHandle")
121            .field("name", &self.name)
122            // The backend is a notify watcher, which has no rendering worth
123            // printing and would drown the one field that matters.
124            .finish_non_exhaustive()
125    }
126}
127
128/// Starts a background thread that runs `reload` whenever one of `files` changes.
129///
130/// Calling this twice for the same type is an error (`AlreadyExists`): a
131/// second handle could only mislead, and the first watcher keeps running.
132///
133/// `reload` is expected to swap in a new snapshot. It is handed the path
134/// whose event opened the debounce window — one path, not the set, because
135/// the window can cover several and an unbounded collection of remount
136/// directory names is what collecting them all would mean. Returning
137/// `Some(summary)` replaces the generic "reloaded" line with something more
138/// specific — which is how `diff` reports the keys that moved without
139/// 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    key: WatchKey,
158    name: &'static str,
159    watched: Watched,
160    debounce: Duration,
161    reload: impl Fn(&Path) -> Result<Option<String>, Error> + Send + 'static,
162) -> std::io::Result<WatchHandle> {
163    spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
164}
165
166/// [`spawn`], with the detection strategy chosen explicitly.
167///
168/// # Errors
169///
170/// As [`spawn`].
171///
172pub fn spawn_with(
173    key: WatchKey,
174    name: &'static str,
175    watched: Watched,
176    debounce: Duration,
177    mode: WatchMode,
178    reload: impl Fn(&Path) -> Result<Option<String>, Error> + Send + 'static,
179) -> std::io::Result<WatchHandle> {
180    // An error, not a quiet no-op handle. The old behaviour returned
181    // `Ok(handle-that-owns-nothing)`, which read as "I started watching" and
182    // was undetectable at runtime — the worst kind of success.
183    if STARTED
184        .lock()
185        .unwrap_or_else(std::sync::PoisonError::into_inner)
186        .insert(key, name)
187        .is_some()
188    {
189        return Err(std::io::Error::new(
190            std::io::ErrorKind::AlreadyExists,
191            format!(
192                "`{name}` is already being watched; hold on to the handle the \
193                 first `start_watch()` returned, or drop it before starting \
194                 another"
195            ),
196        ));
197    }
198
199    // The insertion above is what makes two concurrent `spawn` calls mutually
200    // exclusive, so it has to come first — and therefore a failure below has
201    // to undo it. Without the rollback, every later `start_watch()` for this
202    // type would find the name taken and return a success handle that owns
203    // nothing and watches nothing, silently.
204    let registered = Registered { key, armed: true };
205
206    let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
207
208    let mut backend = match mode {
209        WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
210        WatchMode::Poll { interval } => Backend::Poll(
211            notify::PollWatcher::new(
212                sender,
213                notify::Config::default()
214                    .with_poll_interval(interval)
215                    // Contents, not just timestamps — and this is a
216                    // correctness fix rather than a thoroughness one.
217                    //
218                    // `notify`'s poll backend stores each file's mtime in
219                    // whole **seconds** and reports a change when the new
220                    // one is greater. Two writes inside one second are
221                    // therefore indistinguishable from one, and an edit
222                    // that lands in the same second as the scan before it
223                    // is invisible — permanently, because the next scan
224                    // compares against the value it just recorded. A
225                    // deployment that writes a file a few milliseconds
226                    // after the watcher starts is exactly that case.
227                    //
228                    // Hashing each watched file per interval is what
229                    // closes it. The cost is a read where there was a
230                    // `stat`, which is the trade polling already is: it
231                    // was chosen because notifications never arrive here,
232                    // and a watcher that misses edits is the failure it
233                    // was chosen to escape.
234                    .with_compare_contents(true),
235            )
236            .map_err(to_io)?,
237        ),
238    };
239
240    match &mut backend {
241        Backend::Native(watcher) => watch_directories(name, watcher, &watched)?,
242        Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
243    }
244
245    thread::Builder::new()
246        .name(format!("config-watch-{name}"))
247        .spawn(move || run(name, &watched, debounce, reload, &receiver))?;
248
249    // Everything that could fail has succeeded; from here the *handle* owns
250    // the registration and frees it on drop.
251    registered.defuse();
252
253    Ok(WatchHandle {
254        key,
255        name,
256        watcher: Some(backend),
257    })
258}
259
260/// Rolls the name registration back unless the spawn completed.
261///
262/// Every `?` between the insertion and the end of `spawn_with` — the backend,
263/// the directory watches, the thread — runs through this on the way out.
264struct Registered {
265    key: WatchKey,
266    armed: bool,
267}
268
269impl Registered {
270    /// The spawn completed; the registration now belongs to the handle.
271    fn defuse(mut self) {
272        self.armed = false;
273    }
274}
275
276impl Drop for Registered {
277    fn drop(&mut self) {
278        if self.armed {
279            STARTED
280                .lock()
281                .unwrap_or_else(std::sync::PoisonError::into_inner)
282                .remove(&self.key);
283        }
284    }
285}
286
287fn to_io(error: notify::Error) -> std::io::Error {
288    std::io::Error::new(std::io::ErrorKind::Other, error)
289}
290
291/// Watches the *directories* holding the files, not the files themselves.
292///
293/// Editors and `mv`-based atomic saves replace the inode, which silently
294/// detaches a file-level watch. Watching the parent directory survives that —
295/// and is also what makes a Kubernetes ConfigMap update, delivered as a `..data`
296/// symlink swap, visible at all.
297///
298/// Fails when nothing could be watched, rather than parking a thread on a
299/// channel that will never produce an event.
300fn watch_directories(
301    name: &'static str,
302    watcher: &mut impl Watcher,
303    watched: &Watched,
304) -> std::io::Result<()> {
305    let mut directories = Vec::<PathBuf>::new();
306
307    {
308        let mut push = |directory: PathBuf| {
309            if !directories.contains(&directory) {
310                directories.push(directory);
311            }
312        };
313
314        for file in &watched.files {
315            push(
316                file.parent()
317                    .filter(|parent| !parent.as_os_str().is_empty())
318                    .unwrap_or_else(|| Path::new("."))
319                    .to_path_buf(),
320            );
321        }
322
323        // Every searched directory, whether or not it holds a file today: a
324        // config file appearing later is exactly the event worth catching.
325        for directory in &watched.search_directories {
326            push(directory.clone());
327        }
328    }
329
330    let mut watched = 0usize;
331    let mut last_error = None;
332
333    for directory in &directories {
334        match watcher.watch(directory, RecursiveMode::NonRecursive) {
335            Ok(()) => watched += 1,
336            Err(error) => {
337                warning!("{name}: could not watch {}: {error}", directory.display());
338                last_error = Some(error);
339            }
340        }
341    }
342
343    if watched == 0 {
344        return Err(last_error.map_or_else(
345            || {
346                std::io::Error::new(
347                    std::io::ErrorKind::NotFound,
348                    format!("{name}: no configuration file to watch"),
349                )
350            },
351            to_io,
352        ));
353    }
354
355    Ok(())
356}