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(¤t);
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: ¬ify::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}