Skip to main content

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(&current);
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: &notify::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}