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. Returning `Some(summary)`
134/// replaces the generic "reloaded" line with something more specific — which is
135/// how `diff` reports the keys that moved without logging twice.
136///
137/// Its error is reported and discarded — an invalid or half-written file must
138/// degrade to "no change", never to a crash, because the previous snapshot is
139/// still perfectly good.
140///
141/// The watch is registered *before* this function returns, so an edit that
142/// lands immediately afterwards cannot slip through the gap. Registering it on
143/// the background thread instead would leave a window — short, but reliably hit
144/// by anything that writes configuration during startup.
145///
146/// # Errors
147///
148/// If the notification backend cannot be created, if none of the directories
149/// holding `files` can be watched, or if the thread cannot be spawned. A
150/// directory that fails while others succeed is reported and skipped.
151///
152pub fn spawn(
153 key: WatchKey,
154 name: &'static str,
155 watched: Watched,
156 debounce: Duration,
157 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
158) -> std::io::Result<WatchHandle> {
159 spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
160}
161
162/// [`spawn`], with the detection strategy chosen explicitly.
163///
164/// # Errors
165///
166/// As [`spawn`].
167///
168pub fn spawn_with(
169 key: WatchKey,
170 name: &'static str,
171 watched: Watched,
172 debounce: Duration,
173 mode: WatchMode,
174 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
175) -> std::io::Result<WatchHandle> {
176 // An error, not a quiet no-op handle. The old behaviour returned
177 // `Ok(handle-that-owns-nothing)`, which read as "I started watching" and
178 // was undetectable at runtime — the worst kind of success.
179 if STARTED
180 .lock()
181 .unwrap_or_else(std::sync::PoisonError::into_inner)
182 .insert(key, name)
183 .is_some()
184 {
185 return Err(std::io::Error::new(
186 std::io::ErrorKind::AlreadyExists,
187 format!(
188 "`{name}` is already being watched; hold on to the handle the \
189 first `start_watch()` returned, or drop it before starting \
190 another"
191 ),
192 ));
193 }
194
195 // The insertion above is what makes two concurrent `spawn` calls mutually
196 // exclusive, so it has to come first — and therefore a failure below has
197 // to undo it. Without the rollback, every later `start_watch()` for this
198 // type would find the name taken and return a success handle that owns
199 // nothing and watches nothing, silently.
200 let registered = Registered { key, armed: true };
201
202 let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
203
204 let mut backend = match mode {
205 WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
206 WatchMode::Poll { interval } => Backend::Poll(
207 notify::PollWatcher::new(
208 sender,
209 notify::Config::default().with_poll_interval(interval),
210 )
211 .map_err(to_io)?,
212 ),
213 };
214
215 match &mut backend {
216 Backend::Native(watcher) => watch_directories(name, watcher, &watched)?,
217 Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
218 }
219
220 thread::Builder::new()
221 .name(format!("config-watch-{name}"))
222 .spawn(move || run(name, &watched, debounce, reload, &receiver))?;
223
224 // Everything that could fail has succeeded; from here the *handle* owns
225 // the registration and frees it on drop.
226 registered.defuse();
227
228 Ok(WatchHandle {
229 key,
230 name,
231 watcher: Some(backend),
232 })
233}
234
235/// Rolls the name registration back unless the spawn completed.
236///
237/// Every `?` between the insertion and the end of `spawn_with` — the backend,
238/// the directory watches, the thread — runs through this on the way out.
239struct Registered {
240 key: WatchKey,
241 armed: bool,
242}
243
244impl Registered {
245 /// The spawn completed; the registration now belongs to the handle.
246 fn defuse(mut self) {
247 self.armed = false;
248 }
249}
250
251impl Drop for Registered {
252 fn drop(&mut self) {
253 if self.armed {
254 STARTED
255 .lock()
256 .unwrap_or_else(std::sync::PoisonError::into_inner)
257 .remove(&self.key);
258 }
259 }
260}
261
262fn to_io(error: notify::Error) -> std::io::Error {
263 std::io::Error::new(std::io::ErrorKind::Other, error)
264}
265
266/// Watches the *directories* holding the files, not the files themselves.
267///
268/// Editors and `mv`-based atomic saves replace the inode, which silently
269/// detaches a file-level watch. Watching the parent directory survives that —
270/// and is also what makes a Kubernetes ConfigMap update, delivered as a `..data`
271/// symlink swap, visible at all.
272///
273/// Fails when nothing could be watched, rather than parking a thread on a
274/// channel that will never produce an event.
275fn watch_directories(
276 name: &'static str,
277 watcher: &mut impl Watcher,
278 watched: &Watched,
279) -> std::io::Result<()> {
280 let mut directories = Vec::<PathBuf>::new();
281
282 {
283 let mut push = |directory: PathBuf| {
284 if !directories.contains(&directory) {
285 directories.push(directory);
286 }
287 };
288
289 for file in &watched.files {
290 push(
291 file.parent()
292 .filter(|parent| !parent.as_os_str().is_empty())
293 .unwrap_or_else(|| Path::new("."))
294 .to_path_buf(),
295 );
296 }
297
298 // Every searched directory, whether or not it holds a file today: a
299 // config file appearing later is exactly the event worth catching.
300 for directory in &watched.search_directories {
301 push(directory.clone());
302 }
303 }
304
305 let mut watched = 0usize;
306 let mut last_error = None;
307
308 for directory in &directories {
309 match watcher.watch(directory, RecursiveMode::NonRecursive) {
310 Ok(()) => watched += 1,
311 Err(error) => {
312 warning!("{name}: could not watch {}: {error}", directory.display());
313 last_error = Some(error);
314 }
315 }
316 }
317
318 if watched == 0 {
319 return Err(last_error.map_or_else(
320 || {
321 std::io::Error::new(
322 std::io::ErrorKind::NotFound,
323 format!("{name}: no configuration file to watch"),
324 )
325 },
326 to_io,
327 ));
328 }
329
330 Ok(())
331}