confy_rs/watch.rs
1//! File watching and hot reloading.
2//!
3//! Implementation notes:
4//! - The **parent directory** is watched instead of the file itself, so that
5//! editors saving via "write temp file + atomic rename" keep working
6//! (watching the file directly breaks once its inode is replaced);
7//! - the background thread debounces with `recv_timeout`, collapsing an
8//! event storm into a single reload;
9//! - readers go through `ArcSwap`, so `get()` is lock-free;
10//! - a failed reload keeps the previous config and reports the error via
11//! `on_error`; reloads yielding unchanged content are silently skipped.
12
13use std::collections::HashMap;
14use std::collections::HashSet;
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17use std::sync::mpsc::{self, RecvTimeoutError};
18use std::sync::{Arc, Mutex};
19use std::thread::JoinHandle;
20use std::time::Duration;
21
22use arc_swap::ArcSwap;
23use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
24use serde::de::DeserializeOwned;
25use serde_json::Value;
26
27use crate::builder::ConfigBuilder;
28use crate::error::Error;
29
30/// Debounce window: consecutive file events within it collapse into one reload.
31const DEBOUNCE_WINDOW: Duration = Duration::from_millis(500);
32/// Retry count for a target file that is briefly missing (mid atomic replace).
33const RETRY_ATTEMPTS: usize = 3;
34/// Interval between those retries.
35const RETRY_INTERVAL: Duration = Duration::from_millis(50);
36
37/// Type aliases for change and error callbacks.
38type ChangeCallback<T> = Arc<dyn Fn(Arc<T>) + Send + Sync>;
39type ErrorCallback = Arc<dyn Fn(&Error) + Send + Sync>;
40
41/// A handle to a configuration that is automatically reloaded when any of
42/// its source files change.
43///
44/// - [`get`](Self::get) returns the current snapshot without locking, cheap
45/// enough to call on every request;
46/// - when a reload fails, the previous configuration stays active and the
47/// error is reported through [`on_error`](Self::on_error);
48/// - dropping the watcher stops the background thread.
49///
50/// Created by [`ConfigBuilder::watch`].
51pub struct ConfigWatcher<T> {
52 /// State shared with the background thread.
53 shared: Arc<Shared<T>>,
54 /// notify handle: dropping it closes the event channel, which in turn
55 /// ends the background thread.
56 watcher: Option<RecommendedWatcher>,
57 /// Handle of the background reload thread.
58 thread: Option<JoinHandle<()>>,
59}
60
61/// State shared between readers and the background reload thread.
62struct Shared<T> {
63 /// Currently active configuration snapshot.
64 current: ArcSwap<T>,
65 /// Callbacks notified after each successful reload.
66 on_change: Mutex<Vec<ChangeCallback<T>>>,
67 /// Callbacks notified when a reload fails.
68 on_error: Mutex<Vec<ErrorCallback>>,
69}
70
71impl ConfigBuilder {
72 /// Loads the configuration once, then watches all file sources and
73 /// hot-reloads whenever they change.
74 ///
75 /// The initial load is synchronous, so a broken configuration is
76 /// reported immediately instead of at some point later. The returned
77 /// watcher is runtime-agnostic: it uses one background thread and no
78 /// async runtime, and [`ConfigWatcher::get`] can be called from
79 /// anywhere, including async code.
80 ///
81 /// Note: the parent directory of an *optional* file that does not exist
82 /// yet cannot be watched; such a file will only be picked up on the next
83 /// reload triggered by another source.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error if the initial load fails (see
88 /// [`build`](Self::build)) or if the file watcher cannot be set up.
89 pub fn watch<T>(self) -> Result<ConfigWatcher<T>, Error>
90 where
91 T: DeserializeOwned + Send + Sync + 'static,
92 {
93 // Load once synchronously at startup so a broken config fails immediately
94 let initial_value = self.build_value()?;
95 let initial: T =
96 serde_json::from_value(initial_value.clone()).map_err(Error::Deserialize)?;
97
98 let shared = Arc::new(Shared {
99 current: ArcSwap::from_pointee(initial),
100 on_change: Mutex::new(Vec::new()),
101 on_error: Mutex::new(Vec::new()),
102 });
103
104 // notify events are forwarded through a std mpsc channel to the
105 // background thread
106 let (sender, receiver) = mpsc::channel::<notify::Result<notify::Event>>();
107 let mut watcher = notify::recommended_watcher(move |event| {
108 // A send failure means we are shutting down; drop the event
109 let _ = sender.send(event);
110 })?;
111
112 // Collect watch targets: watched file names are recorded for event
113 // filtering; directories are deduplicated by canonical path, OR-ing
114 // `required` per directory
115 let mut file_names: HashSet<OsString> = HashSet::new();
116 let mut dirs: HashMap<PathBuf, (PathBuf, bool)> = HashMap::new();
117 for (path, required) in self.file_paths() {
118 if let Some(name) = path.file_name() {
119 file_names.insert(name.to_os_string());
120 }
121 let dir = parent_dir(&path);
122 let key = dir.canonicalize().unwrap_or_else(|_| dir.clone());
123 let entry = dirs.entry(key).or_insert_with(|| (dir, false));
124 entry.1 |= required;
125 }
126 for (dir, required) in dirs.into_values() {
127 if let Err(err) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
128 // A required file's directory must exist (the initial load
129 // passed), so a watch failure is a real error; an optional
130 // file's directory may be missing and is simply skipped
131 if required {
132 return Err(Error::Watch(err));
133 }
134 }
135 }
136
137 // The background thread takes over the builder, handling debouncing
138 // and reloads
139 let thread = {
140 let shared = Arc::clone(&shared);
141 std::thread::spawn(move || {
142 reload_loop(&receiver, &self, &shared, initial_value, &file_names);
143 })
144 };
145
146 Ok(ConfigWatcher {
147 shared,
148 watcher: Some(watcher),
149 thread: Some(thread),
150 })
151 }
152}
153
154impl<T> ConfigWatcher<T> {
155 /// Returns the currently active configuration snapshot.
156 ///
157 /// Lock-free (backed by `arc-swap`), so it can be called freely on hot
158 /// paths and from any thread. The returned [`Arc`] stays valid even if
159 /// the configuration is swapped out in the meantime.
160 #[must_use]
161 pub fn get(&self) -> Arc<T> {
162 self.shared.current.load_full()
163 }
164
165 /// Registers a callback invoked after each successful reload with the
166 /// new configuration.
167 ///
168 /// Callbacks may be registered multiple times and run on the background
169 /// watcher thread — keep them short and non-blocking. A callback that
170 /// panics will terminate that thread and stop hot reloading.
171 pub fn on_change(&self, callback: impl Fn(Arc<T>) + Send + Sync + 'static) {
172 push_callback(&self.shared.on_change, Arc::new(callback));
173 }
174
175 /// Registers a callback invoked when a reload fails.
176 ///
177 /// The previous configuration remains active in that case. Runs on the
178 /// background watcher thread, same caveats as
179 /// [`on_change`](Self::on_change).
180 pub fn on_error(&self, callback: impl Fn(&Error) + Send + Sync + 'static) {
181 push_callback(&self.shared.on_error, Arc::new(callback));
182 }
183}
184
185impl<T> Drop for ConfigWatcher<T> {
186 fn drop(&mut self) {
187 // Drop the notify watcher first: the event channel closes and the
188 // background thread exits once recv fails
189 drop(self.watcher.take());
190 if let Some(handle) = self.thread.take() {
191 // No need to re-propagate a panic caused by a user callback
192 let _ = handle.join();
193 }
194 }
195}
196
197impl<T> Shared<T> {
198 /// Fires change callbacks: snapshot the list first so user code never
199 /// runs under the lock.
200 fn fire_change(&self, config: &Arc<T>) {
201 for callback in lock_snapshot(&self.on_change) {
202 callback(Arc::clone(config));
203 }
204 }
205
206 /// Fires error callbacks.
207 fn fire_error(&self, error: &Error) {
208 for callback in lock_snapshot(&self.on_error) {
209 callback(error);
210 }
211 }
212}
213
214/// Background reload loop: block on events → debounce → reload and notify.
215fn reload_loop<T>(
216 receiver: &mpsc::Receiver<notify::Result<notify::Event>>,
217 builder: &ConfigBuilder,
218 shared: &Shared<T>,
219 mut last_value: Value,
220 file_names: &HashSet<OsString>,
221) where
222 T: DeserializeOwned,
223{
224 // The loop ends when the channel closes (watcher dropped); the thread
225 // then exits cleanly
226 while let Ok(event) = receiver.recv() {
227 if !is_relevant(&event, file_names) {
228 continue;
229 }
230 // Debounce: editor saves tend to emit a burst of events, so swallow
231 // everything within the window
232 loop {
233 match receiver.recv_timeout(DEBOUNCE_WINDOW) {
234 Ok(_) => {}
235 Err(RecvTimeoutError::Timeout) => break,
236 Err(RecvTimeoutError::Disconnected) => return,
237 }
238 }
239 reload(builder, shared, &mut last_value);
240 }
241}
242
243/// Performs a single reload: failures keep the old config and report the
244/// error; unchanged content is silently skipped.
245fn reload<T>(builder: &ConfigBuilder, shared: &Shared<T>, last_value: &mut Value)
246where
247 T: DeserializeOwned,
248{
249 // Atomic-replace saves leave a brief window where the file is missing,
250 // so retry NotFound a bounded number of times
251 let mut result = builder.build_value();
252 for _ in 0..RETRY_ATTEMPTS {
253 if !matches!(result, Err(Error::NotFound { .. })) {
254 break;
255 }
256 std::thread::sleep(RETRY_INTERVAL);
257 result = builder.build_value();
258 }
259
260 let value = match result {
261 Ok(value) => value,
262 Err(err) => return shared.fire_error(&err),
263 };
264 // Skip triggers without an actual change (metadata events, historical
265 // events at watcher startup, ...)
266 if value == *last_value {
267 return;
268 }
269 match serde_json::from_value::<T>(value.clone()) {
270 Ok(config) => {
271 *last_value = value;
272 let snapshot = Arc::new(config);
273 // Publish the new value before notifying so get() inside a
274 // callback always sees the new config
275 shared.current.store(Arc::clone(&snapshot));
276 shared.fire_change(&snapshot);
277 }
278 Err(err) => shared.fire_error(&Error::Deserialize(err)),
279 }
280}
281
282/// Whether the event touches any watched file (matched by file name; a
283/// false positive only costs one extra idempotent reload).
284fn is_relevant(event: ¬ify::Result<notify::Event>, file_names: &HashSet<OsString>) -> bool {
285 match event {
286 Ok(event) => event.paths.iter().any(|path| {
287 path.file_name()
288 .is_some_and(|name| file_names.contains(name))
289 }),
290 // Errors from notify itself (e.g. event queue overflow): reload once
291 // to stay on the safe side
292 Err(_) => true,
293 }
294}
295
296/// The file's parent directory; bare file names fall back to the current
297/// directory.
298fn parent_dir(path: &Path) -> PathBuf {
299 match path.parent() {
300 Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
301 _ => PathBuf::from("."),
302 }
303}
304
305/// Appends a callback (tolerating lock poisoning: code holding the lock
306/// never panics).
307fn push_callback<C>(mutex: &Mutex<Vec<C>>, callback: C) {
308 match mutex.lock() {
309 Ok(mut guard) => guard.push(callback),
310 Err(poisoned) => poisoned.into_inner().push(callback),
311 }
312}
313
314/// Clones the callback list and releases the lock right away, so user
315/// callbacks never run under the lock (avoids deadlocks).
316fn lock_snapshot<C: Clone>(mutex: &Mutex<Vec<C>>) -> Vec<C> {
317 match mutex.lock() {
318 Ok(guard) => guard.clone(),
319 Err(poisoned) => poisoned.into_inner().clone(),
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 /// Parent directory derivation: regular paths and bare file names.
328 #[test]
329 fn parent_dir_fallbacks_to_current() {
330 assert_eq!(parent_dir(Path::new("a/b/c.toml")), PathBuf::from("a/b"));
331 assert_eq!(parent_dir(Path::new("c.toml")), PathBuf::from("."));
332 }
333
334 /// Event filtering: only watched file names match.
335 #[test]
336 fn event_relevance_by_file_name() {
337 let names: HashSet<OsString> = [OsString::from("app.toml")].into();
338 let event =
339 notify::Event::new(notify::EventKind::Any).add_path(PathBuf::from("/tmp/app.toml"));
340 assert!(is_relevant(&Ok(event), &names));
341
342 let other =
343 notify::Event::new(notify::EventKind::Any).add_path(PathBuf::from("/tmp/other.toml"));
344 assert!(!is_relevant(&Ok(other), &names));
345 }
346
347 /// Compile-time contract: ConfigWatcher must remain Send + Sync so a
348 /// dependency upgrade (e.g. notify changing its internal handles) cannot
349 /// silently break users' multi-threaded code.
350 #[test]
351 fn config_watcher_is_send_sync() {
352 fn assert_send_sync<T: Send + Sync>() {}
353 assert_send_sync::<ConfigWatcher<String>>();
354 }
355}