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;
15use crate::log::{info, warning};
16use crate::source::LoadSpec;
17
18const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
24
25#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum WatchMode {
36 #[default]
38 Native,
39 Poll {
42 interval: Duration,
44 },
45}
46
47static STARTED: Mutex<BTreeMap<TypeId, &'static str>> = Mutex::new(BTreeMap::new());
52
53#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
64 to watch for the rest of the process"]
65pub struct WatchHandle {
66 key: TypeId,
67 name: &'static str,
68 watcher: Option<Backend>,
70}
71
72enum Backend {
74 Native(notify::RecommendedWatcher),
75 Poll(notify::PollWatcher),
76}
77
78impl WatchHandle {
79 pub fn detach(mut self) {
85 if let Some(watcher) = self.watcher.take() {
86 std::mem::forget(watcher);
87 }
88
89 std::mem::forget(self);
92 }
93
94 pub fn stop(self) {}
96
97 #[must_use]
99 pub fn name(&self) -> &'static str {
100 self.name
101 }
102}
103
104impl Drop for WatchHandle {
105 fn drop(&mut self) {
106 let Some(watcher) = self.watcher.take() else {
109 return;
110 };
111
112 drop(watcher);
116
117 STARTED
121 .lock()
122 .unwrap_or_else(std::sync::PoisonError::into_inner)
123 .remove(&self.key);
124 }
125}
126
127impl std::fmt::Debug for WatchHandle {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("WatchHandle")
130 .field("name", &self.name)
131 .finish_non_exhaustive()
134 }
135}
136
137pub fn spawn(
162 key: TypeId,
163 name: &'static str,
164 spec: LoadSpec<'static>,
165 debounce: Duration,
166 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
167) -> std::io::Result<WatchHandle> {
168 spawn_with(key, name, spec, debounce, WatchMode::default(), reload)
169}
170
171pub fn spawn_with(
178 key: TypeId,
179 name: &'static str,
180 spec: LoadSpec<'static>,
181 debounce: Duration,
182 mode: WatchMode,
183 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
184) -> std::io::Result<WatchHandle> {
185 if STARTED
189 .lock()
190 .unwrap_or_else(std::sync::PoisonError::into_inner)
191 .insert(key, name)
192 .is_some()
193 {
194 return Err(std::io::Error::new(
195 std::io::ErrorKind::AlreadyExists,
196 format!(
197 "`{name}` is already being watched; hold on to the handle the \
198 first `start_watch()` returned, or drop it before starting \
199 another"
200 ),
201 ));
202 }
203
204 let registered = Registered { key, armed: true };
210
211 let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
212
213 let mut backend = match mode {
214 WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
215 WatchMode::Poll { interval } => Backend::Poll(
216 notify::PollWatcher::new(
217 sender,
218 notify::Config::default().with_poll_interval(interval),
219 )
220 .map_err(to_io)?,
221 ),
222 };
223
224 match &mut backend {
225 Backend::Native(watcher) => watch_directories(name, watcher, &spec)?,
226 Backend::Poll(watcher) => watch_directories(name, watcher, &spec)?,
227 }
228
229 thread::Builder::new()
230 .name(format!("config-watch-{name}"))
231 .spawn(move || run(name, spec, debounce, reload, &receiver))?;
232
233 registered.defuse();
236
237 Ok(WatchHandle {
238 key,
239 name,
240 watcher: Some(backend),
241 })
242}
243
244struct Registered {
249 key: TypeId,
250 armed: bool,
251}
252
253impl Registered {
254 fn defuse(mut self) {
256 self.armed = false;
257 }
258}
259
260impl Drop for Registered {
261 fn drop(&mut self) {
262 if self.armed {
263 STARTED
264 .lock()
265 .unwrap_or_else(std::sync::PoisonError::into_inner)
266 .remove(&self.key);
267 }
268 }
269}
270
271fn to_io(error: notify::Error) -> std::io::Error {
272 std::io::Error::new(std::io::ErrorKind::Other, error)
273}
274
275fn run(
276 name: &'static str,
277 spec: LoadSpec<'static>,
278 debounce: Duration,
279 reload: impl Fn() -> Result<Option<String>, Error>,
280 receiver: &mpsc::Receiver<notify::Result<Event>>,
281) {
282 loop {
283 match collect_relevant(receiver, name, debounce, &spec) {
284 Collected::Dirty => {}
285 Collected::Disconnected => {
286 return;
288 }
289 }
290
291 thread::sleep(ATOMIC_SAVE_GRACE);
292
293 match reload() {
294 Ok(Some(summary)) => info!("{name}: reloaded, {summary}"),
295 Ok(None) => info!("{name}: reloaded"),
296 Err(error) => warning!("{name}: reload failed, keeping the previous snapshot: {error}"),
297 }
298 }
299}
300
301enum Collected {
303 Dirty,
305 Disconnected,
307}
308
309fn watch_directories(
319 name: &'static str,
320 watcher: &mut impl Watcher,
321 spec: &LoadSpec<'static>,
322) -> std::io::Result<()> {
323 let mut directories = Vec::<PathBuf>::new();
324
325 {
326 let mut push = |directory: PathBuf| {
327 if !directories.contains(&directory) {
328 directories.push(directory);
329 }
330 };
331
332 for file in spec.sources.iter().filter_map(|source| source.path()) {
333 push(
334 Path::new(file)
335 .parent()
336 .filter(|parent| !parent.as_os_str().is_empty())
337 .unwrap_or_else(|| Path::new("."))
338 .to_path_buf(),
339 );
340 }
341
342 if let Some(search) = &spec.search {
345 for directory in discovery::search_directories(search) {
346 push(directory);
347 }
348 }
349 }
350
351 let mut watched = 0usize;
352 let mut last_error = None;
353
354 for directory in &directories {
355 match watcher.watch(directory, RecursiveMode::NonRecursive) {
356 Ok(()) => watched += 1,
357 Err(error) => {
358 warning!("{name}: could not watch {}: {error}", directory.display());
359 last_error = Some(error);
360 }
361 }
362 }
363
364 if watched == 0 {
365 return Err(last_error.map_or_else(
366 || {
367 std::io::Error::new(
368 std::io::ErrorKind::NotFound,
369 format!("{name}: no configuration file to watch"),
370 )
371 },
372 to_io,
373 ));
374 }
375
376 Ok(())
377}
378
379fn collect_relevant(
396 receiver: &mpsc::Receiver<notify::Result<Event>>,
397 name: &'static str,
398 debounce: Duration,
399 spec: &LoadSpec<'static>,
400) -> Collected {
401 loop {
403 match receiver.recv() {
404 Ok(Ok(event)) if is_relevant(&event, spec) => break,
405 Ok(Ok(_)) => {}
406 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
407 Err(mpsc::RecvError) => return Collected::Disconnected,
408 }
409 }
410
411 let deadline = std::time::Instant::now() + debounce.saturating_mul(4);
413 let mut quiet_until = std::time::Instant::now() + debounce;
414
415 loop {
416 let now = std::time::Instant::now();
417 let target = quiet_until.min(deadline);
419
420 if now >= target {
421 return Collected::Dirty;
422 }
423
424 match receiver.recv_timeout(target - now) {
425 Ok(Ok(event)) if is_relevant(&event, spec) => {
429 quiet_until = std::time::Instant::now() + debounce;
430 }
431 Ok(Ok(_)) => {}
432 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
433 Err(mpsc::RecvTimeoutError::Timeout) => return Collected::Dirty,
434 Err(mpsc::RecvTimeoutError::Disconnected) => return Collected::Disconnected,
435 }
436 }
437}
438
439fn is_relevant(event: &Event, spec: &LoadSpec<'static>) -> bool {
446 matches!(
447 event.kind,
448 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
449 ) && event.paths.iter().any(|changed| is_ours(changed, spec))
450}
451
452fn is_ours(changed: &Path, spec: &LoadSpec<'static>) -> bool {
453 let explicit = spec
454 .sources
455 .iter()
456 .filter_map(|source| source.path())
457 .any(|file| {
458 let configured = Path::new(file);
459
460 changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
461 });
462
463 if explicit {
464 return true;
465 }
466
467 if spec
471 .search
472 .as_ref()
473 .is_some_and(|search| discovery::is_candidate(changed, search.name))
474 {
475 return true;
476 }
477
478 is_mount_marker(changed, spec)
479}
480
481fn is_mount_marker(changed: &Path, spec: &LoadSpec<'static>) -> bool {
491 let is_marker = changed
492 .file_name()
493 .and_then(|name| name.to_str())
494 .is_some_and(|name| name.starts_with(".."));
495
496 if !is_marker {
497 return false;
498 }
499
500 let Some(directory) = changed.parent() else {
501 return false;
502 };
503
504 let mut watched = spec
505 .sources
506 .iter()
507 .filter_map(|source| source.path())
508 .filter_map(|file| Path::new(file).parent());
509
510 if watched.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
511 return true;
512 }
513
514 spec.search.as_ref().is_some_and(|search| {
515 discovery::search_directories(search)
516 .iter()
517 .any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
518 })
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use notify::event::{CreateKind, ModifyKind};
525
526 fn explicit_spec() -> LoadSpec<'static> {
528 static SOURCES: &[crate::Source<'static>] =
529 &[crate::Source::file("config.toml", crate::Format::Toml)];
530
531 LoadSpec::new("app", SOURCES)
532 }
533
534 fn event(kind: EventKind, path: &str) -> Event {
535 Event {
536 kind,
537 paths: vec![PathBuf::from(path)],
538 attrs: Default::default(),
539 }
540 }
541
542 #[test]
543 fn an_absolute_event_path_matches_a_relative_configured_path() {
544 let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/config.toml");
545
546 assert!(is_relevant(&probe, &explicit_spec()));
547 }
548
549 #[test]
550 fn a_discovered_name_matches_even_though_no_file_was_listed() {
551 let paths: &'static [&'static str] = &["/srv/app"];
552 let spec = LoadSpec::new("db", &[]).with_search("config", paths);
553
554 let probe = event(EventKind::Create(CreateKind::File), "/srv/app/config.toml");
555 assert!(is_relevant(&probe, &spec));
556
557 let probe = event(EventKind::Create(CreateKind::File), "/srv/app/other.toml");
558 assert!(!is_relevant(&probe, &spec));
559 }
560
561 #[test]
562 fn an_unrelated_file_in_the_same_directory_is_ignored() {
563 let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/notes.txt");
564
565 assert!(!is_relevant(&probe, &explicit_spec()));
566 }
567
568 #[test]
569 fn access_events_do_not_trigger_a_reload() {
570 let probe = event(
571 EventKind::Access(notify::event::AccessKind::Read),
572 "/srv/app/config.toml",
573 );
574
575 assert!(!is_relevant(&probe, &explicit_spec()));
576 }
577
578 #[test]
579 fn a_duplicate_spawn_is_an_error_and_frees_nothing() {
580 struct DuplicateMarker;
581
582 let spec = explicit_spec();
583 let key = TypeId::of::<DuplicateMarker>();
584
585 let first = spawn(
586 key,
587 "DuplicateTest",
588 spec,
589 Duration::from_millis(10),
590 || Ok(None),
591 )
592 .expect("the first spawn should start a watcher");
593
594 let spec = explicit_spec();
597 let error = spawn(
598 key,
599 "DuplicateTest",
600 spec,
601 Duration::from_millis(10),
602 || Ok(None),
603 )
604 .expect_err("a second watcher for the same type must be refused");
605
606 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
607 assert!(error.to_string().contains("DuplicateTest"), "{error}");
608 assert!(
609 STARTED.lock().unwrap().contains_key(&key),
610 "the refusal must not free the first watcher's registration"
611 );
612
613 drop(first);
614 assert!(
615 !STARTED.lock().unwrap().contains_key(&key),
616 "dropping the real handle frees the registration"
617 );
618
619 let spec = explicit_spec();
621 let again = spawn(
622 key,
623 "DuplicateTest",
624 spec,
625 Duration::from_millis(10),
626 || Ok(None),
627 )
628 .expect("after the drop, watching can restart");
629 drop(again);
630 }
631
632 #[test]
637 fn a_failed_spawn_frees_its_registration_for_a_retry() {
638 struct FailedSpawnMarker;
639
640 let key = TypeId::of::<FailedSpawnMarker>();
641
642 static BAD: [crate::Source<'static>; 1] = [crate::Source::file(
643 "/nonexistent-dynamic-config-test-dir/config.toml",
644 crate::Format::Toml,
645 )];
646 let bad = LoadSpec::new("db", &BAD);
647
648 let _ = spawn(
649 key,
650 "FailedSpawnTest",
651 bad,
652 Duration::from_millis(10),
653 || Ok(None),
654 )
655 .expect_err("no directory to watch means the spawn fails");
656
657 assert!(
658 !STARTED.lock().unwrap().contains_key(&key),
659 "a failed spawn must not keep its registration"
660 );
661
662 let handle = spawn(
664 key,
665 "FailedSpawnTest",
666 explicit_spec(),
667 Duration::from_millis(10),
668 || Ok(None),
669 )
670 .expect("the retry should start a watcher");
671
672 drop(handle);
673 assert!(
674 !STARTED.lock().unwrap().contains_key(&key),
675 "and dropping it frees the registration"
676 );
677 }
678
679 #[test]
680 fn creation_and_removal_both_count_as_changes() {
681 for kind in [
682 EventKind::Create(CreateKind::File),
683 EventKind::Remove(notify::event::RemoveKind::File),
684 ] {
685 let probe = event(kind, "config.toml");
686
687 assert!(is_relevant(&probe, &explicit_spec()), "{kind:?}");
688 }
689 }
690}