use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::SyncSender;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use crate::error::Error;
use super::{Env, FILE_NAME, config_search_dirs};
pub(crate) struct FileWatch {
_watcher: RecommendedWatcher,
flags: LossFlags,
}
#[derive(Clone)]
pub(crate) struct LossFlags {
lost: Arc<AtomicBool>,
live_directories: Arc<AtomicUsize>,
}
impl LossFlags {
pub(crate) fn any_lost(&self) -> bool {
self.lost.load(Ordering::Acquire)
}
pub(crate) fn is_live(&self) -> bool {
self.live_directories.load(Ordering::Relaxed) > 0
}
}
impl FileWatch {
pub(crate) fn is_live(&self) -> bool {
self.flags.is_live()
}
pub(crate) fn any_lost(&self) -> bool {
self.flags.any_lost()
}
pub(crate) fn loss_flags(&self) -> LossFlags {
self.flags.clone()
}
}
impl std::fmt::Debug for FileWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileWatch").finish_non_exhaustive()
}
}
#[cfg(test)]
impl LossFlags {
pub(crate) fn lose_one_for_test(&self) {
self.live_directories.fetch_sub(1, Ordering::Relaxed);
self.lost.store(true, Ordering::Release);
}
}
#[cfg(test)]
impl FileWatch {
pub(crate) fn with_flags_for_test(lost: bool, live_directories: usize) -> Self {
FileWatch {
_watcher: notify::recommended_watcher(|_: notify::Result<notify::Event>| {})
.expect("a watcher that watches nothing still needs an inotify handle"),
flags: LossFlags {
lost: Arc::new(AtomicBool::new(lost)),
live_directories: Arc::new(AtomicUsize::new(live_directories)),
},
}
}
}
fn watch_targets(dirs: Vec<PathBuf>) -> (Vec<PathBuf>, bool) {
let leading_dropped = dirs.first().is_some_and(|dir| !dir.is_dir());
let kept: Vec<PathBuf> = dirs.into_iter().filter(|dir| dir.is_dir()).collect();
let leading_dropped = leading_dropped && !kept.is_empty();
(kept, leading_dropped)
}
pub(crate) fn watch(trigger: SyncSender<()>, env: &Env) -> Result<Option<FileWatch>, Error> {
let (directories, leading_dropped) = watch_targets(config_search_dirs(env));
if directories.is_empty() {
return Ok(None);
}
if leading_dropped {
crate::trace::warning!(
file = FILE_NAME,
"the most specific configuration directory does not exist, so it cannot be \
watched; a kioslaverc created there later would win the cascade and be read, \
but no event would announce it; health() reports this route as degraded, and \
WatchOptions::poll_interval is what closes it"
);
}
let flags = LossFlags {
lost: Arc::new(AtomicBool::new(leading_dropped)),
live_directories: Arc::new(AtomicUsize::new(directories.len())),
};
let mut watcher =
notify::recommended_watcher(loss_callback(flags.clone(), directories.clone(), trigger))
.map_err(|source| notify_error("creating the kioslaverc watcher", source))?;
for directory in &directories {
watcher
.watch(directory, RecursiveMode::NonRecursive)
.map_err(|source| {
notify_error(
format!("watching {} for kioslaverc", directory.display()),
source,
)
})?;
crate::trace::debug!(
directory = %directory.display(),
file = FILE_NAME,
"watching the configuration directory with inotify"
);
}
Ok(Some(FileWatch {
_watcher: watcher,
flags,
}))
}
fn loss_callback(
flags: LossFlags,
directories: Vec<PathBuf>,
trigger: SyncSender<()>,
) -> impl FnMut(notify::Result<notify::Event>) {
let mut warned = false;
let mut watched = directories;
move |event: notify::Result<notify::Event>| {
let mut dark = false;
let relevant = match event {
Ok(event) => {
let ended = drop_ended_directories(&event, &mut watched);
if ended > 0 {
let live = flags.live_directories.fetch_sub(ended, Ordering::Relaxed) - ended;
flags.lost.store(true, Ordering::Release);
let first = !std::mem::replace(&mut warned, true);
if live == 0 {
dark = true;
crate::trace::warning!(
file = FILE_NAME,
"every watched configuration directory has been removed or \
renamed; the watches are dropped or now follow the moved \
directories, so no kioslaverc change will be noticed at all \
from here on"
);
} else if first {
crate::trace::warning!(
file = FILE_NAME,
ended = ended,
remaining = live,
"watched configuration directories were removed or renamed; \
those watches are dropped or now follow the moved directories, \
so kioslaverc changes there will no longer be noticed; \
health() now reports this route as degraded, though the \
remaining directories still report"
);
}
true
} else {
is_interesting(&event)
}
}
Err(_) => true,
};
if relevant {
if dark {
super::super::watcher::wake_delivered(&trigger);
} else {
super::super::watcher::wake(&trigger);
}
}
}
}
fn drop_ended_directories(event: ¬ify::Event, watched: &mut Vec<PathBuf>) -> usize {
use notify::EventKind;
use notify::event::ModifyKind;
if !matches!(
event.kind,
EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(_))
) {
return 0;
}
let before = watched.len();
watched.retain(|directory| !event.paths.iter().any(|path| path == directory));
before - watched.len()
}
fn is_interesting(event: ¬ify::Event) -> bool {
use notify::EventKind;
if event.need_rescan() {
return true;
}
if !matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
) {
return false;
}
event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == FILE_NAME))
}
fn notify_error(context: impl Into<String>, source: notify::Error) -> Error {
let text = source.to_string();
let io_error = match source.kind {
notify::ErrorKind::Io(error) => error,
_ => io::Error::other(text),
};
Error::io(context, io_error)
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::tests::fixture;
#[test]
fn a_lower_priority_holder_still_watches_the_directories_ahead_of_it() {
let f = fixture("lower", &["home", "etc"], &["home", "etc"], &["etc"]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn the_leading_holder_does_not_hide_lower_layers_from_the_watch() {
let f = fixture("leading", &["home", "etc"], &["home", "etc"], &["home"]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn candidates_below_a_holder_are_watched() {
let f = fixture(
"below",
&["home", "etc", "opt"],
&["home", "etc", "opt"],
&["etc"],
);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn a_candidate_directory_that_does_not_exist_is_skipped() {
let f = fixture("absent", &["home", "etc"], &["etc"], &["etc"]);
assert_eq!(
watch_targets(f.dirs.clone()),
(vec![f.dirs[1].clone()], true)
);
}
#[test]
fn a_dropped_middle_candidate_is_not_reported() {
let f = fixture(
"middle",
&["home", "session", "etc"],
&["home", "etc"],
&["etc"],
);
assert_eq!(
watch_targets(f.dirs.clone()),
(vec![f.dirs[0].clone(), f.dirs[2].clone()], false)
);
}
#[test]
fn with_no_kioslaverc_anywhere_all_existing_directories_are_watched() {
let f = fixture("none", &["home", "etc"], &["home", "etc"], &[]);
assert_eq!(watch_targets(f.dirs.clone()), (f.dirs.clone(), false));
}
#[test]
fn with_no_candidate_directory_at_all_there_is_nothing_to_watch() {
let f = fixture("empty", &["home", "etc"], &[], &[]);
assert_eq!(watch_targets(f.dirs.clone()), (Vec::new(), false));
}
fn ends_the_watch(event: ¬ify::Event, watched: &[PathBuf]) -> bool {
drop_ended_directories(event, &mut watched.to_vec()) > 0
}
fn directory_event(kind: notify::EventKind, path: &str) -> notify::Event {
notify::Event::new(kind).add_path(PathBuf::from(path))
}
fn watched() -> Vec<PathBuf> {
vec![
PathBuf::from("/home/alice/.config"),
PathBuf::from("/etc/xdg"),
]
}
#[test]
fn removing_a_watched_directory_ends_the_watch() {
use notify::event::RemoveKind;
let event = directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
);
assert!(ends_the_watch(&event, &watched()));
assert!(
!is_interesting(&event),
"the basename is not kioslaverc, which is exactly why is_interesting alone \
let this route go silent"
);
}
#[test]
fn renaming_a_watched_directory_ends_the_watch() {
use notify::event::{ModifyKind, RenameMode};
let event = directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
);
assert!(ends_the_watch(&event, &watched()));
}
#[test]
fn removing_the_file_itself_does_not_end_the_watch() {
use notify::event::RemoveKind;
let event = directory_event(
notify::EventKind::Remove(RemoveKind::File),
"/home/alice/.config/kioslaverc",
);
assert!(!ends_the_watch(&event, &watched()));
assert!(is_interesting(&event), "it is still a change to report");
}
#[test]
fn events_elsewhere_do_not_end_the_watch() {
use notify::event::{DataChange, ModifyKind, RemoveKind};
for event in [
directory_event(notify::EventKind::Remove(RemoveKind::Folder), "/tmp/other"),
directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/home/alice/.config/kdeglobals",
),
] {
assert!(!ends_the_watch(&event, &watched()));
}
}
#[test]
fn a_non_terminal_event_on_the_directory_itself_is_not_terminal() {
use notify::event::{CreateKind, MetadataKind, ModifyKind};
for kind in [
notify::EventKind::Create(CreateKind::Folder),
notify::EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
notify::EventKind::Any,
] {
let event = directory_event(kind, "/home/alice/.config");
assert!(!ends_the_watch(&event, &watched()));
}
}
#[test]
fn directories_are_lost_one_at_a_time() {
use notify::event::{ModifyKind, RemoveKind, RenameMode};
let mut live = watched();
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
),
&mut live,
),
1
);
assert_eq!(live, vec![PathBuf::from("/etc/xdg")]);
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/home/alice/.config",
),
&mut live,
),
0
);
assert_eq!(live, vec![PathBuf::from("/etc/xdg")]);
assert_eq!(
drop_ended_directories(
&directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
),
&mut live,
),
1
);
assert!(live.is_empty());
}
#[test]
fn one_event_can_end_more_than_one_directory() {
use notify::event::RemoveKind;
let event = notify::Event::new(notify::EventKind::Remove(RemoveKind::Folder))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"));
let mut live = watched();
assert_eq!(drop_ended_directories(&event, &mut live), 2);
assert!(live.is_empty());
}
fn callback_over_two_directories() -> (
LossFlags,
std::sync::mpsc::Receiver<()>,
impl FnMut(notify::Result<notify::Event>),
) {
let flags = LossFlags {
lost: Arc::new(AtomicBool::new(false)),
live_directories: Arc::new(AtomicUsize::new(2)),
};
let (trigger, wakes) = std::sync::mpsc::sync_channel(1);
let callback = loss_callback(flags.clone(), watched(), trigger);
(flags, wakes, callback)
}
#[test]
fn a_partial_loss_is_published_before_the_route_goes_dark() {
use notify::event::{DataChange, ModifyKind, RemoveKind, RenameMode};
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/etc/xdg/kioslaverc",
)));
assert!(wakes.try_recv().is_ok());
assert!(!flags.any_lost());
assert!(flags.is_live());
callback(Ok(directory_event(
notify::EventKind::Remove(RemoveKind::Folder),
"/home/alice/.config",
)));
assert!(wakes.try_recv().is_ok(), "a lost directory is itself news");
assert!(flags.any_lost());
assert!(flags.is_live(), "/etc/xdg still delivers");
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"/etc/xdg",
)));
assert!(wakes.try_recv().is_ok());
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn an_event_that_ends_every_directory_at_once_goes_dark_in_one_step() {
use notify::event::RemoveKind;
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(notify::Event::new(notify::EventKind::Remove(
RemoveKind::Folder,
))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"))));
assert!(wakes.try_recv().is_ok());
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn the_wake_that_reports_a_dark_route_is_not_allowed_to_be_refused() {
use notify::event::{DataChange, ModifyKind, RemoveKind};
let (flags, wakes, mut callback) = callback_over_two_directories();
callback(Ok(directory_event(
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"/etc/xdg/kioslaverc",
)));
let ending = std::thread::spawn(move || {
callback(Ok(notify::Event::new(notify::EventKind::Remove(
RemoveKind::Folder,
))
.add_path(PathBuf::from("/home/alice/.config"))
.add_path(PathBuf::from("/etc/xdg"))));
});
while flags.is_live() {
std::thread::yield_now();
}
std::thread::sleep(std::time::Duration::from_millis(200));
let timeout = std::time::Duration::from_secs(5);
assert_eq!(
wakes.recv_timeout(timeout),
Ok(()),
"the ordinary change is still queued"
);
assert_eq!(
wakes.recv_timeout(timeout),
Ok(()),
"the wake that says the route went dark must survive a full buffer"
);
ending.join().expect("the callback thread must finish");
assert!(flags.any_lost());
assert!(!flags.is_live());
}
#[test]
fn a_notify_failure_keeps_the_io_kind_it_arrived_with() {
let Error::Io { context, source } = notify_error(
"watching /etc/xdg for kioslaverc",
notify::Error::io(io::Error::from_raw_os_error(28)),
) else {
panic!("a notify failure is an I/O error");
};
assert_eq!(context, "watching /etc/xdg for kioslaverc");
assert_eq!(source.raw_os_error(), Some(28));
let Error::Io { source, .. } = notify_error("creating", notify::Error::generic("no fds"))
else {
panic!("a notify failure is an I/O error");
};
assert!(
source.to_string().contains("no fds"),
"the message is all there is: {source}"
);
}
#[test]
fn only_kioslaverc_events_are_interesting() {
use notify::event::{CreateKind, ModifyKind};
use notify::{Event, EventKind};
let kioslaverc = PathBuf::from("/home/alice/.config/kioslaverc");
let other = PathBuf::from("/home/alice/.config/kdeglobals");
let hit = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(kioslaverc.clone());
assert!(is_interesting(&hit));
let miss = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(other);
assert!(!is_interesting(&miss));
let created = Event::new(EventKind::Create(CreateKind::File)).add_path(kioslaverc.clone());
assert!(is_interesting(&created));
let accessed =
Event::new(EventKind::Access(notify::event::AccessKind::Read)).add_path(kioslaverc);
assert!(
!is_interesting(&accessed),
"reads must not wake the watcher"
);
}
#[test]
fn a_dropped_event_queue_wakes_the_watcher_even_though_it_names_nothing() {
use notify::event::Flag;
use notify::{Event, EventKind};
let overflow = Event::new(EventKind::Any).set_flag(Flag::Rescan);
assert!(
overflow.paths.is_empty(),
"an overflow reports no path, which is the whole difficulty"
);
assert!(is_interesting(&overflow));
}
}