Skip to main content

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: &notify::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}