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 type, keyed by `TypeId`), the spawn that
4//! registers *before* returning so no edit slips through the gap, the
5//! rollback that frees the name when a spawn fails partway, and the
6//! directory-level watches — directories, not files, because editors and
7//! 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/// Types that already have a watcher, keyed by [`TypeId`] — the one identity
25/// that survives generics. The display name is kept only for messages: keyed
26/// by name, `Db<Postgres>` and `Db<Mysql>` both stringify to `"Db"`, and the
27/// second `start_watch()` silently watched nothing.
28pub(super) static STARTED: Mutex<BTreeMap<TypeId, &'static str>> = Mutex::new(BTreeMap::new());
29
30/// Keeps a watcher alive. Dropping it stops watching.
31///
32/// The handle owns the notification backend, and the background thread owns
33/// only the receiving end. Dropping the handle closes the channel, which is
34/// what ends the thread — no flag to poll, no wake-up latency.
35///
36/// A server usually wants the watcher to outlive everything, which is what
37/// [`detach`](Self::detach) is for. Anything with a lifecycle — a test, a
38/// library, a subcommand — should hold the handle instead, so watching stops
39/// when the thing being configured goes away.
40#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
41              to watch for the rest of the process"]
42pub struct WatchHandle {
43    key: TypeId,
44    name: &'static str,
45    /// `None` only while `detach` is dismantling the handle.
46    watcher: Option<Backend>,
47}
48
49/// The two backends, kept as one owner so the handle is a single type.
50enum Backend {
51    Native(notify::RecommendedWatcher),
52    Poll(notify::PollWatcher),
53}
54
55impl WatchHandle {
56    /// Watches for the remainder of the process.
57    ///
58    /// Leaks the backend on purpose: a watcher that must never stop has no
59    /// owner to hold it, and pretending otherwise is how the handle ends up
60    /// dropped at the end of `main`'s first statement.
61    pub fn detach(mut self) {
62        if let Some(watcher) = self.watcher.take() {
63            std::mem::forget(watcher);
64        }
65
66        // The registration stays, so a later `spawn` still reports
67        // `AlreadyExists` rather than starting a second watcher.
68        std::mem::forget(self);
69    }
70
71    /// Stops watching. The same as dropping it, spelled out.
72    pub fn stop(self) {}
73
74    /// The type name this watcher was started for.
75    #[must_use]
76    pub fn name(&self) -> &'static str {
77        self.name
78    }
79}
80
81impl Drop for WatchHandle {
82    fn drop(&mut self) {
83        // `None` only mid-`detach`, which forgets the handle before this
84        // could run — but belt and braces costs one branch.
85        let Some(watcher) = self.watcher.take() else {
86            return;
87        };
88
89        // Dropping the backend closes the channel and ends the thread. Freeing
90        // the registration lets a later `spawn` start a fresh one — which is
91        // what makes this usable from tests.
92        drop(watcher);
93
94        // Recovered from poisoning rather than skipped: skipping would leak
95        // the registration forever, and the map has no invariant a panic
96        // could break — the same policy every other lock in the crate follows.
97        STARTED
98            .lock()
99            .unwrap_or_else(std::sync::PoisonError::into_inner)
100            .remove(&self.key);
101    }
102}
103
104impl std::fmt::Debug for WatchHandle {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("WatchHandle")
107            .field("name", &self.name)
108            // The backend is a notify watcher, which has no rendering worth
109            // printing and would drown the one field that matters.
110            .finish_non_exhaustive()
111    }
112}
113
114/// Starts a background thread that runs `reload` whenever one of `files` changes.
115///
116/// Calling this twice for the same type is an error (`AlreadyExists`): a
117/// second handle could only mislead, and the first watcher keeps running.
118///
119/// `reload` is expected to swap in a new snapshot. Returning `Some(summary)`
120/// replaces the generic "reloaded" line with something more specific — which is
121/// how `diff` reports the keys that moved without logging twice.
122///
123/// Its error is reported and discarded — an invalid or half-written file must
124/// degrade to "no change", never to a crash, because the previous snapshot is
125/// still perfectly good.
126///
127/// The watch is registered *before* this function returns, so an edit that
128/// lands immediately afterwards cannot slip through the gap. Registering it on
129/// the background thread instead would leave a window — short, but reliably hit
130/// by anything that writes configuration during startup.
131///
132/// # Errors
133///
134/// If the notification backend cannot be created, if none of the directories
135/// holding `files` can be watched, or if the thread cannot be spawned. A
136/// directory that fails while others succeed is reported and skipped.
137///
138pub fn spawn(
139    key: TypeId,
140    name: &'static str,
141    watched: Watched,
142    debounce: Duration,
143    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
144) -> std::io::Result<WatchHandle> {
145    spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
146}
147
148/// [`spawn`], with the detection strategy chosen explicitly.
149///
150/// # Errors
151///
152/// As [`spawn`].
153///
154pub fn spawn_with(
155    key: TypeId,
156    name: &'static str,
157    watched: Watched,
158    debounce: Duration,
159    mode: WatchMode,
160    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
161) -> std::io::Result<WatchHandle> {
162    // An error, not a quiet no-op handle. The old behaviour returned
163    // `Ok(handle-that-owns-nothing)`, which read as "I started watching" and
164    // was undetectable at runtime — the worst kind of success.
165    if STARTED
166        .lock()
167        .unwrap_or_else(std::sync::PoisonError::into_inner)
168        .insert(key, name)
169        .is_some()
170    {
171        return Err(std::io::Error::new(
172            std::io::ErrorKind::AlreadyExists,
173            format!(
174                "`{name}` is already being watched; hold on to the handle the \
175                 first `start_watch()` returned, or drop it before starting \
176                 another"
177            ),
178        ));
179    }
180
181    // The insertion above is what makes two concurrent `spawn` calls mutually
182    // exclusive, so it has to come first — and therefore a failure below has
183    // to undo it. Without the rollback, every later `start_watch()` for this
184    // type would find the name taken and return a success handle that owns
185    // nothing and watches nothing, silently.
186    let registered = Registered { key, armed: true };
187
188    let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
189
190    let mut backend = match mode {
191        WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
192        WatchMode::Poll { interval } => Backend::Poll(
193            notify::PollWatcher::new(
194                sender,
195                notify::Config::default().with_poll_interval(interval),
196            )
197            .map_err(to_io)?,
198        ),
199    };
200
201    match &mut backend {
202        Backend::Native(watcher) => watch_directories(name, watcher, &watched)?,
203        Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
204    }
205
206    thread::Builder::new()
207        .name(format!("config-watch-{name}"))
208        .spawn(move || run(name, &watched, debounce, reload, &receiver))?;
209
210    // Everything that could fail has succeeded; from here the *handle* owns
211    // the registration and frees it on drop.
212    registered.defuse();
213
214    Ok(WatchHandle {
215        key,
216        name,
217        watcher: Some(backend),
218    })
219}
220
221/// Rolls the name registration back unless the spawn completed.
222///
223/// Every `?` between the insertion and the end of `spawn_with` — the backend,
224/// the directory watches, the thread — runs through this on the way out.
225struct Registered {
226    key: TypeId,
227    armed: bool,
228}
229
230impl Registered {
231    /// The spawn completed; the registration now belongs to the handle.
232    fn defuse(mut self) {
233        self.armed = false;
234    }
235}
236
237impl Drop for Registered {
238    fn drop(&mut self) {
239        if self.armed {
240            STARTED
241                .lock()
242                .unwrap_or_else(std::sync::PoisonError::into_inner)
243                .remove(&self.key);
244        }
245    }
246}
247
248fn to_io(error: notify::Error) -> std::io::Error {
249    std::io::Error::new(std::io::ErrorKind::Other, error)
250}
251
252/// Watches the *directories* holding the files, not the files themselves.
253///
254/// Editors and `mv`-based atomic saves replace the inode, which silently
255/// detaches a file-level watch. Watching the parent directory survives that —
256/// and is also what makes a Kubernetes ConfigMap update, delivered as a `..data`
257/// symlink swap, visible at all.
258///
259/// Fails when nothing could be watched, rather than parking a thread on a
260/// channel that will never produce an event.
261fn watch_directories(
262    name: &'static str,
263    watcher: &mut impl Watcher,
264    watched: &Watched,
265) -> std::io::Result<()> {
266    let mut directories = Vec::<PathBuf>::new();
267
268    {
269        let mut push = |directory: PathBuf| {
270            if !directories.contains(&directory) {
271                directories.push(directory);
272            }
273        };
274
275        for file in &watched.files {
276            push(
277                file.parent()
278                    .filter(|parent| !parent.as_os_str().is_empty())
279                    .unwrap_or_else(|| Path::new("."))
280                    .to_path_buf(),
281            );
282        }
283
284        // Every searched directory, whether or not it holds a file today: a
285        // config file appearing later is exactly the event worth catching.
286        for directory in &watched.search_directories {
287            push(directory.clone());
288        }
289    }
290
291    let mut watched = 0usize;
292    let mut last_error = None;
293
294    for directory in &directories {
295        match watcher.watch(directory, RecursiveMode::NonRecursive) {
296            Ok(()) => watched += 1,
297            Err(error) => {
298                warning!("{name}: could not watch {}: {error}", directory.display());
299                last_error = Some(error);
300            }
301        }
302    }
303
304    if watched == 0 {
305        return Err(last_error.map_or_else(
306            || {
307                std::io::Error::new(
308                    std::io::ErrorKind::NotFound,
309                    format!("{name}: no configuration file to watch"),
310                )
311            },
312            to_io,
313        ));
314    }
315
316    Ok(())
317}