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}