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//! - **`mpsc` change notifications** preserving the
21//!   `ConfigChangeEvent` surface from earlier releases.
22//! - **Polling fallback** (always available, used as the default when
23//!   the `hot-reload` feature is disabled, or available as an opt-in
24//!   on top of event-driven watching for environments where the kernel
25//!   APIs are known-broken — network filesystems, some container
26//!   layers).
27
28use crate::config::Config;
29use crate::error::{Error, Result};
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::mpsc::{self, Receiver, Sender};
33use std::sync::{Arc, RwLock};
34use std::thread;
35use std::time::{Duration, SystemTime};
36
37/// Configuration change event types.
38///
39/// **Stability:** `ConfigChangeEvent` is `#[non_exhaustive]` so the
40/// v1.x SemVer contract can add new variants (e.g. `Renamed`,
41/// `PermissionDenied`) in MINOR releases without breaking user code.
42/// Callers must use a wildcard arm when pattern-matching.
43#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub enum ConfigChangeEvent {
46    /// Configuration successfully reloaded
47    Reloaded {
48        /// Path to the configuration file that was reloaded
49        path: PathBuf,
50        /// Timestamp when the reload completed
51        timestamp: SystemTime,
52    },
53    /// Configuration reload failed
54    ReloadFailed {
55        /// Path to the configuration file that failed to reload
56        path: PathBuf,
57        /// Error message describing what went wrong
58        error: String,
59        /// Timestamp when the error occurred
60        timestamp: SystemTime,
61    },
62    /// Configuration file was modified
63    FileModified {
64        /// Path to the configuration file that was modified
65        path: PathBuf,
66        /// Timestamp when the modification was detected
67        timestamp: SystemTime,
68    },
69    /// Configuration file was deleted
70    FileDeleted {
71        /// Path to the configuration file that was deleted
72        path: PathBuf,
73        /// Timestamp when the deletion was detected
74        timestamp: SystemTime,
75    },
76}
77
78/// Default debounce window applied to file-change events before
79/// triggering a reload. Sized to cover the editor "save via atomic
80/// rename" pattern (where a single save fires multiple kernel events
81/// within ~10–50 ms).
82const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(100);
83
84/// Hot-reloadable configuration container.
85///
86/// Construct with [`HotReloadConfig::from_file`], then either drive
87/// reloads manually with [`HotReloadConfig::reload`] or hand off to a
88/// background watcher with [`HotReloadConfig::start_watching`].
89///
90/// Configurable knobs (all consuming-builder style, intended for
91/// fluent construction):
92///
93/// - [`HotReloadConfig::with_change_notifications`] — receive
94///   [`ConfigChangeEvent`]s on an `mpsc` channel.
95/// - [`HotReloadConfig::with_debounce`] — adjust the debounce window
96///   (default 100 ms).
97/// - [`HotReloadConfig::with_poll_interval`] — set the polling
98///   interval. Used directly when the `hot-reload` feature is off;
99///   used as the watchdog interval when the feature is on.
100/// - [`HotReloadConfig::with_polling_fallback`] — opt into a
101///   parallel polling thread *in addition to* the event-driven
102///   watcher, for environments where the kernel watcher is known
103///   unreliable.
104pub struct HotReloadConfig {
105    /// Current configuration (thread-safe)
106    current: Arc<RwLock<Config>>,
107    /// File path being watched
108    file_path: PathBuf,
109    /// Last known modification time
110    last_modified: SystemTime,
111    /// Event sender for notifications
112    event_sender: Option<Sender<ConfigChangeEvent>>,
113    /// Polling interval — used as primary cadence when the
114    /// `hot-reload` feature is off, or as the watchdog interval when
115    /// the feature is on and `polling_fallback_enabled` is set.
116    poll_interval: Duration,
117    /// Debounce window applied to clustered file-change events.
118    debounce: Duration,
119    /// Whether to run the polling watchdog *in addition to* the
120    /// event-driven watcher. Useful on network filesystems.
121    polling_fallback_enabled: bool,
122}
123
124impl HotReloadConfig {
125    /// Create a new hot-reloadable configuration from a file.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the file cannot be read, parsed, or stat'd
130    /// for its modification time.
131    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
132        let path = path.as_ref().to_path_buf();
133        let config = Config::from_file(&path)?;
134
135        let last_modified = std::fs::metadata(&path)
136            .map_err(|e| Error::io(path.display().to_string(), e))?
137            .modified()
138            .map_err(|e| Error::io(path.display().to_string(), e))?;
139
140        Ok(Self {
141            current: Arc::new(RwLock::new(config)),
142            file_path: path,
143            last_modified,
144            event_sender: None,
145            poll_interval: Duration::from_secs(1),
146            debounce: DEFAULT_DEBOUNCE,
147            polling_fallback_enabled: false,
148        })
149    }
150
151    /// Set the polling interval for file change detection.
152    ///
153    /// When the `hot-reload` feature is enabled (the default in v0.9.6+),
154    /// the primary watcher is event-driven and this interval is only
155    /// consulted as the watchdog cadence if `with_polling_fallback`
156    /// has been called.
157    ///
158    /// When the `hot-reload` feature is disabled, this is the actual
159    /// polling cadence of the background thread.
160    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
161        self.poll_interval = interval;
162        self
163    }
164
165    /// Override the debounce window applied to clustered file-change
166    /// events.
167    ///
168    /// Editors that save via "write-to-tmp + atomic-rename" generate
169    /// multiple kernel events for a single user save. The debounce
170    /// collapses any burst within this window to a single reload.
171    /// Default: 100 ms.
172    pub fn with_debounce(mut self, debounce: Duration) -> Self {
173        self.debounce = debounce;
174        self
175    }
176
177    /// Opt into running a polling watchdog *in addition to* the
178    /// event-driven watcher.
179    ///
180    /// Network filesystems (SMB, NFS), some container overlay
181    /// filesystems, and a handful of edge-case kernel configurations
182    /// drop or delay events that `notify` would normally surface.
183    /// Enabling the polling fallback re-derives changes from periodic
184    /// `stat(2)` calls on the watched path, at the
185    /// [`HotReloadConfig::with_poll_interval`] cadence.
186    ///
187    /// Has no effect (and costs nothing) when the `hot-reload` Cargo
188    /// feature is disabled — the watcher is already polling in that
189    /// configuration.
190    pub fn with_polling_fallback(mut self) -> Self {
191        self.polling_fallback_enabled = true;
192        self
193    }
194
195    /// Enable change notifications.
196    ///
197    /// Returns the configured [`HotReloadConfig`] together with a
198    /// [`Receiver`] that will deliver [`ConfigChangeEvent`]s as the
199    /// watcher observes them.
200    pub fn with_change_notifications(mut self) -> (Self, Receiver<ConfigChangeEvent>) {
201        let (sender, receiver) = mpsc::channel();
202        self.event_sender = Some(sender);
203        (self, receiver)
204    }
205
206    /// Get a thread-safe reference to the current configuration.
207    pub fn config(&self) -> Arc<RwLock<Config>> {
208        Arc::clone(&self.current)
209    }
210
211    /// Get a freshly-reparsed snapshot of the configuration file as
212    /// it exists on disk *right now*.
213    ///
214    /// This is distinct from reading the current `Arc<RwLock<Config>>`
215    /// — it bypasses the watcher and re-reads the file. Useful for
216    /// "what would I see if I reloaded now" inspection.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the file cannot be read or parsed.
221    pub fn snapshot(&self) -> Result<Config> {
222        Config::from_file(&self.file_path)
223    }
224
225    /// Manually trigger a reload check.
226    ///
227    /// Re-stats the file, compares mtime against the last-known
228    /// modification time, and re-parses if newer. Sends a
229    /// [`ConfigChangeEvent::Reloaded`] or
230    /// [`ConfigChangeEvent::ReloadFailed`] notification if change
231    /// notifications are enabled.
232    ///
233    /// Returns `Ok(true)` if a reload was performed, `Ok(false)` if
234    /// the file was unchanged since the last check.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if the file cannot be stat'd, read, or parsed.
239    pub fn reload(&mut self) -> Result<bool> {
240        let metadata = std::fs::metadata(&self.file_path)
241            .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
242
243        let modified = metadata
244            .modified()
245            .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
246
247        if modified <= self.last_modified {
248            return Ok(false);
249        }
250
251        match Config::from_file(&self.file_path) {
252            Ok(new_config) => {
253                {
254                    let mut config = self.current.write().map_err(|_| {
255                        Error::concurrency("Failed to acquire write lock".to_string())
256                    })?;
257                    *config = new_config;
258                }
259                self.last_modified = modified;
260
261                if let Some(sender) = &self.event_sender {
262                    let _ = sender.send(ConfigChangeEvent::Reloaded {
263                        path: self.file_path.clone(),
264                        timestamp: SystemTime::now(),
265                    });
266                }
267                Ok(true)
268            }
269            Err(e) => {
270                if let Some(sender) = &self.event_sender {
271                    let _ = sender.send(ConfigChangeEvent::ReloadFailed {
272                        path: self.file_path.clone(),
273                        error: e.to_string(),
274                        timestamp: SystemTime::now(),
275                    });
276                }
277                Err(e)
278            }
279        }
280    }
281
282    /// Start automatic hot reloading in a background thread.
283    ///
284    /// With the `hot-reload` Cargo feature enabled (the default in
285    /// v0.9.6+), the background worker registers a
286    /// `notify::RecommendedWatcher` on the file's parent directory
287    /// and reacts to kernel events. Otherwise it falls back to a
288    /// `poll_interval`-cadence polling thread (the v0.9.5 behavior).
289    pub fn start_watching(self) -> HotReloadHandle {
290        #[cfg(feature = "hot-reload")]
291        {
292            self.start_watching_event_driven()
293        }
294        #[cfg(not(feature = "hot-reload"))]
295        {
296            self.start_watching_polling()
297        }
298    }
299
300    /// Get the file path being watched.
301    pub fn file_path(&self) -> &Path {
302        &self.file_path
303    }
304
305    /// Get the last modification time.
306    pub fn last_modified(&self) -> SystemTime {
307        self.last_modified
308    }
309
310    // -----------------------------------------------------------------
311    // Polling watcher — used as the primary watcher when the
312    // `hot-reload` Cargo feature is disabled. (When the feature is on,
313    // the event-driven path covers all environments where the kernel
314    // event API works; opt-in polling-as-watchdog alongside the
315    // event-driven watcher is reserved for a follow-up release.)
316    // -----------------------------------------------------------------
317
318    #[cfg(not(feature = "hot-reload"))]
319    fn start_watching_polling(self) -> HotReloadHandle {
320        let stop = Arc::new(AtomicBool::new(false));
321        let stop_clone = Arc::clone(&stop);
322
323        let current = Arc::clone(&self.current);
324        let file_path = self.file_path.clone();
325        let event_sender = self.event_sender.clone();
326        let poll_interval = self.poll_interval;
327        let mut last_modified = self.last_modified;
328
329        let handle = thread::spawn(move || {
330            while !stop_clone.load(Ordering::Relaxed) {
331                if let Ok(metadata) = std::fs::metadata(&file_path) {
332                    if let Ok(modified) = metadata.modified() {
333                        if modified > last_modified {
334                            if let Some(sender) = &event_sender {
335                                let _ = sender.send(ConfigChangeEvent::FileModified {
336                                    path: file_path.clone(),
337                                    timestamp: SystemTime::now(),
338                                });
339                            }
340
341                            match Config::from_file(&file_path) {
342                                Ok(new_config) => {
343                                    if let Ok(mut config) = current.write() {
344                                        *config = new_config;
345                                        last_modified = modified;
346
347                                        if let Some(sender) = &event_sender {
348                                            let _ = sender.send(ConfigChangeEvent::Reloaded {
349                                                path: file_path.clone(),
350                                                timestamp: SystemTime::now(),
351                                            });
352                                        }
353                                    }
354                                }
355                                Err(e) => {
356                                    if let Some(sender) = &event_sender {
357                                        let _ = sender.send(ConfigChangeEvent::ReloadFailed {
358                                            path: file_path.clone(),
359                                            error: e.to_string(),
360                                            timestamp: SystemTime::now(),
361                                        });
362                                    }
363                                }
364                            }
365                        }
366                    }
367                }
368                thread::sleep(poll_interval);
369            }
370        });
371
372        HotReloadHandle {
373            handle: Some(handle),
374            stop,
375        }
376    }
377
378    // -----------------------------------------------------------------
379    // Event-driven watcher — gated on the `hot-reload` feature.
380    // -----------------------------------------------------------------
381
382    #[cfg(feature = "hot-reload")]
383    fn start_watching_event_driven(self) -> HotReloadHandle {
384        use notify::{Event, RecursiveMode, Watcher};
385
386        let stop = Arc::new(AtomicBool::new(false));
387        let current = Arc::clone(&self.current);
388        let file_path = self.file_path.clone();
389        let event_sender = self.event_sender.clone();
390        let debounce = self.debounce;
391        let poll_interval = self.poll_interval;
392        let polling_fallback = self.polling_fallback_enabled;
393        let initial_modified = self.last_modified;
394
395        // Channel from the notify callback to the reload worker.
396        let (tx, rx) = mpsc::channel::<Event>();
397
398        // Build the watcher. We watch the *parent* directory (not the
399        // file itself) so that atomic-rename saves — where the file's
400        // inode is replaced — still surface as events on our target.
401        let watcher_dir = file_path
402            .parent()
403            .map(Path::to_path_buf)
404            .unwrap_or_else(|| PathBuf::from("."));
405
406        let watcher_result = notify::RecommendedWatcher::new(
407            move |res: notify::Result<Event>| {
408                if let Ok(event) = res {
409                    let _ = tx.send(event);
410                }
411            },
412            notify::Config::default(),
413        )
414        .and_then(|mut w| {
415            w.watch(&watcher_dir, RecursiveMode::NonRecursive)?;
416            Ok(w)
417        });
418
419        let watcher = match watcher_result {
420            Ok(w) => Some(w),
421            Err(e) => {
422                // Watcher construction failed — likely the platform
423                // event API is unavailable (rare). Surface a
424                // `ReloadFailed` so the caller knows, then fall
425                // through and spawn the polling worker as the only
426                // safety net.
427                if let Some(sender) = &event_sender {
428                    let _ = sender.send(ConfigChangeEvent::ReloadFailed {
429                        path: file_path.clone(),
430                        error: format!(
431                            "notify watcher construction failed: {e}; falling back to polling"
432                        ),
433                        timestamp: SystemTime::now(),
434                    });
435                }
436                None
437            }
438        };
439
440        // Reload worker — consumes events from the notify callback,
441        // debounces, and re-parses on change.
442        let target_file = file_path.clone();
443        let event_sender_for_worker = event_sender.clone();
444        let current_for_worker = Arc::clone(&current);
445        let stop_for_worker = Arc::clone(&stop);
446        let mut last_modified_seen = initial_modified;
447
448        let handle = thread::spawn(move || {
449            while !stop_for_worker.load(Ordering::Relaxed) {
450                // Block up to `poll_interval` for the next event so
451                // the stop flag is observed promptly even when the
452                // file is quiet. (`recv_timeout` is the only stdlib
453                // mpsc primitive that respects both the channel and
454                // a deadline.)
455                let first = match rx.recv_timeout(poll_interval) {
456                    Ok(ev) => Some(ev),
457                    Err(mpsc::RecvTimeoutError::Timeout) => None,
458                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
459                };
460
461                // If we got an event, drain the channel for the
462                // debounce window so the burst from a single save
463                // collapses to one reload.
464                let mut relevant = false;
465                if let Some(ev) = first {
466                    relevant |= event_targets_path(&ev, &target_file);
467
468                    let deadline = std::time::Instant::now() + debounce;
469                    loop {
470                        let remaining =
471                            deadline.saturating_duration_since(std::time::Instant::now());
472                        if remaining.is_zero() {
473                            break;
474                        }
475                        match rx.recv_timeout(remaining) {
476                            Ok(ev) => relevant |= event_targets_path(&ev, &target_file),
477                            Err(_) => break,
478                        }
479                    }
480                } else if !polling_fallback {
481                    continue;
482                }
483
484                // Path resolution: did the target file actually change?
485                let metadata = std::fs::metadata(&target_file);
486                match metadata {
487                    Ok(meta) => {
488                        let modified = meta.modified().ok();
489                        let is_newer = match modified {
490                            Some(m) => m > last_modified_seen,
491                            None => true,
492                        };
493                        if !relevant && !is_newer {
494                            continue;
495                        }
496
497                        if let Some(sender) = &event_sender_for_worker {
498                            let _ = sender.send(ConfigChangeEvent::FileModified {
499                                path: target_file.clone(),
500                                timestamp: SystemTime::now(),
501                            });
502                        }
503
504                        match Config::from_file(&target_file) {
505                            Ok(new_config) => {
506                                if let Ok(mut cfg) = current_for_worker.write() {
507                                    *cfg = new_config;
508                                    if let Some(m) = modified {
509                                        last_modified_seen = m;
510                                    }
511                                    if let Some(sender) = &event_sender_for_worker {
512                                        let _ = sender.send(ConfigChangeEvent::Reloaded {
513                                            path: target_file.clone(),
514                                            timestamp: SystemTime::now(),
515                                        });
516                                    }
517                                }
518                            }
519                            Err(e) => {
520                                if let Some(sender) = &event_sender_for_worker {
521                                    let _ = sender.send(ConfigChangeEvent::ReloadFailed {
522                                        path: target_file.clone(),
523                                        error: e.to_string(),
524                                        timestamp: SystemTime::now(),
525                                    });
526                                }
527                            }
528                        }
529                    }
530                    Err(_) => {
531                        // File missing — likely deleted between
532                        // events. Emit FileDeleted but keep the
533                        // last-known-good config in place.
534                        if let Some(sender) = &event_sender_for_worker {
535                            let _ = sender.send(ConfigChangeEvent::FileDeleted {
536                                path: target_file.clone(),
537                                timestamp: SystemTime::now(),
538                            });
539                        }
540                    }
541                }
542            }
543        });
544
545        HotReloadHandle {
546            handle: Some(handle),
547            stop,
548            _watcher: watcher,
549        }
550    }
551}
552
553/// Helper: does the `notify::Event` reference our watched file?
554///
555/// When watching a directory non-recursively, every event carries the
556/// list of paths it applies to. Filtering on the exact file path keeps
557/// us from reacting to unrelated sibling files in the same directory.
558#[cfg(feature = "hot-reload")]
559fn event_targets_path(event: &notify::Event, target: &Path) -> bool {
560    use notify::EventKind;
561    if !matches!(
562        event.kind,
563        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) | EventKind::Any
564    ) {
565        return false;
566    }
567    // Canonical-form comparison helps with macOS symlink/realpath
568    // shenanigans. Fall back to direct equality when canonicalize
569    // can't resolve (e.g. file was just deleted).
570    let target_canon = std::fs::canonicalize(target).ok();
571    event.paths.iter().any(|p| {
572        if p == target {
573            return true;
574        }
575        if let (Some(tc), Ok(pc)) = (&target_canon, std::fs::canonicalize(p)) {
576            return *tc == pc;
577        }
578        false
579    })
580}
581
582/// Handle for controlling hot reload background thread.
583pub struct HotReloadHandle {
584    handle: Option<thread::JoinHandle<()>>,
585    stop: Arc<AtomicBool>,
586    /// Watcher kept alive for the duration of the watch. Dropping
587    /// the watcher tears down the kernel registration. Only carried
588    /// when the `hot-reload` feature is on.
589    #[cfg(feature = "hot-reload")]
590    _watcher: Option<notify::RecommendedWatcher>,
591}
592
593impl HotReloadHandle {
594    /// Stop the background watching thread.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error if the background thread panicked.
599    pub fn stop(mut self) -> Result<()> {
600        self.stop.store(true, Ordering::Relaxed);
601        if let Some(handle) = self.handle.take() {
602            handle
603                .join()
604                .map_err(|_| Error::concurrency("Failed to join background thread".to_string()))?;
605        }
606        Ok(())
607    }
608}
609
610impl Drop for HotReloadHandle {
611    fn drop(&mut self) {
612        self.stop.store(true, Ordering::Relaxed);
613        if let Some(handle) = self.handle.take() {
614            let _ = handle.join();
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use std::fs::File;
623    use std::io::Write;
624    use tempfile::TempDir;
625
626    /// Helper: write a CONF body to `path` and `fsync` it so the
627    /// kernel surfaces the modification event before we proceed.
628    fn write_conf(path: &Path, body: &str) {
629        let mut f = File::create(path).unwrap();
630        f.write_all(body.as_bytes()).unwrap();
631        f.flush().unwrap();
632        f.sync_all().unwrap();
633    }
634
635    #[test]
636    fn test_hot_reload_basic() {
637        let temp_dir = TempDir::new().unwrap();
638        let config_path = temp_dir.path().join("test.conf");
639        write_conf(&config_path, "key=value1\n");
640
641        let mut hot_config = HotReloadConfig::from_file(&config_path).unwrap();
642        {
643            let config = hot_config.config();
644            let config_read = config.read().unwrap();
645            assert_eq!(
646                config_read.get("key").unwrap().as_string().unwrap(),
647                "value1"
648            );
649        }
650
651        // Sleep past filesystem mtime resolution before re-writing.
652        thread::sleep(Duration::from_millis(10));
653        write_conf(&config_path, "key=value2\n");
654
655        let reloaded = hot_config.reload().unwrap();
656        assert!(reloaded);
657
658        {
659            let config = hot_config.config();
660            let config_read = config.read().unwrap();
661            assert_eq!(
662                config_read.get("key").unwrap().as_string().unwrap(),
663                "value2"
664            );
665        }
666    }
667
668    #[test]
669    fn test_hot_reload_notifications() {
670        let temp_dir = TempDir::new().unwrap();
671        let config_path = temp_dir.path().join("test.conf");
672        write_conf(&config_path, "key=value1\n");
673
674        let (mut hot_config, receiver) = HotReloadConfig::from_file(&config_path)
675            .unwrap()
676            .with_change_notifications();
677
678        thread::sleep(Duration::from_millis(10));
679        write_conf(&config_path, "key=value2\n");
680        hot_config.reload().unwrap();
681
682        let event = receiver.try_recv().unwrap();
683        match event {
684            ConfigChangeEvent::Reloaded { path, .. } => assert_eq!(path, config_path),
685            _ => panic!("Expected Reloaded event"),
686        }
687    }
688
689    #[test]
690    fn test_automatic_watching() {
691        let temp_dir = TempDir::new().unwrap();
692        let config_path = temp_dir.path().join("test.conf");
693        write_conf(&config_path, "key=value1\n");
694
695        let (hot_config, receiver) = HotReloadConfig::from_file(&config_path)
696            .unwrap()
697            .with_poll_interval(Duration::from_millis(50))
698            .with_debounce(Duration::from_millis(25))
699            .with_change_notifications();
700
701        let config_ref = hot_config.config();
702        let handle = hot_config.start_watching();
703
704        // Give the watcher a moment to register.
705        thread::sleep(Duration::from_millis(100));
706        write_conf(&config_path, "key=value2\n");
707
708        // Wait long enough for the event-driven path (a few ms) OR
709        // the polling fallback (50ms+) to react and re-parse.
710        thread::sleep(Duration::from_millis(500));
711
712        {
713            let config_read = config_ref.read().unwrap();
714            assert_eq!(
715                config_read.get("key").unwrap().as_string().unwrap(),
716                "value2"
717            );
718        }
719
720        let mut events = Vec::new();
721        while let Ok(ev) = receiver.try_recv() {
722            events.push(ev);
723        }
724        assert!(
725            !events.is_empty(),
726            "expected at least one ConfigChangeEvent"
727        );
728        let has_reloaded = events
729            .iter()
730            .any(|e| matches!(e, ConfigChangeEvent::Reloaded { .. }));
731        assert!(has_reloaded, "expected at least one Reloaded event");
732
733        handle.stop().unwrap();
734    }
735}