1use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use std::sync::mpsc;
6use std::sync::Mutex;
7use std::thread;
8use std::time::Duration;
9
10use notify::{Event, EventKind, RecursiveMode, Watcher};
11
12use crate::discovery;
13use crate::error::Error;
14use crate::log::{info, warning};
15use crate::source::LoadSpec;
16
17const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
23
24#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum WatchMode {
35 #[default]
37 Native,
38 Poll {
41 interval: Duration,
43 },
44}
45
46static STARTED: Mutex<BTreeSet<&'static str>> = Mutex::new(BTreeSet::new());
48
49#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
60 to watch for the rest of the process"]
61pub struct WatchHandle {
62 name: &'static str,
63 watcher: Option<Backend>,
65}
66
67enum Backend {
69 Native(notify::RecommendedWatcher),
70 Poll(notify::PollWatcher),
71}
72
73impl WatchHandle {
74 pub fn detach(mut self) {
80 if let Some(watcher) = self.watcher.take() {
81 std::mem::forget(watcher);
82 }
83
84 std::mem::forget(self);
86 }
87
88 pub fn stop(self) {}
90
91 #[must_use]
93 pub fn name(&self) -> &'static str {
94 self.name
95 }
96}
97
98impl Drop for WatchHandle {
99 fn drop(&mut self) {
100 let Some(watcher) = self.watcher.take() else {
104 return;
105 };
106
107 drop(watcher);
111
112 STARTED
116 .lock()
117 .unwrap_or_else(std::sync::PoisonError::into_inner)
118 .remove(self.name);
119 }
120}
121
122impl std::fmt::Debug for WatchHandle {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("WatchHandle")
125 .field("name", &self.name)
126 .finish_non_exhaustive()
129 }
130}
131
132pub fn spawn(
157 name: &'static str,
158 spec: LoadSpec<'static>,
159 debounce: Duration,
160 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
161) -> std::io::Result<WatchHandle> {
162 spawn_with(name, spec, debounce, WatchMode::default(), reload)
163}
164
165pub fn spawn_with(
172 name: &'static str,
173 spec: LoadSpec<'static>,
174 debounce: Duration,
175 mode: WatchMode,
176 reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
177) -> std::io::Result<WatchHandle> {
178 if !STARTED
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner)
181 .insert(name)
182 {
183 return Ok(WatchHandle {
184 name,
185 watcher: None,
186 });
187 }
188
189 let registered = Registered { name, armed: true };
195
196 let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
197
198 let mut backend = match mode {
199 WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
200 WatchMode::Poll { interval } => Backend::Poll(
201 notify::PollWatcher::new(
202 sender,
203 notify::Config::default().with_poll_interval(interval),
204 )
205 .map_err(to_io)?,
206 ),
207 };
208
209 match &mut backend {
210 Backend::Native(watcher) => watch_directories(name, watcher, &spec)?,
211 Backend::Poll(watcher) => watch_directories(name, watcher, &spec)?,
212 }
213
214 thread::Builder::new()
215 .name(format!("config-watch-{name}"))
216 .spawn(move || run(name, spec, debounce, reload, &receiver))?;
217
218 registered.defuse();
221
222 Ok(WatchHandle {
223 name,
224 watcher: Some(backend),
225 })
226}
227
228struct Registered {
233 name: &'static str,
234 armed: bool,
235}
236
237impl Registered {
238 fn defuse(mut self) {
240 self.armed = false;
241 }
242}
243
244impl Drop for Registered {
245 fn drop(&mut self) {
246 if self.armed {
247 STARTED
248 .lock()
249 .unwrap_or_else(std::sync::PoisonError::into_inner)
250 .remove(self.name);
251 }
252 }
253}
254
255fn to_io(error: notify::Error) -> std::io::Error {
256 std::io::Error::new(std::io::ErrorKind::Other, error)
257}
258
259fn run(
260 name: &'static str,
261 spec: LoadSpec<'static>,
262 debounce: Duration,
263 reload: impl Fn() -> Result<Option<String>, Error>,
264 receiver: &mpsc::Receiver<notify::Result<Event>>,
265) {
266 loop {
267 let Some(batch) = collect_batch(receiver, name, debounce) else {
268 return;
270 };
271
272 if !touches_configured_file(&batch, &spec) {
273 continue;
274 }
275
276 thread::sleep(ATOMIC_SAVE_GRACE);
277
278 match reload() {
279 Ok(Some(summary)) => info!("{name}: reloaded, {summary}"),
280 Ok(None) => info!("{name}: reloaded"),
281 Err(error) => warning!("{name}: reload failed, keeping the previous snapshot: {error}"),
282 }
283 }
284}
285
286fn watch_directories(
296 name: &'static str,
297 watcher: &mut impl Watcher,
298 spec: &LoadSpec<'static>,
299) -> std::io::Result<()> {
300 let mut directories = Vec::<PathBuf>::new();
301
302 {
303 let mut push = |directory: PathBuf| {
304 if !directories.contains(&directory) {
305 directories.push(directory);
306 }
307 };
308
309 for file in spec.sources.iter().filter_map(|source| source.path()) {
310 push(
311 Path::new(file)
312 .parent()
313 .filter(|parent| !parent.as_os_str().is_empty())
314 .unwrap_or_else(|| Path::new("."))
315 .to_path_buf(),
316 );
317 }
318
319 if let Some(search) = &spec.search {
322 for directory in discovery::search_directories(search) {
323 push(directory);
324 }
325 }
326 }
327
328 let mut watched = 0usize;
329 let mut last_error = None;
330
331 for directory in &directories {
332 match watcher.watch(directory, RecursiveMode::NonRecursive) {
333 Ok(()) => watched += 1,
334 Err(error) => {
335 warning!("{name}: could not watch {}: {error}", directory.display());
336 last_error = Some(error);
337 }
338 }
339 }
340
341 if watched == 0 {
342 return Err(last_error.map_or_else(
343 || {
344 std::io::Error::new(
345 std::io::ErrorKind::NotFound,
346 format!("{name}: no configuration file to watch"),
347 )
348 },
349 to_io,
350 ));
351 }
352
353 Ok(())
354}
355
356fn collect_batch(
363 receiver: &mpsc::Receiver<notify::Result<Event>>,
364 name: &'static str,
365 debounce: Duration,
366) -> Option<Vec<Event>> {
367 let mut batch = Vec::new();
368
369 loop {
370 match receiver.recv() {
371 Ok(Ok(event)) => {
372 batch.push(event);
373 break;
374 }
375 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
376 Err(mpsc::RecvError) => return None,
377 }
378 }
379
380 loop {
381 match receiver.recv_timeout(debounce) {
382 Ok(Ok(event)) => batch.push(event),
383 Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
384 Err(mpsc::RecvTimeoutError::Timeout) => return Some(batch),
385 Err(mpsc::RecvTimeoutError::Disconnected) => return None,
386 }
387 }
388}
389
390fn touches_configured_file(batch: &[Event], spec: &LoadSpec<'static>) -> bool {
397 batch.iter().any(|event| {
398 matches!(
399 event.kind,
400 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
401 ) && event.paths.iter().any(|changed| is_ours(changed, spec))
402 })
403}
404
405fn is_ours(changed: &Path, spec: &LoadSpec<'static>) -> bool {
406 let explicit = spec
407 .sources
408 .iter()
409 .filter_map(|source| source.path())
410 .any(|file| {
411 let configured = Path::new(file);
412
413 changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
414 });
415
416 if explicit {
417 return true;
418 }
419
420 if spec
424 .search
425 .as_ref()
426 .is_some_and(|search| discovery::is_candidate(changed, search.name))
427 {
428 return true;
429 }
430
431 is_mount_marker(changed, spec)
432}
433
434fn is_mount_marker(changed: &Path, spec: &LoadSpec<'static>) -> bool {
444 let is_marker = changed
445 .file_name()
446 .and_then(|name| name.to_str())
447 .is_some_and(|name| name.starts_with(".."));
448
449 if !is_marker {
450 return false;
451 }
452
453 let Some(directory) = changed.parent() else {
454 return false;
455 };
456
457 let mut watched = spec
458 .sources
459 .iter()
460 .filter_map(|source| source.path())
461 .filter_map(|file| Path::new(file).parent());
462
463 if watched.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
464 return true;
465 }
466
467 spec.search.as_ref().is_some_and(|search| {
468 discovery::search_directories(search)
469 .iter()
470 .any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
471 })
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use notify::event::{CreateKind, ModifyKind};
478
479 fn explicit_spec() -> LoadSpec<'static> {
481 static SOURCES: &[crate::Source<'static>] =
482 &[crate::Source::file("config.toml", crate::Format::Toml)];
483
484 LoadSpec::new("app", SOURCES)
485 }
486
487 fn event(kind: EventKind, path: &str) -> Event {
488 Event {
489 kind,
490 paths: vec![PathBuf::from(path)],
491 attrs: Default::default(),
492 }
493 }
494
495 #[test]
496 fn an_absolute_event_path_matches_a_relative_configured_path() {
497 let batch = [event(
498 EventKind::Modify(ModifyKind::Any),
499 "/srv/app/config.toml",
500 )];
501
502 assert!(touches_configured_file(&batch, &explicit_spec()));
503 }
504
505 #[test]
506 fn a_discovered_name_matches_even_though_no_file_was_listed() {
507 let paths: &'static [&'static str] = &["/srv/app"];
508 let spec = LoadSpec::new("db", &[]).with_search("config", paths);
509
510 let batch = [event(
511 EventKind::Create(CreateKind::File),
512 "/srv/app/config.toml",
513 )];
514 assert!(touches_configured_file(&batch, &spec));
515
516 let batch = [event(
517 EventKind::Create(CreateKind::File),
518 "/srv/app/other.toml",
519 )];
520 assert!(!touches_configured_file(&batch, &spec));
521 }
522
523 #[test]
524 fn an_unrelated_file_in_the_same_directory_is_ignored() {
525 let batch = [event(
526 EventKind::Modify(ModifyKind::Any),
527 "/srv/app/notes.txt",
528 )];
529
530 assert!(!touches_configured_file(&batch, &explicit_spec()));
531 }
532
533 #[test]
534 fn access_events_do_not_trigger_a_reload() {
535 let batch = [event(
536 EventKind::Access(notify::event::AccessKind::Read),
537 "/srv/app/config.toml",
538 )];
539
540 assert!(!touches_configured_file(&batch, &explicit_spec()));
541 }
542
543 #[test]
544 fn a_duplicate_handle_owns_nothing_and_frees_nothing() {
545 let spec = explicit_spec();
546
547 let first = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
548 Ok(None)
549 })
550 .expect("the first spawn should start a watcher");
551 let second = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
552 Ok(None)
553 })
554 .expect("the second spawn should be a no-op");
555
556 drop(second);
559 assert!(
560 STARTED.lock().unwrap().contains("DuplicateTest"),
561 "the running watcher should still hold its name"
562 );
563
564 drop(first);
565 assert!(
566 !STARTED.lock().unwrap().contains("DuplicateTest"),
567 "dropping the owning handle should free the name for a restart"
568 );
569 }
570
571 #[test]
576 fn a_failed_spawn_frees_its_name_for_a_retry() {
577 static BAD: &[crate::Source<'static>] = &[crate::Source::file(
578 "/nonexistent-dynamic-config-test-dir/config.toml",
579 crate::Format::Toml,
580 )];
581
582 let bad = LoadSpec::new("app", BAD);
583
584 assert!(
585 spawn("FailedSpawnTest", bad, Duration::from_millis(10), || Ok(
586 None
587 ))
588 .is_err(),
589 "watching a directory that does not exist should fail"
590 );
591 assert!(
592 !STARTED.lock().unwrap().contains("FailedSpawnTest"),
593 "a failed spawn must not keep its name registered"
594 );
595
596 let handle = spawn(
599 "FailedSpawnTest",
600 explicit_spec(),
601 Duration::from_millis(10),
602 || Ok(None),
603 )
604 .expect("the name is free, so the retry starts a watcher");
605
606 drop(handle);
607
608 assert!(
609 !STARTED.lock().unwrap().contains("FailedSpawnTest"),
610 "the retry owned a real watcher, whose drop frees the name"
611 );
612 }
613
614 #[test]
615 fn creation_and_removal_both_count_as_changes() {
616 for kind in [
617 EventKind::Create(CreateKind::File),
618 EventKind::Remove(notify::event::RemoveKind::File),
619 ] {
620 let batch = [event(kind, "config.toml")];
621
622 assert!(
623 touches_configured_file(&batch, &explicit_spec()),
624 "{kind:?}"
625 );
626 }
627 }
628}