use std::collections::BTreeSet;
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<BTreeSet<&'static str>> = Mutex::new(BTreeSet::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 {
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.name);
}
}
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(
name: &'static str,
spec: LoadSpec<'static>,
debounce: Duration,
reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
) -> std::io::Result<WatchHandle> {
spawn_with(name, spec, debounce, WatchMode::default(), reload)
}
pub fn spawn_with(
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(name)
{
return Ok(WatchHandle {
name,
watcher: None,
});
}
let registered = Registered { name, 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 {
name,
watcher: Some(backend),
})
}
struct Registered {
name: &'static str,
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.name);
}
}
}
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 {
let Some(batch) = collect_batch(receiver, name, debounce) else {
return;
};
if !touches_configured_file(&batch, &spec) {
continue;
}
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}"),
}
}
}
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_batch(
receiver: &mpsc::Receiver<notify::Result<Event>>,
name: &'static str,
debounce: Duration,
) -> Option<Vec<Event>> {
let mut batch = Vec::new();
loop {
match receiver.recv() {
Ok(Ok(event)) => {
batch.push(event);
break;
}
Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
Err(mpsc::RecvError) => return None,
}
}
loop {
match receiver.recv_timeout(debounce) {
Ok(Ok(event)) => batch.push(event),
Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
Err(mpsc::RecvTimeoutError::Timeout) => return Some(batch),
Err(mpsc::RecvTimeoutError::Disconnected) => return None,
}
}
}
fn touches_configured_file(batch: &[Event], spec: &LoadSpec<'static>) -> bool {
batch.iter().any(|event| {
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 batch = [event(
EventKind::Modify(ModifyKind::Any),
"/srv/app/config.toml",
)];
assert!(touches_configured_file(&batch, &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 batch = [event(
EventKind::Create(CreateKind::File),
"/srv/app/config.toml",
)];
assert!(touches_configured_file(&batch, &spec));
let batch = [event(
EventKind::Create(CreateKind::File),
"/srv/app/other.toml",
)];
assert!(!touches_configured_file(&batch, &spec));
}
#[test]
fn an_unrelated_file_in_the_same_directory_is_ignored() {
let batch = [event(
EventKind::Modify(ModifyKind::Any),
"/srv/app/notes.txt",
)];
assert!(!touches_configured_file(&batch, &explicit_spec()));
}
#[test]
fn access_events_do_not_trigger_a_reload() {
let batch = [event(
EventKind::Access(notify::event::AccessKind::Read),
"/srv/app/config.toml",
)];
assert!(!touches_configured_file(&batch, &explicit_spec()));
}
#[test]
fn a_duplicate_handle_owns_nothing_and_frees_nothing() {
let spec = explicit_spec();
let first = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
Ok(None)
})
.expect("the first spawn should start a watcher");
let second = spawn("DuplicateTest", spec, Duration::from_millis(10), || {
Ok(None)
})
.expect("the second spawn should be a no-op");
drop(second);
assert!(
STARTED.lock().unwrap().contains("DuplicateTest"),
"the running watcher should still hold its name"
);
drop(first);
assert!(
!STARTED.lock().unwrap().contains("DuplicateTest"),
"dropping the owning handle should free the name for a restart"
);
}
#[test]
fn a_failed_spawn_frees_its_name_for_a_retry() {
static BAD: &[crate::Source<'static>] = &[crate::Source::file(
"/nonexistent-dynamic-config-test-dir/config.toml",
crate::Format::Toml,
)];
let bad = LoadSpec::new("app", BAD);
assert!(
spawn("FailedSpawnTest", bad, Duration::from_millis(10), || Ok(
None
))
.is_err(),
"watching a directory that does not exist should fail"
);
assert!(
!STARTED.lock().unwrap().contains("FailedSpawnTest"),
"a failed spawn must not keep its name registered"
);
let handle = spawn(
"FailedSpawnTest",
explicit_spec(),
Duration::from_millis(10),
|| Ok(None),
)
.expect("the name is free, so the retry starts a watcher");
drop(handle);
assert!(
!STARTED.lock().unwrap().contains("FailedSpawnTest"),
"the retry owned a real watcher, whose drop frees the name"
);
}
#[test]
fn creation_and_removal_both_count_as_changes() {
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Remove(notify::event::RemoveKind::File),
] {
let batch = [event(kind, "config.toml")];
assert!(
touches_configured_file(&batch, &explicit_spec()),
"{kind:?}"
);
}
}
}