1use std::any::TypeId;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::mpsc;
7use std::sync::Mutex;
8use std::thread;
9use std::time::Duration;
10
11use notify::{Event, EventKind, RecursiveMode, Watcher};
12
13use crate::discovery;
14use crate::error::Error;
15#[cfg(not(feature = "tracing"))]
16use crate::log::info;
17use crate::log::warning;
18use crate::source::LoadSpec;
19
20#[derive(Debug, Clone)]
28pub struct Watched {
29 files: Vec<PathBuf>,
30 search_name: Option<String>,
31 search_directories: Vec<PathBuf>,
32}
33
34impl Watched {
35 #[must_use]
40 pub fn from_spec(spec: &LoadSpec<'_>) -> Self {
41 Self {
42 files: spec
43 .sources
44 .iter()
45 .filter_map(|source| source.path())
46 .map(PathBuf::from)
47 .collect(),
48 search_name: spec.search.as_ref().map(|search| search.name.to_owned()),
49 search_directories: spec
50 .search
51 .as_ref()
52 .map(|search| discovery::search_directories(search))
53 .unwrap_or_default(),
54 }
55 }
56}
57
58const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
64
65#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum WatchMode {
76 #[default]
78 Native,
79 Poll {
82 interval: Duration,
84 },
85}
86
87static STARTED: Mutex<BTreeMap<TypeId, &'static str>> = Mutex::new(BTreeMap::new());
92
93#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
104 to watch for the rest of the process"]
105pub struct WatchHandle {
106 key: TypeId,
107 name: &'static str,
108 watcher: Option<Backend>,
110}
111
112enum Backend {
114 Native(notify::RecommendedWatcher),
115 Poll(notify::PollWatcher),
116}
117
118impl WatchHandle {
119 pub fn detach(mut self) {
125 if let Some(watcher) = self.watcher.take() {
126 std::mem::forget(watcher);
127 }
128
129 std::mem::forget(self);
132 }
133
134 pub fn stop(self) {}
136
137 #[must_use]
139 pub fn name(&self) -> &'static str {
140 self.name
141 }
142}
143
144impl Drop for WatchHandle {
145 fn drop(&mut self) {
146 let Some(watcher) = self.watcher.take() else {
149 return;
150 };
151
152 drop(watcher);
156
157 STARTED
161 .lock()
162 .unwrap_or_else(std::sync::PoisonError::into_inner)
163 .remove(&self.key);
164 }
165}
166
167impl std::fmt::Debug for WatchHandle {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.debug_struct("WatchHandle")
170 .field("name", &self.name)
171 .finish_non_exhaustive()
174 }
175}
176
177pub fn spawn(
202 key: TypeId,
203 name: &'static str,
204 watched: Watched,
205 debounce: Duration,
206 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
207) -> std::io::Result<WatchHandle> {
208 spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
209}
210
211pub fn spawn_with(
218 key: TypeId,
219 name: &'static str,
220 watched: Watched,
221 debounce: Duration,
222 mode: WatchMode,
223 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
224) -> std::io::Result<WatchHandle> {
225 if STARTED
229 .lock()
230 .unwrap_or_else(std::sync::PoisonError::into_inner)
231 .insert(key, name)
232 .is_some()
233 {
234 return Err(std::io::Error::new(
235 std::io::ErrorKind::AlreadyExists,
236 format!(
237 "`{name}` is already being watched; hold on to the handle the \
238 first `start_watch()` returned, or drop it before starting \
239 another"
240 ),
241 ));
242 }
243
244 let registered = Registered { key, armed: true };
250
251 let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
252
253 let mut backend = match mode {
254 WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
255 WatchMode::Poll { interval } => Backend::Poll(
256 notify::PollWatcher::new(
257 sender,
258 notify::Config::default().with_poll_interval(interval),
259 )
260 .map_err(to_io)?,
261 ),
262 };
263
264 match &mut backend {
265 Backend::Native(watcher) => watch_directories(name, watcher, &watched)?,
266 Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
267 }
268
269 thread::Builder::new()
270 .name(format!("config-watch-{name}"))
271 .spawn(move || run(name, &watched, debounce, reload, &receiver))?;
272
273 registered.defuse();
276
277 Ok(WatchHandle {
278 key,
279 name,
280 watcher: Some(backend),
281 })
282}
283
284struct Registered {
289 key: TypeId,
290 armed: bool,
291}
292
293impl Registered {
294 fn defuse(mut self) {
296 self.armed = false;
297 }
298}
299
300impl Drop for Registered {
301 fn drop(&mut self) {
302 if self.armed {
303 STARTED
304 .lock()
305 .unwrap_or_else(std::sync::PoisonError::into_inner)
306 .remove(&self.key);
307 }
308 }
309}
310
311fn to_io(error: notify::Error) -> std::io::Error {
312 std::io::Error::new(std::io::ErrorKind::Other, error)
313}
314
315fn run(
316 name: &'static str,
317 watched: &Watched,
318 debounce: Duration,
319 reload: impl Fn() -> Result<Option<String>, Error>,
320 receiver: &mpsc::Receiver<notify::Result<Event>>,
321) {
322 loop {
323 match collect_relevant(receiver, name, debounce, watched) {
324 Collected::Dirty => {}
325 Collected::Disconnected => {
326 return;
328 }
329 }
330
331 thread::sleep(ATOMIC_SAVE_GRACE);
332
333 #[cfg(feature = "tracing")]
339 let _span = ::tracing::info_span!(target: "dynamic_config", "config_reload", config = name)
340 .entered();
341
342 let started = std::time::Instant::now();
343 let outcome = reload();
344 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
345
346 #[cfg(feature = "tracing")]
350 match &outcome {
351 Ok(summary) => ::tracing::info!(
352 target: "dynamic_config",
353 config = name,
354 outcome = "reloaded",
355 duration_ms,
356 summary = summary.as_deref().unwrap_or(""),
357 "{name}: reloaded in {duration_ms}ms"
358 ),
359 Err(error) => ::tracing::warn!(
360 target: "dynamic_config",
361 config = name,
362 outcome = "failed",
363 duration_ms,
364 error = %error,
365 "{name}: reload failed in {duration_ms}ms, keeping the previous snapshot"
366 ),
367 }
368
369 #[cfg(not(feature = "tracing"))]
370 match outcome {
371 Ok(Some(summary)) => info!("{name}: reloaded in {duration_ms}ms, {summary}"),
372 Ok(None) => info!("{name}: reloaded in {duration_ms}ms"),
373 Err(error) => warning!(
374 "{name}: reload failed after {duration_ms}ms, keeping the previous snapshot: \
375 {error}"
376 ),
377 }
378 }
379}
380
381enum Collected {
383 Dirty,
385 Disconnected,
387}
388
389fn watch_directories(
399 name: &'static str,
400 watcher: &mut impl Watcher,
401 watched: &Watched,
402) -> std::io::Result<()> {
403 let mut directories = Vec::<PathBuf>::new();
404
405 {
406 let mut push = |directory: PathBuf| {
407 if !directories.contains(&directory) {
408 directories.push(directory);
409 }
410 };
411
412 for file in &watched.files {
413 push(
414 file.parent()
415 .filter(|parent| !parent.as_os_str().is_empty())
416 .unwrap_or_else(|| Path::new("."))
417 .to_path_buf(),
418 );
419 }
420
421 for directory in &watched.search_directories {
424 push(directory.clone());
425 }
426 }
427
428 let mut watched = 0usize;
429 let mut last_error = None;
430
431 for directory in &directories {
432 match watcher.watch(directory, RecursiveMode::NonRecursive) {
433 Ok(()) => watched += 1,
434 Err(error) => {
435 warning!("{name}: could not watch {}: {error}", directory.display());
436 last_error = Some(error);
437 }
438 }
439 }
440
441 if watched == 0 {
442 return Err(last_error.map_or_else(
443 || {
444 std::io::Error::new(
445 std::io::ErrorKind::NotFound,
446 format!("{name}: no configuration file to watch"),
447 )
448 },
449 to_io,
450 ));
451 }
452
453 Ok(())
454}
455
456fn collect_relevant(
473 receiver: &mpsc::Receiver<notify::Result<Event>>,
474 name: &'static str,
475 debounce: Duration,
476 watched: &Watched,
477) -> Collected {
478 loop {
480 match receiver.recv() {
481 Ok(Ok(event)) if is_relevant(&event, watched) => break,
482 Ok(Ok(_)) => {}
483 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
484 Err(mpsc::RecvError) => return Collected::Disconnected,
485 }
486 }
487
488 let deadline = std::time::Instant::now() + debounce.saturating_mul(4);
490 let mut quiet_until = std::time::Instant::now() + debounce;
491
492 loop {
493 let now = std::time::Instant::now();
494 let target = quiet_until.min(deadline);
496
497 if now >= target {
498 return Collected::Dirty;
499 }
500
501 match receiver.recv_timeout(target - now) {
502 Ok(Ok(event)) if is_relevant(&event, watched) => {
506 quiet_until = std::time::Instant::now() + debounce;
507 }
508 Ok(Ok(_)) => {}
509 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
510 Err(mpsc::RecvTimeoutError::Timeout) => return Collected::Dirty,
511 Err(mpsc::RecvTimeoutError::Disconnected) => return Collected::Disconnected,
512 }
513 }
514}
515
516fn is_relevant(event: &Event, watched: &Watched) -> bool {
523 matches!(
524 event.kind,
525 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
526 ) && event.paths.iter().any(|changed| is_ours(changed, watched))
527}
528
529fn is_ours(changed: &Path, watched: &Watched) -> bool {
530 let explicit = watched.files.iter().any(|configured| {
531 changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
532 });
533
534 if explicit {
535 return true;
536 }
537
538 if watched
542 .search_name
543 .as_deref()
544 .is_some_and(|name| discovery::is_candidate(changed, name))
545 {
546 return true;
547 }
548
549 is_mount_marker(changed, watched)
550}
551
552fn is_mount_marker(changed: &Path, watched: &Watched) -> bool {
562 let is_marker = changed
563 .file_name()
564 .and_then(|name| name.to_str())
565 .is_some_and(|name| name.starts_with(".."));
566
567 if !is_marker {
568 return false;
569 }
570
571 let Some(directory) = changed.parent() else {
572 return false;
573 };
574
575 let mut parents = watched.files.iter().filter_map(|file| file.parent());
576
577 if parents.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
578 return true;
579 }
580
581 watched
582 .search_directories
583 .iter()
584 .any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use notify::event::{CreateKind, ModifyKind};
591
592 fn explicit_spec() -> LoadSpec<'static> {
594 static SOURCES: &[crate::Source<'static>] =
595 &[crate::Source::file("config.toml", crate::Format::Toml)];
596
597 LoadSpec::new("app", SOURCES)
598 }
599
600 fn event(kind: EventKind, path: &str) -> Event {
601 Event {
602 kind,
603 paths: vec![PathBuf::from(path)],
604 attrs: Default::default(),
605 }
606 }
607
608 #[test]
609 fn an_absolute_event_path_matches_a_relative_configured_path() {
610 let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/config.toml");
611
612 assert!(is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
613 }
614
615 #[test]
616 fn a_discovered_name_matches_even_though_no_file_was_listed() {
617 let paths: &'static [&'static str] = &["/srv/app"];
618 let spec = LoadSpec::new("db", &[]).with_search("config", paths);
619 let watched = Watched::from_spec(&spec);
620
621 let probe = event(EventKind::Create(CreateKind::File), "/srv/app/config.toml");
622 assert!(is_relevant(&probe, &watched));
623
624 let probe = event(EventKind::Create(CreateKind::File), "/srv/app/other.toml");
625 assert!(!is_relevant(&probe, &watched));
626 }
627
628 #[test]
629 fn an_unrelated_file_in_the_same_directory_is_ignored() {
630 let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/notes.txt");
631
632 assert!(!is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
633 }
634
635 #[test]
636 fn access_events_do_not_trigger_a_reload() {
637 let probe = event(
638 EventKind::Access(notify::event::AccessKind::Read),
639 "/srv/app/config.toml",
640 );
641
642 assert!(!is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
643 }
644
645 #[test]
646 fn a_duplicate_spawn_is_an_error_and_frees_nothing() {
647 struct DuplicateMarker;
648
649 let spec = explicit_spec();
650 let key = TypeId::of::<DuplicateMarker>();
651
652 let first = spawn(
653 key,
654 "DuplicateTest",
655 Watched::from_spec(&spec),
656 Duration::from_millis(10),
657 || Ok(None),
658 )
659 .expect("the first spawn should start a watcher");
660
661 let spec = explicit_spec();
664 let error = spawn(
665 key,
666 "DuplicateTest",
667 Watched::from_spec(&spec),
668 Duration::from_millis(10),
669 || Ok(None),
670 )
671 .expect_err("a second watcher for the same type must be refused");
672
673 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
674 assert!(error.to_string().contains("DuplicateTest"), "{error}");
675 assert!(
676 STARTED.lock().unwrap().contains_key(&key),
677 "the refusal must not free the first watcher's registration"
678 );
679
680 drop(first);
681 assert!(
682 !STARTED.lock().unwrap().contains_key(&key),
683 "dropping the real handle frees the registration"
684 );
685
686 let spec = explicit_spec();
688 let again = spawn(
689 key,
690 "DuplicateTest",
691 Watched::from_spec(&spec),
692 Duration::from_millis(10),
693 || Ok(None),
694 )
695 .expect("after the drop, watching can restart");
696 drop(again);
697 }
698
699 #[test]
704 fn a_failed_spawn_frees_its_registration_for_a_retry() {
705 struct FailedSpawnMarker;
706
707 let key = TypeId::of::<FailedSpawnMarker>();
708
709 static BAD: [crate::Source<'static>; 1] = [crate::Source::file(
710 "/nonexistent-dynamic-config-test-dir/config.toml",
711 crate::Format::Toml,
712 )];
713 let bad = LoadSpec::new("db", &BAD);
714
715 let _ = spawn(
716 key,
717 "FailedSpawnTest",
718 Watched::from_spec(&bad),
719 Duration::from_millis(10),
720 || Ok(None),
721 )
722 .expect_err("no directory to watch means the spawn fails");
723
724 assert!(
725 !STARTED.lock().unwrap().contains_key(&key),
726 "a failed spawn must not keep its registration"
727 );
728
729 let handle = spawn(
731 key,
732 "FailedSpawnTest",
733 Watched::from_spec(&explicit_spec()),
734 Duration::from_millis(10),
735 || Ok(None),
736 )
737 .expect("the retry should start a watcher");
738
739 drop(handle);
740 assert!(
741 !STARTED.lock().unwrap().contains_key(&key),
742 "and dropping it frees the registration"
743 );
744 }
745
746 #[test]
747 fn creation_and_removal_both_count_as_changes() {
748 for kind in [
749 EventKind::Create(CreateKind::File),
750 EventKind::Remove(notify::event::RemoveKind::File),
751 ] {
752 let probe = event(kind, "config.toml");
753
754 assert!(
755 is_relevant(&probe, &Watched::from_spec(&explicit_spec())),
756 "{kind:?}"
757 );
758 }
759 }
760}