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