use std::any::TypeId;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use crate::discovery;
use crate::error::Error;
use crate::log::{info, warning};
use crate::source::LoadSpec;
const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WatchMode {
#[default]
Native,
Poll {
interval: Duration,
},
}
static STARTED: Mutex<BTreeMap<TypeId, &'static str>> = Mutex::new(BTreeMap::new());
#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
to watch for the rest of the process"]
pub struct WatchHandle {
key: TypeId,
name: &'static str,
watcher: Option<Backend>,
}
enum Backend {
Native(notify::RecommendedWatcher),
Poll(notify::PollWatcher),
}
impl WatchHandle {
pub fn detach(mut self) {
if let Some(watcher) = self.watcher.take() {
std::mem::forget(watcher);
}
std::mem::forget(self);
}
pub fn stop(self) {}
#[must_use]
pub fn name(&self) -> &'static str {
self.name
}
}
impl Drop for WatchHandle {
fn drop(&mut self) {
let Some(watcher) = self.watcher.take() else {
return;
};
drop(watcher);
STARTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.key);
}
}
impl std::fmt::Debug for WatchHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WatchHandle")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
pub fn spawn(
key: TypeId,
name: &'static str,
spec: LoadSpec<'static>,
debounce: Duration,
reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
) -> std::io::Result<WatchHandle> {
spawn_with(key, name, spec, debounce, WatchMode::default(), reload)
}
pub fn spawn_with(
key: TypeId,
name: &'static str,
spec: LoadSpec<'static>,
debounce: Duration,
mode: WatchMode,
reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
) -> std::io::Result<WatchHandle> {
if STARTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, name)
.is_some()
{
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"`{name}` is already being watched; hold on to the handle the \
first `start_watch()` returned, or drop it before starting \
another"
),
));
}
let registered = Registered { key, armed: true };
let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
let mut backend = match mode {
WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
WatchMode::Poll { interval } => Backend::Poll(
notify::PollWatcher::new(
sender,
notify::Config::default().with_poll_interval(interval),
)
.map_err(to_io)?,
),
};
match &mut backend {
Backend::Native(watcher) => watch_directories(name, watcher, &spec)?,
Backend::Poll(watcher) => watch_directories(name, watcher, &spec)?,
}
thread::Builder::new()
.name(format!("config-watch-{name}"))
.spawn(move || run(name, spec, debounce, reload, &receiver))?;
registered.defuse();
Ok(WatchHandle {
key,
name,
watcher: Some(backend),
})
}
struct Registered {
key: TypeId,
armed: bool,
}
impl Registered {
fn defuse(mut self) {
self.armed = false;
}
}
impl Drop for Registered {
fn drop(&mut self) {
if self.armed {
STARTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.key);
}
}
}
fn to_io(error: notify::Error) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::Other, error)
}
fn run(
name: &'static str,
spec: LoadSpec<'static>,
debounce: Duration,
reload: impl Fn() -> Result<Option<String>, Error>,
receiver: &mpsc::Receiver<notify::Result<Event>>,
) {
loop {
match collect_relevant(receiver, name, debounce, &spec) {
Collected::Dirty => {}
Collected::Disconnected => {
return;
}
}
thread::sleep(ATOMIC_SAVE_GRACE);
match reload() {
Ok(Some(summary)) => info!("{name}: reloaded, {summary}"),
Ok(None) => info!("{name}: reloaded"),
Err(error) => warning!("{name}: reload failed, keeping the previous snapshot: {error}"),
}
}
}
enum Collected {
Dirty,
Disconnected,
}
fn watch_directories(
name: &'static str,
watcher: &mut impl Watcher,
spec: &LoadSpec<'static>,
) -> std::io::Result<()> {
let mut directories = Vec::<PathBuf>::new();
{
let mut push = |directory: PathBuf| {
if !directories.contains(&directory) {
directories.push(directory);
}
};
for file in spec.sources.iter().filter_map(|source| source.path()) {
push(
Path::new(file)
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
);
}
if let Some(search) = &spec.search {
for directory in discovery::search_directories(search) {
push(directory);
}
}
}
let mut watched = 0usize;
let mut last_error = None;
for directory in &directories {
match watcher.watch(directory, RecursiveMode::NonRecursive) {
Ok(()) => watched += 1,
Err(error) => {
warning!("{name}: could not watch {}: {error}", directory.display());
last_error = Some(error);
}
}
}
if watched == 0 {
return Err(last_error.map_or_else(
|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{name}: no configuration file to watch"),
)
},
to_io,
));
}
Ok(())
}
fn collect_relevant(
receiver: &mpsc::Receiver<notify::Result<Event>>,
name: &'static str,
debounce: Duration,
spec: &LoadSpec<'static>,
) -> Collected {
loop {
match receiver.recv() {
Ok(Ok(event)) if is_relevant(&event, spec) => break,
Ok(Ok(_)) => {}
Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
Err(mpsc::RecvError) => return Collected::Disconnected,
}
}
let deadline = std::time::Instant::now() + debounce.saturating_mul(4);
let mut quiet_until = std::time::Instant::now() + debounce;
loop {
let now = std::time::Instant::now();
let target = quiet_until.min(deadline);
if now >= target {
return Collected::Dirty;
}
match receiver.recv_timeout(target - now) {
Ok(Ok(event)) if is_relevant(&event, spec) => {
quiet_until = std::time::Instant::now() + debounce;
}
Ok(Ok(_)) => {}
Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
Err(mpsc::RecvTimeoutError::Timeout) => return Collected::Dirty,
Err(mpsc::RecvTimeoutError::Disconnected) => return Collected::Disconnected,
}
}
}
fn is_relevant(event: &Event, spec: &LoadSpec<'static>) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|changed| is_ours(changed, spec))
}
fn is_ours(changed: &Path, spec: &LoadSpec<'static>) -> bool {
let explicit = spec
.sources
.iter()
.filter_map(|source| source.path())
.any(|file| {
let configured = Path::new(file);
changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
});
if explicit {
return true;
}
if spec
.search
.as_ref()
.is_some_and(|search| discovery::is_candidate(changed, search.name))
{
return true;
}
is_mount_marker(changed, spec)
}
fn is_mount_marker(changed: &Path, spec: &LoadSpec<'static>) -> bool {
let is_marker = changed
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(".."));
if !is_marker {
return false;
}
let Some(directory) = changed.parent() else {
return false;
};
let mut watched = spec
.sources
.iter()
.filter_map(|source| source.path())
.filter_map(|file| Path::new(file).parent());
if watched.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
return true;
}
spec.search.as_ref().is_some_and(|search| {
discovery::search_directories(search)
.iter()
.any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
})
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind};
fn explicit_spec() -> LoadSpec<'static> {
static SOURCES: &[crate::Source<'static>] =
&[crate::Source::file("config.toml", crate::Format::Toml)];
LoadSpec::new("app", SOURCES)
}
fn event(kind: EventKind, path: &str) -> Event {
Event {
kind,
paths: vec![PathBuf::from(path)],
attrs: Default::default(),
}
}
#[test]
fn an_absolute_event_path_matches_a_relative_configured_path() {
let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/config.toml");
assert!(is_relevant(&probe, &explicit_spec()));
}
#[test]
fn a_discovered_name_matches_even_though_no_file_was_listed() {
let paths: &'static [&'static str] = &["/srv/app"];
let spec = LoadSpec::new("db", &[]).with_search("config", paths);
let probe = event(EventKind::Create(CreateKind::File), "/srv/app/config.toml");
assert!(is_relevant(&probe, &spec));
let probe = event(EventKind::Create(CreateKind::File), "/srv/app/other.toml");
assert!(!is_relevant(&probe, &spec));
}
#[test]
fn an_unrelated_file_in_the_same_directory_is_ignored() {
let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/notes.txt");
assert!(!is_relevant(&probe, &explicit_spec()));
}
#[test]
fn access_events_do_not_trigger_a_reload() {
let probe = event(
EventKind::Access(notify::event::AccessKind::Read),
"/srv/app/config.toml",
);
assert!(!is_relevant(&probe, &explicit_spec()));
}
#[test]
fn a_duplicate_spawn_is_an_error_and_frees_nothing() {
struct DuplicateMarker;
let spec = explicit_spec();
let key = TypeId::of::<DuplicateMarker>();
let first = spawn(
key,
"DuplicateTest",
spec,
Duration::from_millis(10),
|| Ok(None),
)
.expect("the first spawn should start a watcher");
let spec = explicit_spec();
let error = spawn(
key,
"DuplicateTest",
spec,
Duration::from_millis(10),
|| Ok(None),
)
.expect_err("a second watcher for the same type must be refused");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert!(error.to_string().contains("DuplicateTest"), "{error}");
assert!(
STARTED.lock().unwrap().contains_key(&key),
"the refusal must not free the first watcher's registration"
);
drop(first);
assert!(
!STARTED.lock().unwrap().contains_key(&key),
"dropping the real handle frees the registration"
);
let spec = explicit_spec();
let again = spawn(
key,
"DuplicateTest",
spec,
Duration::from_millis(10),
|| Ok(None),
)
.expect("after the drop, watching can restart");
drop(again);
}
#[test]
fn a_failed_spawn_frees_its_registration_for_a_retry() {
struct FailedSpawnMarker;
let key = TypeId::of::<FailedSpawnMarker>();
static BAD: [crate::Source<'static>; 1] = [crate::Source::file(
"/nonexistent-dynamic-config-test-dir/config.toml",
crate::Format::Toml,
)];
let bad = LoadSpec::new("db", &BAD);
let _ = spawn(
key,
"FailedSpawnTest",
bad,
Duration::from_millis(10),
|| Ok(None),
)
.expect_err("no directory to watch means the spawn fails");
assert!(
!STARTED.lock().unwrap().contains_key(&key),
"a failed spawn must not keep its registration"
);
let handle = spawn(
key,
"FailedSpawnTest",
explicit_spec(),
Duration::from_millis(10),
|| Ok(None),
)
.expect("the retry should start a watcher");
drop(handle);
assert!(
!STARTED.lock().unwrap().contains_key(&key),
"and dropping it frees the registration"
);
}
#[test]
fn creation_and_removal_both_count_as_changes() {
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Remove(notify::event::RemoveKind::File),
] {
let probe = event(kind, "config.toml");
assert!(is_relevant(&probe, &explicit_spec()), "{kind:?}");
}
}
}