config_lib/hot_reload.rs
1//! Configuration Hot Reloading System
2//!
3//! Production-grade hot reloading with:
4//!
5//! - **Event-driven file watching** via the [`notify`](https://docs.rs/notify)
6//! crate (default in v0.9.6+ via the `hot-reload` Cargo feature).
7//! `notify` is a cross-platform wrapper over the kernel's native
8//! filesystem event APIs: `inotify` on Linux, `FSEvents` on macOS,
9//! and `ReadDirectoryChangesW` on Windows. Detection latency is
10//! typically a few milliseconds — well under the 100 ms target the
11//! v1.0 stability contract commits to.
12//! - **Atomic-write debouncing.** Many editors save by writing to a
13//! temporary file and atomically renaming it over the target. This
14//! produces a flurry of events (create, modify, delete, modify) in
15//! rapid succession. The reloader collapses any burst within the
16//! debounce window (default 100 ms, configurable) to one
17//! `Reloaded` notification.
18//! - **`Arc<RwLock<Config>>` swap** for zero-downtime updates —
19//! readers never block while the reloader parses the new file.
20//! - **Lock-free in-process notification dispatch** (v1.0.0+). Register
21//! handlers via `HotReloadConfig::on_change` / `HotReloadHandle::on_change`;
22//! the reloader thread invokes every registered handler inline with
23//! no channel allocation and no cross-thread wakeup. Backed by
24//! `ArcSwap<Vec<Handler>>` — snapshot reads cost a single atomic
25//! pointer load (~5 ns) regardless of how many handlers are
26//! registered. See `docs/PERFORMANCE.md` for measured numbers.
27//! - **Panic isolation.** Each handler invocation is wrapped in
28//! `catch_unwind` so one bad handler can't take down the watcher
29//! or other handlers.
30//! - **Polling fallback** (always available, used as the default when
31//! the `hot-reload` feature is disabled, or available as an opt-in
32//! on top of event-driven watching for environments where the kernel
33//! APIs are known-broken — network filesystems, some container
34//! layers).
35
36use crate::config::Config;
37use crate::error::{Error, Result};
38use arc_swap::ArcSwap;
39use std::panic::{catch_unwind, AssertUnwindSafe};
40use std::path::{Path, PathBuf};
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::mpsc::{self, Receiver};
43use std::sync::{Arc, RwLock, Weak};
44use std::thread;
45use std::time::{Duration, SystemTime};
46
47/// Configuration change event types.
48///
49/// **Stability:** `ConfigChangeEvent` is `#[non_exhaustive]` so the
50/// v1.x SemVer contract can add new variants (e.g. `Renamed`,
51/// `PermissionDenied`) in MINOR releases without breaking user code.
52/// Callers must use a wildcard arm when pattern-matching.
53#[derive(Debug, Clone)]
54#[non_exhaustive]
55pub enum ConfigChangeEvent {
56 /// Configuration successfully reloaded
57 Reloaded {
58 /// Path to the configuration file that was reloaded
59 path: PathBuf,
60 /// Timestamp when the reload completed
61 timestamp: SystemTime,
62 },
63 /// Configuration reload failed
64 ReloadFailed {
65 /// Path to the configuration file that failed to reload
66 path: PathBuf,
67 /// Error message describing what went wrong
68 error: String,
69 /// Timestamp when the error occurred
70 timestamp: SystemTime,
71 },
72 /// Configuration file was modified
73 FileModified {
74 /// Path to the configuration file that was modified
75 path: PathBuf,
76 /// Timestamp when the modification was detected
77 timestamp: SystemTime,
78 },
79 /// Configuration file was deleted
80 FileDeleted {
81 /// Path to the configuration file that was deleted
82 path: PathBuf,
83 /// Timestamp when the deletion was detected
84 timestamp: SystemTime,
85 },
86}
87
88/// Default debounce window applied to file-change events before
89/// triggering a reload. Sized to cover the editor "save via atomic
90/// rename" pattern (where a single save fires multiple kernel events
91/// within ~10–50 ms).
92const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(100);
93
94// =========================================================================
95// HandlerList — lock-free in-process notification dispatch (v1.0.0+)
96// =========================================================================
97
98/// Boxed handler closure. `Arc` so that an iteration snapshot can
99/// outlive registration / unregistration of the same handler without
100/// invalidating the in-flight call.
101type Handler = Arc<dyn Fn(&ConfigChangeEvent) + Send + Sync + 'static>;
102
103/// Lock-free handler list backing [`HotReloadConfig::on_change`].
104///
105/// Reads (the dispatch path on the reloader thread) take a snapshot
106/// of the current handler vector via one `ArcSwap::load` — a single
107/// relaxed atomic pointer load. Writes (register / unregister)
108/// allocate a new vector, copy the old contents minus/plus one
109/// handler, and atomic-swap the pointer (`rcu`-style update). Writes
110/// never block reads.
111///
112/// The handler list is shared between [`HotReloadConfig`] and its
113/// spawned worker thread via `Arc<HandlerList>`, so handlers can be
114/// registered before *or* after [`HotReloadConfig::start_watching`]
115/// — see [`HotReloadHandle::on_change`].
116pub(crate) struct HandlerList {
117 handlers: ArcSwap<Vec<(u64, Handler)>>,
118 next_id: AtomicU64,
119}
120
121impl HandlerList {
122 fn new() -> Self {
123 Self {
124 handlers: ArcSwap::from_pointee(Vec::new()),
125 next_id: AtomicU64::new(0),
126 }
127 }
128
129 /// Dispatch an event to every registered handler.
130 ///
131 /// Each handler is invoked inline on the calling thread. Panic
132 /// isolation: a handler that panics is caught via `catch_unwind`
133 /// so subsequent handlers still run and the reloader thread is
134 /// not torn down.
135 fn dispatch(&self, event: &ConfigChangeEvent) {
136 // Single atomic pointer load — the dispatch hot path.
137 let snapshot = self.handlers.load();
138 for (_id, handler) in snapshot.iter() {
139 // Clone the Arc<dyn Fn> so the handler stays alive even
140 // if it's concurrently unregistered during this loop
141 // iteration. Refcount bump, no allocation.
142 let h = Arc::clone(handler);
143 // REPS-AUDIT: handlers are user-supplied; one panicking
144 // handler must not break the watcher or other handlers.
145 // `catch_unwind` swallows the panic and we discard the
146 // payload (handlers are best-effort observers).
147 let _ = catch_unwind(AssertUnwindSafe(move || {
148 h(event);
149 }));
150 }
151 }
152
153 /// Register a new handler, returning its assigned id.
154 fn register(&self, handler: Handler) -> u64 {
155 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
156 self.handlers.rcu(|current| {
157 let mut next = Vec::with_capacity(current.len() + 1);
158 next.extend(current.iter().cloned());
159 next.push((id, Arc::clone(&handler)));
160 next
161 });
162 id
163 }
164
165 /// Remove the handler with the given id, if present. Idempotent.
166 fn unregister(&self, id: u64) {
167 self.handlers.rcu(|current| {
168 current
169 .iter()
170 .filter(|(other_id, _)| *other_id != id)
171 .cloned()
172 .collect::<Vec<_>>()
173 });
174 }
175}
176
177/// RAII handle for a registered change-notification handler.
178///
179/// Returned by [`HotReloadConfig::on_change`] /
180/// [`HotReloadHandle::on_change`]. Drop the `Subscription` to
181/// unregister the handler. The watcher itself outlives any individual
182/// subscription — multiple subscriptions can come and go without
183/// touching the underlying [`HotReloadConfig`].
184///
185/// # Example
186///
187/// ```rust,no_run
188/// use config_lib::hot_reload::{HotReloadConfig, ConfigChangeEvent};
189///
190/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
191/// let hot = HotReloadConfig::from_file("app.conf")?;
192/// let _subscription = hot.on_change(|event: &ConfigChangeEvent| {
193/// println!("config changed: {event:?}");
194/// });
195/// // Drop `_subscription` (e.g. at end of scope) to unregister.
196/// # Ok(())
197/// # }
198/// ```
199#[must_use = "dropping the Subscription immediately unregisters the handler; bind to a name (`let _sub = ...`) or call `.forget()` to keep the handler alive"]
200pub struct Subscription {
201 list: Weak<HandlerList>,
202 id: u64,
203}
204
205impl Subscription {
206 /// Detach the subscription from its drop-based unregistration
207 /// hook. The handler stays registered for the lifetime of the
208 /// underlying watcher (until the [`HotReloadConfig`] or
209 /// [`HotReloadHandle`] that produced it is dropped).
210 ///
211 /// Useful for global / process-lifetime handlers where the
212 /// caller has no convenient owning scope to hold the
213 /// `Subscription`.
214 pub fn forget(mut self) {
215 // Clear the weak reference so Drop becomes a no-op.
216 self.list = Weak::new();
217 }
218}
219
220impl Drop for Subscription {
221 fn drop(&mut self) {
222 if let Some(list) = self.list.upgrade() {
223 list.unregister(self.id);
224 }
225 }
226}
227
228impl std::fmt::Debug for Subscription {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 f.debug_struct("Subscription")
231 .field("id", &self.id)
232 .field("alive", &(self.list.strong_count() > 0))
233 .finish()
234 }
235}
236
237// =========================================================================
238// HotReloadConfig
239// =========================================================================
240
241/// Hot-reloadable configuration container.
242///
243/// Construct with [`HotReloadConfig::from_file`], then either drive
244/// reloads manually with [`HotReloadConfig::reload`] or hand off to a
245/// background watcher with [`HotReloadConfig::start_watching`].
246///
247/// Configurable knobs (all consuming-builder style, intended for
248/// fluent construction):
249///
250/// - [`HotReloadConfig::on_change`] — register a change handler
251/// (v1.0.0+; the recommended notification API).
252/// - [`HotReloadConfig::with_change_notifications`] — receive
253/// [`ConfigChangeEvent`]s on an `mpsc` channel (deprecated since
254/// v1.0.0; kept as a bridge for backwards compatibility).
255/// - [`HotReloadConfig::with_debounce`] — adjust the debounce window
256/// (default 100 ms).
257/// - [`HotReloadConfig::with_poll_interval`] — set the polling
258/// interval. Used directly when the `hot-reload` feature is off;
259/// used as the watchdog interval when the feature is on.
260/// - [`HotReloadConfig::with_polling_fallback`] — opt into a
261/// parallel polling watchdog *in addition to* the event-driven
262/// watcher, for environments where the kernel watcher is known
263/// unreliable.
264pub struct HotReloadConfig {
265 /// Current configuration (thread-safe)
266 current: Arc<RwLock<Config>>,
267 /// File path being watched
268 file_path: PathBuf,
269 /// Last known modification time
270 last_modified: SystemTime,
271 /// Lock-free handler list. Shared with the worker thread via
272 /// `Arc<HandlerList>` once `start_watching` is called.
273 handlers: Arc<HandlerList>,
274 /// Bridge subscriptions kept alive by the deprecated
275 /// `with_change_notifications` API. Each entry registers a
276 /// closure that forwards events to the corresponding
277 /// `mpsc::Sender`; dropping the subscription stops the
278 /// forwarding. Stored here so the bridge lives at least as
279 /// long as the `HotReloadConfig` itself, and is moved into the
280 /// [`HotReloadHandle`] when `start_watching` consumes self.
281 bridges: Vec<Subscription>,
282 /// Polling interval — used as primary cadence when the
283 /// `hot-reload` feature is off, or as the watchdog interval when
284 /// the feature is on and `polling_fallback_enabled` is set.
285 poll_interval: Duration,
286 /// Debounce window applied to clustered file-change events.
287 debounce: Duration,
288 /// Whether to run the polling watchdog *in addition to* the
289 /// event-driven watcher. Useful on network filesystems.
290 polling_fallback_enabled: bool,
291}
292
293impl HotReloadConfig {
294 /// Create a new hot-reloadable configuration from a file.
295 ///
296 /// # Errors
297 ///
298 /// Returns an error if the file cannot be read, parsed, or stat'd
299 /// for its modification time.
300 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
301 let path = path.as_ref().to_path_buf();
302 let config = Config::from_file(&path)?;
303
304 let last_modified = std::fs::metadata(&path)
305 .map_err(|e| Error::io(path.display().to_string(), e))?
306 .modified()
307 .map_err(|e| Error::io(path.display().to_string(), e))?;
308
309 Ok(Self {
310 current: Arc::new(RwLock::new(config)),
311 file_path: path,
312 last_modified,
313 handlers: Arc::new(HandlerList::new()),
314 bridges: Vec::new(),
315 poll_interval: Duration::from_secs(1),
316 debounce: DEFAULT_DEBOUNCE,
317 polling_fallback_enabled: false,
318 })
319 }
320
321 /// Set the polling interval for file change detection.
322 ///
323 /// When the `hot-reload` feature is enabled (the default in v0.9.6+),
324 /// the primary watcher is event-driven and this interval is only
325 /// consulted as the watchdog cadence if `with_polling_fallback`
326 /// has been called.
327 ///
328 /// When the `hot-reload` feature is disabled, this is the actual
329 /// polling cadence of the background thread.
330 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
331 self.poll_interval = interval;
332 self
333 }
334
335 /// Override the debounce window applied to clustered file-change
336 /// events.
337 ///
338 /// Editors that save via "write-to-tmp + atomic-rename" generate
339 /// multiple kernel events for a single user save. The debounce
340 /// collapses any burst within this window to a single reload.
341 /// Default: 100 ms.
342 pub fn with_debounce(mut self, debounce: Duration) -> Self {
343 self.debounce = debounce;
344 self
345 }
346
347 /// Opt into running a polling watchdog *in addition to* the
348 /// event-driven watcher.
349 ///
350 /// Network filesystems (SMB, NFS), some container overlay
351 /// filesystems, and a handful of edge-case kernel configurations
352 /// drop or delay events that `notify` would normally surface.
353 /// Enabling the polling fallback re-derives changes from periodic
354 /// `stat(2)` calls on the watched path, at the
355 /// [`HotReloadConfig::with_poll_interval`] cadence.
356 ///
357 /// Has no effect (and costs nothing) when the `hot-reload` Cargo
358 /// feature is disabled — the watcher is already polling in that
359 /// configuration.
360 pub fn with_polling_fallback(mut self) -> Self {
361 self.polling_fallback_enabled = true;
362 self
363 }
364
365 /// Register a change handler. **Recommended notification API**
366 /// (v1.0.0+).
367 ///
368 /// The handler is invoked inline on the reloader thread every
369 /// time a [`ConfigChangeEvent`] is produced — typically a few
370 /// milliseconds after the underlying filesystem event, plus the
371 /// debounce window. Dispatch overhead is a single atomic pointer
372 /// load (~5 ns) plus the handler's own cost; multiple handlers
373 /// are called in registration order with no per-handler channel
374 /// allocation.
375 ///
376 /// Returns a [`Subscription`] whose `Drop` unregisters the
377 /// handler. Bind to a `let _sub = ...` if you want the handler
378 /// to live for the surrounding scope, or call
379 /// [`Subscription::forget`] to detach the drop hook.
380 ///
381 /// # Panics
382 ///
383 /// If the handler itself panics, the panic is caught via
384 /// `catch_unwind` and discarded — other handlers continue to
385 /// receive the event and the reloader thread is not torn down.
386 /// Handler authors should avoid panicking, but a buggy handler
387 /// won't take down the whole library.
388 ///
389 /// # Example
390 ///
391 /// ```rust,no_run
392 /// use config_lib::hot_reload::{HotReloadConfig, ConfigChangeEvent};
393 ///
394 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
395 /// let hot = HotReloadConfig::from_file("app.conf")?;
396 /// let _sub = hot.on_change(|event: &ConfigChangeEvent| {
397 /// if let ConfigChangeEvent::Reloaded { path, .. } = event {
398 /// println!("reloaded {}", path.display());
399 /// }
400 /// });
401 /// let _handle = hot.start_watching();
402 /// // ... `_sub` and `_handle` are alive for the rest of the scope
403 /// # Ok(())
404 /// # }
405 /// ```
406 pub fn on_change<F>(&self, handler: F) -> Subscription
407 where
408 F: Fn(&ConfigChangeEvent) + Send + Sync + 'static,
409 {
410 let id = self.handlers.register(Arc::new(handler));
411 Subscription {
412 list: Arc::downgrade(&self.handlers),
413 id,
414 }
415 }
416
417 /// Enable channel-based change notifications. **Deprecated since
418 /// v1.0.0** — prefer [`HotReloadConfig::on_change`].
419 ///
420 /// Internally bridges to the same lock-free handler list as
421 /// `on_change` by registering a closure that forwards events
422 /// to an `mpsc::Sender`. The bridge subscription is held by
423 /// `self` so the channel keeps receiving events for the
424 /// lifetime of the watcher.
425 ///
426 /// The bridge pays one extra `mpsc::Sender::send` per event
427 /// (~100–200 ns) on top of the lock-free dispatch cost — the
428 /// inverse of why `on_change` exists. Existing code using
429 /// `Receiver<ConfigChangeEvent>` continues to work unchanged.
430 #[deprecated(
431 since = "1.0.0",
432 note = "use `on_change` for lock-free dispatch; this method continues to work but pays an mpsc allocation per event"
433 )]
434 pub fn with_change_notifications(mut self) -> (Self, Receiver<ConfigChangeEvent>) {
435 let (tx, rx) = mpsc::channel();
436 let bridge = self.on_change(move |event| {
437 let _ = tx.send(event.clone());
438 });
439 self.bridges.push(bridge);
440 (self, rx)
441 }
442
443 /// Get a thread-safe reference to the current configuration.
444 pub fn config(&self) -> Arc<RwLock<Config>> {
445 Arc::clone(&self.current)
446 }
447
448 /// Get a freshly-reparsed snapshot of the configuration file as
449 /// it exists on disk *right now*.
450 ///
451 /// This is distinct from reading the current `Arc<RwLock<Config>>`
452 /// — it bypasses the watcher and re-reads the file. Useful for
453 /// "what would I see if I reloaded now" inspection.
454 ///
455 /// # Errors
456 ///
457 /// Returns an error if the file cannot be read or parsed.
458 pub fn snapshot(&self) -> Result<Config> {
459 Config::from_file(&self.file_path)
460 }
461
462 /// Manually trigger a reload check.
463 ///
464 /// Re-stats the file, compares mtime against the last-known
465 /// modification time, and re-parses if newer. Dispatches a
466 /// [`ConfigChangeEvent::Reloaded`] or
467 /// [`ConfigChangeEvent::ReloadFailed`] notification through the
468 /// handler list.
469 ///
470 /// Returns `Ok(true)` if a reload was performed, `Ok(false)` if
471 /// the file was unchanged since the last check.
472 ///
473 /// # Errors
474 ///
475 /// Returns an error if the file cannot be stat'd, read, or parsed.
476 pub fn reload(&mut self) -> Result<bool> {
477 let metadata = std::fs::metadata(&self.file_path)
478 .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
479
480 let modified = metadata
481 .modified()
482 .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
483
484 if modified <= self.last_modified {
485 return Ok(false);
486 }
487
488 match Config::from_file(&self.file_path) {
489 Ok(new_config) => {
490 {
491 let mut config = self.current.write().map_err(|_| {
492 Error::concurrency("Failed to acquire write lock".to_string())
493 })?;
494 *config = new_config;
495 }
496 self.last_modified = modified;
497
498 self.handlers.dispatch(&ConfigChangeEvent::Reloaded {
499 path: self.file_path.clone(),
500 timestamp: SystemTime::now(),
501 });
502 Ok(true)
503 }
504 Err(e) => {
505 self.handlers.dispatch(&ConfigChangeEvent::ReloadFailed {
506 path: self.file_path.clone(),
507 error: e.to_string(),
508 timestamp: SystemTime::now(),
509 });
510 Err(e)
511 }
512 }
513 }
514
515 /// Start automatic hot reloading in a background thread.
516 ///
517 /// With the `hot-reload` Cargo feature enabled (the default in
518 /// v0.9.6+), the background worker registers a
519 /// `notify::RecommendedWatcher` on the file's parent directory
520 /// and reacts to kernel events. Otherwise it falls back to a
521 /// `poll_interval`-cadence polling thread (the v0.9.5 behavior).
522 pub fn start_watching(self) -> HotReloadHandle {
523 #[cfg(feature = "hot-reload")]
524 {
525 self.start_watching_event_driven()
526 }
527 #[cfg(not(feature = "hot-reload"))]
528 {
529 self.start_watching_polling()
530 }
531 }
532
533 /// Get the file path being watched.
534 pub fn file_path(&self) -> &Path {
535 &self.file_path
536 }
537
538 /// Get the last modification time.
539 pub fn last_modified(&self) -> SystemTime {
540 self.last_modified
541 }
542
543 // -----------------------------------------------------------------
544 // Polling watcher — used as the primary watcher when the
545 // `hot-reload` Cargo feature is disabled. (When the feature is on,
546 // the event-driven path covers all environments where the kernel
547 // event API works; opt-in polling-as-watchdog alongside the
548 // event-driven watcher is reserved for a follow-up release.)
549 // -----------------------------------------------------------------
550
551 #[cfg(not(feature = "hot-reload"))]
552 fn start_watching_polling(self) -> HotReloadHandle {
553 let stop = Arc::new(AtomicBool::new(false));
554 let stop_clone = Arc::clone(&stop);
555
556 let current = Arc::clone(&self.current);
557 let file_path = self.file_path.clone();
558 let handlers = Arc::clone(&self.handlers);
559 let poll_interval = self.poll_interval;
560 let mut last_modified = self.last_modified;
561
562 let handle = thread::spawn(move || {
563 while !stop_clone.load(Ordering::Relaxed) {
564 if let Ok(metadata) = std::fs::metadata(&file_path) {
565 if let Ok(modified) = metadata.modified() {
566 if modified > last_modified {
567 handlers.dispatch(&ConfigChangeEvent::FileModified {
568 path: file_path.clone(),
569 timestamp: SystemTime::now(),
570 });
571
572 match Config::from_file(&file_path) {
573 Ok(new_config) => {
574 if let Ok(mut config) = current.write() {
575 *config = new_config;
576 last_modified = modified;
577 handlers.dispatch(&ConfigChangeEvent::Reloaded {
578 path: file_path.clone(),
579 timestamp: SystemTime::now(),
580 });
581 }
582 }
583 Err(e) => {
584 handlers.dispatch(&ConfigChangeEvent::ReloadFailed {
585 path: file_path.clone(),
586 error: e.to_string(),
587 timestamp: SystemTime::now(),
588 });
589 }
590 }
591 }
592 }
593 }
594 thread::sleep(poll_interval);
595 }
596 });
597
598 HotReloadHandle {
599 handle: Some(handle),
600 stop,
601 handlers: self.handlers,
602 _bridges: self.bridges,
603 }
604 }
605
606 // -----------------------------------------------------------------
607 // Event-driven watcher — gated on the `hot-reload` feature.
608 // -----------------------------------------------------------------
609
610 #[cfg(feature = "hot-reload")]
611 fn start_watching_event_driven(self) -> HotReloadHandle {
612 use notify::{Event, RecursiveMode, Watcher};
613
614 let stop = Arc::new(AtomicBool::new(false));
615 let current = Arc::clone(&self.current);
616 let file_path = self.file_path.clone();
617 let handlers = Arc::clone(&self.handlers);
618 let debounce = self.debounce;
619 let poll_interval = self.poll_interval;
620 let polling_fallback = self.polling_fallback_enabled;
621 let initial_modified = self.last_modified;
622
623 // Channel from the notify callback to the reload worker.
624 // This `mpsc` is purely internal — between the notify
625 // callback (which runs on `notify`'s own thread) and our
626 // worker thread. It is NOT the user-facing notification
627 // channel; that path is via `HandlerList::dispatch`.
628 let (tx, rx) = mpsc::channel::<Event>();
629
630 // Build the watcher. We watch the *parent* directory (not the
631 // file itself) so that atomic-rename saves — where the file's
632 // inode is replaced — still surface as events on our target.
633 let watcher_dir = file_path
634 .parent()
635 .map(Path::to_path_buf)
636 .unwrap_or_else(|| PathBuf::from("."));
637
638 let watcher_result = notify::RecommendedWatcher::new(
639 move |res: notify::Result<Event>| {
640 if let Ok(event) = res {
641 let _ = tx.send(event);
642 }
643 },
644 notify::Config::default(),
645 )
646 .and_then(|mut w| {
647 w.watch(&watcher_dir, RecursiveMode::NonRecursive)?;
648 Ok(w)
649 });
650
651 let watcher = match watcher_result {
652 Ok(w) => Some(w),
653 Err(e) => {
654 // Watcher construction failed — likely the platform
655 // event API is unavailable (rare). Surface a
656 // `ReloadFailed` so the caller knows.
657 handlers.dispatch(&ConfigChangeEvent::ReloadFailed {
658 path: file_path.clone(),
659 error: format!(
660 "notify watcher construction failed: {e}; falling back to polling"
661 ),
662 timestamp: SystemTime::now(),
663 });
664 None
665 }
666 };
667
668 // Reload worker — consumes events from the notify callback,
669 // debounces, and re-parses on change.
670 let target_file = file_path.clone();
671 let handlers_for_worker = Arc::clone(&handlers);
672 let current_for_worker = Arc::clone(¤t);
673 let stop_for_worker = Arc::clone(&stop);
674 let mut last_modified_seen = initial_modified;
675
676 let handle = thread::spawn(move || {
677 while !stop_for_worker.load(Ordering::Relaxed) {
678 // Block up to `poll_interval` for the next event so
679 // the stop flag is observed promptly even when the
680 // file is quiet.
681 let first = match rx.recv_timeout(poll_interval) {
682 Ok(ev) => Some(ev),
683 Err(mpsc::RecvTimeoutError::Timeout) => None,
684 Err(mpsc::RecvTimeoutError::Disconnected) => break,
685 };
686
687 // If we got an event, drain the channel for the
688 // debounce window so the burst from a single save
689 // collapses to one reload.
690 let mut relevant = false;
691 if let Some(ev) = first {
692 relevant |= event_targets_path(&ev, &target_file);
693
694 let deadline = std::time::Instant::now() + debounce;
695 loop {
696 let remaining =
697 deadline.saturating_duration_since(std::time::Instant::now());
698 if remaining.is_zero() {
699 break;
700 }
701 match rx.recv_timeout(remaining) {
702 Ok(ev) => relevant |= event_targets_path(&ev, &target_file),
703 Err(_) => break,
704 }
705 }
706 } else if !polling_fallback {
707 continue;
708 }
709
710 // Path resolution: did the target file actually change?
711 let metadata = std::fs::metadata(&target_file);
712 match metadata {
713 Ok(meta) => {
714 let modified = meta.modified().ok();
715 let is_newer = match modified {
716 Some(m) => m > last_modified_seen,
717 None => true,
718 };
719 if !relevant && !is_newer {
720 continue;
721 }
722
723 handlers_for_worker.dispatch(&ConfigChangeEvent::FileModified {
724 path: target_file.clone(),
725 timestamp: SystemTime::now(),
726 });
727
728 match Config::from_file(&target_file) {
729 Ok(new_config) => {
730 if let Ok(mut cfg) = current_for_worker.write() {
731 *cfg = new_config;
732 if let Some(m) = modified {
733 last_modified_seen = m;
734 }
735 handlers_for_worker.dispatch(&ConfigChangeEvent::Reloaded {
736 path: target_file.clone(),
737 timestamp: SystemTime::now(),
738 });
739 }
740 }
741 Err(e) => {
742 handlers_for_worker.dispatch(&ConfigChangeEvent::ReloadFailed {
743 path: target_file.clone(),
744 error: e.to_string(),
745 timestamp: SystemTime::now(),
746 });
747 }
748 }
749 }
750 Err(_) => {
751 // File missing — likely deleted between
752 // events. Emit FileDeleted but keep the
753 // last-known-good config in place.
754 handlers_for_worker.dispatch(&ConfigChangeEvent::FileDeleted {
755 path: target_file.clone(),
756 timestamp: SystemTime::now(),
757 });
758 }
759 }
760 }
761 });
762
763 HotReloadHandle {
764 handle: Some(handle),
765 stop,
766 handlers: self.handlers,
767 _bridges: self.bridges,
768 _watcher: watcher,
769 }
770 }
771}
772
773/// Helper: does the `notify::Event` reference our watched file?
774///
775/// When watching a directory non-recursively, every event carries the
776/// list of paths it applies to. Filtering on the exact file path keeps
777/// us from reacting to unrelated sibling files in the same directory.
778#[cfg(feature = "hot-reload")]
779fn event_targets_path(event: ¬ify::Event, target: &Path) -> bool {
780 use notify::EventKind;
781 if !matches!(
782 event.kind,
783 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) | EventKind::Any
784 ) {
785 return false;
786 }
787 // Canonical-form comparison helps with macOS symlink/realpath
788 // shenanigans. Fall back to direct equality when canonicalize
789 // can't resolve (e.g. file was just deleted).
790 let target_canon = std::fs::canonicalize(target).ok();
791 event.paths.iter().any(|p| {
792 if p == target {
793 return true;
794 }
795 if let (Some(tc), Ok(pc)) = (&target_canon, std::fs::canonicalize(p)) {
796 return *tc == pc;
797 }
798 false
799 })
800}
801
802/// Handle for controlling the hot-reload background thread.
803///
804/// Returned by [`HotReloadConfig::start_watching`]. Dropping the
805/// handle (or calling [`HotReloadHandle::stop`]) signals the worker
806/// to exit and tears down the kernel-level watch registration.
807/// Holding the handle keeps the watcher alive.
808///
809/// New handlers can be registered after `start_watching` via
810/// [`HotReloadHandle::on_change`] — the handler list is shared
811/// between the original `HotReloadConfig` and the spawned worker
812/// thread.
813pub struct HotReloadHandle {
814 handle: Option<thread::JoinHandle<()>>,
815 stop: Arc<AtomicBool>,
816 /// Lock-free handler list shared with the worker thread.
817 /// Carried here so that `on_change` continues to work after
818 /// the original `HotReloadConfig` has been consumed by
819 /// `start_watching`.
820 handlers: Arc<HandlerList>,
821 /// Bridge subscriptions kept alive for the lifetime of the
822 /// watcher. The deprecated `with_change_notifications` path
823 /// installed these; dropping the handle drops them, which
824 /// unregisters the bridge handlers.
825 _bridges: Vec<Subscription>,
826 /// Watcher kept alive for the duration of the watch. Dropping
827 /// the watcher tears down the kernel registration. Only carried
828 /// when the `hot-reload` feature is on.
829 #[cfg(feature = "hot-reload")]
830 _watcher: Option<notify::RecommendedWatcher>,
831}
832
833impl HotReloadHandle {
834 /// Register a change handler after `start_watching` has been
835 /// called.
836 ///
837 /// Semantics match [`HotReloadConfig::on_change`]. Useful when
838 /// the consumer of the handle is a different component from
839 /// whoever called `start_watching` — pass the handle around and
840 /// let each component install its own handler.
841 pub fn on_change<F>(&self, handler: F) -> Subscription
842 where
843 F: Fn(&ConfigChangeEvent) + Send + Sync + 'static,
844 {
845 let id = self.handlers.register(Arc::new(handler));
846 Subscription {
847 list: Arc::downgrade(&self.handlers),
848 id,
849 }
850 }
851
852 /// Stop the background watching thread.
853 ///
854 /// # Errors
855 ///
856 /// Returns an error if the background thread panicked.
857 pub fn stop(mut self) -> Result<()> {
858 self.stop.store(true, Ordering::Relaxed);
859 if let Some(handle) = self.handle.take() {
860 handle
861 .join()
862 .map_err(|_| Error::concurrency("Failed to join background thread".to_string()))?;
863 }
864 Ok(())
865 }
866}
867
868impl Drop for HotReloadHandle {
869 fn drop(&mut self) {
870 self.stop.store(true, Ordering::Relaxed);
871 if let Some(handle) = self.handle.take() {
872 let _ = handle.join();
873 }
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use std::fs::File;
881 use std::io::Write;
882 use std::sync::atomic::AtomicUsize;
883 use tempfile::TempDir;
884
885 /// Helper: write a CONF body to `path` and `fsync` it so the
886 /// kernel surfaces the modification event before we proceed.
887 fn write_conf(path: &Path, body: &str) {
888 let mut f = File::create(path).unwrap();
889 f.write_all(body.as_bytes()).unwrap();
890 f.flush().unwrap();
891 f.sync_all().unwrap();
892 }
893
894 #[test]
895 fn test_hot_reload_basic() {
896 let temp_dir = TempDir::new().unwrap();
897 let config_path = temp_dir.path().join("test.conf");
898 write_conf(&config_path, "key=value1\n");
899
900 let mut hot_config = HotReloadConfig::from_file(&config_path).unwrap();
901 {
902 let config = hot_config.config();
903 let config_read = config.read().unwrap();
904 assert_eq!(
905 config_read.get("key").unwrap().as_string().unwrap(),
906 "value1"
907 );
908 }
909
910 // Sleep past filesystem mtime resolution before re-writing.
911 thread::sleep(Duration::from_millis(10));
912 write_conf(&config_path, "key=value2\n");
913
914 let reloaded = hot_config.reload().unwrap();
915 assert!(reloaded);
916
917 {
918 let config = hot_config.config();
919 let config_read = config.read().unwrap();
920 assert_eq!(
921 config_read.get("key").unwrap().as_string().unwrap(),
922 "value2"
923 );
924 }
925 }
926
927 #[test]
928 #[allow(deprecated)]
929 fn test_hot_reload_notifications_deprecated_bridge() {
930 // Tests that the deprecated `with_change_notifications` bridge
931 // still produces events through the new dispatch path.
932 let temp_dir = TempDir::new().unwrap();
933 let config_path = temp_dir.path().join("test.conf");
934 write_conf(&config_path, "key=value1\n");
935
936 let (mut hot_config, receiver) = HotReloadConfig::from_file(&config_path)
937 .unwrap()
938 .with_change_notifications();
939
940 thread::sleep(Duration::from_millis(10));
941 write_conf(&config_path, "key=value2\n");
942 hot_config.reload().unwrap();
943
944 let event = receiver.try_recv().unwrap();
945 match event {
946 ConfigChangeEvent::Reloaded { path, .. } => assert_eq!(path, config_path),
947 _ => panic!("Expected Reloaded event"),
948 }
949 }
950
951 #[test]
952 fn test_on_change_single_handler() {
953 let temp_dir = TempDir::new().unwrap();
954 let config_path = temp_dir.path().join("test.conf");
955 write_conf(&config_path, "key=value1\n");
956
957 let mut hot = HotReloadConfig::from_file(&config_path).unwrap();
958 let counter = Arc::new(AtomicUsize::new(0));
959 let c = Arc::clone(&counter);
960 let _sub = hot.on_change(move |event| {
961 if matches!(event, ConfigChangeEvent::Reloaded { .. }) {
962 c.fetch_add(1, Ordering::Relaxed);
963 }
964 });
965
966 thread::sleep(Duration::from_millis(10));
967 write_conf(&config_path, "key=value2\n");
968 hot.reload().unwrap();
969
970 assert_eq!(counter.load(Ordering::Relaxed), 1);
971 }
972
973 #[test]
974 fn test_on_change_multiple_handlers_fire_in_order() {
975 let temp_dir = TempDir::new().unwrap();
976 let config_path = temp_dir.path().join("test.conf");
977 write_conf(&config_path, "key=value1\n");
978
979 let mut hot = HotReloadConfig::from_file(&config_path).unwrap();
980 let order: Arc<std::sync::Mutex<Vec<u8>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
981
982 let o1 = Arc::clone(&order);
983 let _s1 = hot.on_change(move |_e| o1.lock().unwrap().push(1));
984 let o2 = Arc::clone(&order);
985 let _s2 = hot.on_change(move |_e| o2.lock().unwrap().push(2));
986 let o3 = Arc::clone(&order);
987 let _s3 = hot.on_change(move |_e| o3.lock().unwrap().push(3));
988
989 thread::sleep(Duration::from_millis(10));
990 write_conf(&config_path, "key=value2\n");
991 hot.reload().unwrap();
992
993 // Three handlers see the Reloaded event in registration order.
994 // (Each `reload` produces exactly one Reloaded event.)
995 let final_order = order.lock().unwrap().clone();
996 assert_eq!(final_order, vec![1u8, 2, 3]);
997 }
998
999 #[test]
1000 fn test_on_change_drop_unregisters() {
1001 let temp_dir = TempDir::new().unwrap();
1002 let config_path = temp_dir.path().join("test.conf");
1003 write_conf(&config_path, "key=value1\n");
1004
1005 let mut hot = HotReloadConfig::from_file(&config_path).unwrap();
1006 let counter = Arc::new(AtomicUsize::new(0));
1007 let c = Arc::clone(&counter);
1008 let sub = hot.on_change(move |_e| {
1009 c.fetch_add(1, Ordering::Relaxed);
1010 });
1011
1012 // First reload: handler fires.
1013 thread::sleep(Duration::from_millis(10));
1014 write_conf(&config_path, "key=value2\n");
1015 hot.reload().unwrap();
1016 assert_eq!(counter.load(Ordering::Relaxed), 1);
1017
1018 // Drop the subscription — handler is unregistered.
1019 drop(sub);
1020
1021 // Second reload: handler does NOT fire.
1022 thread::sleep(Duration::from_millis(10));
1023 write_conf(&config_path, "key=value3\n");
1024 hot.reload().unwrap();
1025 assert_eq!(counter.load(Ordering::Relaxed), 1);
1026 }
1027
1028 #[test]
1029 fn test_on_change_panic_isolation() {
1030 // A handler that panics must NOT prevent subsequent handlers
1031 // from running and must NOT poison the watcher thread.
1032 let temp_dir = TempDir::new().unwrap();
1033 let config_path = temp_dir.path().join("test.conf");
1034 write_conf(&config_path, "key=value1\n");
1035
1036 let mut hot = HotReloadConfig::from_file(&config_path).unwrap();
1037 let after_panic = Arc::new(AtomicUsize::new(0));
1038
1039 let _s_panic = hot.on_change(|_e| {
1040 panic!("handler-side panic; should be swallowed");
1041 });
1042 let after = Arc::clone(&after_panic);
1043 let _s_after = hot.on_change(move |_e| {
1044 after.fetch_add(1, Ordering::Relaxed);
1045 });
1046
1047 thread::sleep(Duration::from_millis(10));
1048 write_conf(&config_path, "key=value2\n");
1049 hot.reload().unwrap();
1050
1051 // The second handler ran despite the first panicking.
1052 assert_eq!(after_panic.load(Ordering::Relaxed), 1);
1053 }
1054
1055 #[test]
1056 fn test_on_change_forget_keeps_handler_alive() {
1057 let temp_dir = TempDir::new().unwrap();
1058 let config_path = temp_dir.path().join("test.conf");
1059 write_conf(&config_path, "key=value1\n");
1060
1061 let mut hot = HotReloadConfig::from_file(&config_path).unwrap();
1062 let counter = Arc::new(AtomicUsize::new(0));
1063 let c = Arc::clone(&counter);
1064
1065 // forget() — handler is now process-lifetime (or, more
1066 // precisely, lifetime of the HotReloadConfig's HandlerList).
1067 hot.on_change(move |_e| {
1068 c.fetch_add(1, Ordering::Relaxed);
1069 })
1070 .forget();
1071
1072 thread::sleep(Duration::from_millis(10));
1073 write_conf(&config_path, "key=value2\n");
1074 hot.reload().unwrap();
1075 assert_eq!(counter.load(Ordering::Relaxed), 1);
1076
1077 thread::sleep(Duration::from_millis(10));
1078 write_conf(&config_path, "key=value3\n");
1079 hot.reload().unwrap();
1080 assert_eq!(counter.load(Ordering::Relaxed), 2);
1081 }
1082
1083 #[test]
1084 fn test_handle_on_change_after_start_watching() {
1085 // Verify that `HotReloadHandle::on_change` works post-
1086 // start_watching — handlers registered via the handle see
1087 // events the same as handlers registered before.
1088 let temp_dir = TempDir::new().unwrap();
1089 let config_path = temp_dir.path().join("test.conf");
1090 write_conf(&config_path, "key=value1\n");
1091
1092 let hot = HotReloadConfig::from_file(&config_path)
1093 .unwrap()
1094 .with_poll_interval(Duration::from_millis(50))
1095 .with_debounce(Duration::from_millis(25));
1096
1097 let handle = hot.start_watching();
1098
1099 let counter = Arc::new(AtomicUsize::new(0));
1100 let c = Arc::clone(&counter);
1101 let _sub = handle.on_change(move |_e| {
1102 c.fetch_add(1, Ordering::Relaxed);
1103 });
1104
1105 thread::sleep(Duration::from_millis(150));
1106 write_conf(&config_path, "key=value2\n");
1107 thread::sleep(Duration::from_millis(500));
1108
1109 assert!(
1110 counter.load(Ordering::Relaxed) >= 1,
1111 "handle.on_change handler never fired"
1112 );
1113
1114 handle.stop().unwrap();
1115 }
1116
1117 #[test]
1118 fn test_automatic_watching() {
1119 let temp_dir = TempDir::new().unwrap();
1120 let config_path = temp_dir.path().join("test.conf");
1121 write_conf(&config_path, "key=value1\n");
1122
1123 let counter = Arc::new(AtomicUsize::new(0));
1124 let hot = HotReloadConfig::from_file(&config_path)
1125 .unwrap()
1126 .with_poll_interval(Duration::from_millis(50))
1127 .with_debounce(Duration::from_millis(25));
1128
1129 let c = Arc::clone(&counter);
1130 let _sub = hot.on_change(move |event| {
1131 if matches!(event, ConfigChangeEvent::Reloaded { .. }) {
1132 c.fetch_add(1, Ordering::Relaxed);
1133 }
1134 });
1135
1136 let config_ref = hot.config();
1137 let handle = hot.start_watching();
1138
1139 thread::sleep(Duration::from_millis(100));
1140 write_conf(&config_path, "key=value2\n");
1141 thread::sleep(Duration::from_millis(500));
1142
1143 {
1144 let config_read = config_ref.read().unwrap();
1145 assert_eq!(
1146 config_read.get("key").unwrap().as_string().unwrap(),
1147 "value2"
1148 );
1149 }
1150 assert!(
1151 counter.load(Ordering::Relaxed) >= 1,
1152 "expected at least one Reloaded event"
1153 );
1154
1155 handle.stop().unwrap();
1156 }
1157}