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}