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;
#[cfg(not(feature = "tracing"))]
use crate::log::info;
use crate::log::warning;
use crate::source::LoadSpec;
#[derive(Debug, Clone)]
pub struct Watched {
files: Vec<PathBuf>,
search_name: Option<String>,
search_directories: Vec<PathBuf>,
}
impl Watched {
#[must_use]
pub fn from_spec(spec: &LoadSpec<'_>) -> Self {
Self {
files: spec
.sources
.iter()
.filter_map(|source| source.path())
.map(PathBuf::from)
.collect(),
search_name: spec.search.as_ref().map(|search| search.name.to_owned()),
search_directories: spec
.search
.as_ref()
.map(|search| discovery::search_directories(search))
.unwrap_or_default(),
}
}
}
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,
watched: Watched,
debounce: Duration,
reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
) -> std::io::Result<WatchHandle> {
spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
}
pub fn spawn_with(
key: TypeId,
name: &'static str,
watched: Watched,
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, &watched)?,
Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
}
thread::Builder::new()
.name(format!("config-watch-{name}"))
.spawn(move || run(name, &watched, 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,
watched: &Watched,
debounce: Duration,
reload: impl Fn() -> Result<Option<String>, Error>,
receiver: &mpsc::Receiver<notify::Result<Event>>,
) {
loop {
match collect_relevant(receiver, name, debounce, watched) {
Collected::Dirty => {}
Collected::Disconnected => {
return;
}
}
thread::sleep(ATOMIC_SAVE_GRACE);
#[cfg(feature = "tracing")]
let _span = ::tracing::info_span!(target: "dynamic_config", "config_reload", config = name)
.entered();
let started = std::time::Instant::now();
let outcome = reload();
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
#[cfg(feature = "tracing")]
match &outcome {
Ok(summary) => ::tracing::info!(
target: "dynamic_config",
config = name,
outcome = "reloaded",
duration_ms,
summary = summary.as_deref().unwrap_or(""),
"{name}: reloaded in {duration_ms}ms"
),
Err(error) => ::tracing::warn!(
target: "dynamic_config",
config = name,
outcome = "failed",
duration_ms,
error = %error,
"{name}: reload failed in {duration_ms}ms, keeping the previous snapshot"
),
}
#[cfg(not(feature = "tracing"))]
match outcome {
Ok(Some(summary)) => info!("{name}: reloaded in {duration_ms}ms, {summary}"),
Ok(None) => info!("{name}: reloaded in {duration_ms}ms"),
Err(error) => warning!(
"{name}: reload failed after {duration_ms}ms, keeping the previous snapshot: \
{error}"
),
}
}
}
enum Collected {
Dirty,
Disconnected,
}
fn watch_directories(
name: &'static str,
watcher: &mut impl Watcher,
watched: &Watched,
) -> std::io::Result<()> {
let mut directories = Vec::<PathBuf>::new();
{
let mut push = |directory: PathBuf| {
if !directories.contains(&directory) {
directories.push(directory);
}
};
for file in &watched.files {
push(
file.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
);
}
for directory in &watched.search_directories {
push(directory.clone());
}
}
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,
watched: &Watched,
) -> Collected {
loop {
match receiver.recv() {
Ok(Ok(event)) if is_relevant(&event, watched) => 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, watched) => {
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, watched: &Watched) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|changed| is_ours(changed, watched))
}
fn is_ours(changed: &Path, watched: &Watched) -> bool {
let explicit = watched.files.iter().any(|configured| {
changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
});
if explicit {
return true;
}
if watched
.search_name
.as_deref()
.is_some_and(|name| discovery::is_candidate(changed, name))
{
return true;
}
is_mount_marker(changed, watched)
}
fn is_mount_marker(changed: &Path, watched: &Watched) -> 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 parents = watched.files.iter().filter_map(|file| file.parent());
if parents.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
return true;
}
watched
.search_directories
.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, &Watched::from_spec(&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 watched = Watched::from_spec(&spec);
let probe = event(EventKind::Create(CreateKind::File), "/srv/app/config.toml");
assert!(is_relevant(&probe, &watched));
let probe = event(EventKind::Create(CreateKind::File), "/srv/app/other.toml");
assert!(!is_relevant(&probe, &watched));
}
#[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, &Watched::from_spec(&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, &Watched::from_spec(&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",
Watched::from_spec(&spec),
Duration::from_millis(10),
|| Ok(None),
)
.expect("the first spawn should start a watcher");
let spec = explicit_spec();
let error = spawn(
key,
"DuplicateTest",
Watched::from_spec(&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",
Watched::from_spec(&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",
Watched::from_spec(&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",
Watched::from_spec(&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, &Watched::from_spec(&explicit_spec())),
"{kind:?}"
);
}
}
}