use std::collections::HashMap;
use std::sync::Mutex;
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct NoSuchDir(pub String);
impl std::fmt::Display for NoSuchDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for NoSuchDir {}
#[derive(Debug, Clone)]
pub struct BaseDirChanged(pub String);
impl std::fmt::Display for BaseDirChanged {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for BaseDirChanged {}
#[derive(Debug, Clone)]
pub struct DirTooLarge {
pub basedir: String,
}
impl std::fmt::Display for DirTooLarge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"The directory {} is too large to monitor. Try increasing the value in /proc/sys/fs/inotify/max_user_watches",
self.basedir
)
}
}
impl std::error::Error for DirTooLarge {}
#[derive(Debug, Clone, Default)]
struct WatchEntry {
wd: Option<i32>,
modified: bool,
last_query: f64,
}
pub struct INotifyFileWatcher {
pub expire_time: f64,
entries: Mutex<HashMap<String, WatchEntry>>,
}
impl INotifyFileWatcher {
pub fn new(expire_time_minutes: f64) -> Self {
Self {
expire_time: expire_time_minutes * 60.0,
entries: Mutex::new(HashMap::new()),
}
}
fn now() -> f64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
pub fn expire_watches(&self) -> Vec<String> {
let now = Self::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let mut expired: Vec<String> = Vec::new();
let expire_time = self.expire_time;
for (path, entry) in entries.iter() {
if entry.last_query - now > expire_time {
expired.push(path.clone());
}
}
for path in &expired {
entries.remove(path);
}
expired
}
pub fn watch<F>(&self, path: impl Into<String>, add_wd: F)
where
F: FnOnce() -> Option<i32>,
{
let path = path.into();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
#[allow(clippy::map_entry)]
if !entries.contains_key(&path) {
let wd = add_wd();
let entry = WatchEntry {
wd,
modified: false,
last_query: 0.0,
};
entries.insert(path, entry);
}
}
pub fn unwatch<F>(&self, path: &str, rm_wd: F)
where
F: FnOnce(i32),
{
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = entries.remove(path) {
if let Some(wd) = entry.wd {
rm_wd(wd);
}
}
}
pub fn is_watching(&self, path: &str) -> bool {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.contains_key(path)
}
pub fn query<F>(&self, path: &str, read_events: F) -> bool
where
F: FnOnce(),
{
let now = Self::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = entries.get_mut(path) {
entry.last_query = now;
}
drop(entries);
read_events();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = entries.get_mut(path) {
let ans = entry.modified;
entry.modified = false;
ans
} else {
true
}
}
pub fn mark_modified(&self, path: &str) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = entries.get_mut(path) {
entry.modified = true;
}
}
pub fn close<F>(&self, mut unwatch_each: F)
where
F: FnMut(String, i32),
{
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let drained: Vec<(String, Option<i32>)> = entries.drain().map(|(p, e)| (p, e.wd)).collect();
drop(entries);
for (path, wd) in drained {
if let Some(w) = wd {
unwatch_each(path, w);
}
}
}
pub fn process_event(&self, _wd: i32, _mask: u32, _cookie: u32, _name: &str) {
}
}
pub struct INotifyTreeWatcher {
pub is_dummy: bool,
pub basedir: String,
pub modified: bool,
pub watched_dirs: HashMap<String, i32>,
pub watched_rmap: HashMap<i32, String>,
}
impl INotifyTreeWatcher {
pub fn new(basedir: impl Into<String>) -> Self {
Self {
is_dummy: false,
basedir: basedir.into(),
modified: true,
watched_dirs: HashMap::new(),
watched_rmap: HashMap::new(),
}
}
pub fn watch_tree<F>(&mut self, mut add_wd: F) -> Result<(), DirTooLarge>
where
F: FnMut(&str) -> Option<(i32, bool)>,
{
self.watched_dirs.clear();
self.watched_rmap.clear();
let basedir = self.basedir.clone();
match self.add_watches(&basedir, true, &mut add_wd) {
Ok(()) => Ok(()),
Err(_) => Err(DirTooLarge { basedir }),
}
}
#[allow(clippy::result_unit_err)]
pub fn add_watches<F>(&mut self, base: &str, top_level: bool, add_wd: &mut F) -> Result<(), ()>
where
F: FnMut(&str) -> Option<(i32, bool)>,
{
if !top_level && self.watched_dirs.contains_key(base) {
return Ok(());
}
let is_dir = match add_wd(base) {
Some((wd, is_dir)) => {
self.watched_dirs.insert(base.to_string(), wd);
self.watched_rmap.insert(wd, base.to_string());
is_dir
}
None => {
if top_level {
return Err(());
}
return Ok(());
}
};
if is_dir {
if let Ok(entries) = std::fs::read_dir(base) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
let p_str = p.to_string_lossy().to_string();
self.add_watches(&p_str, false, add_wd)?;
}
}
}
}
Ok(())
}
pub fn add_watch<F>(&mut self, path: impl Into<String>, add_wd: F) -> bool
where
F: FnOnce() -> Option<(i32, bool)>,
{
let path = path.into();
match add_wd() {
None => false,
Some((wd, is_dir)) => {
self.watched_dirs.insert(path.clone(), wd);
self.watched_rmap.insert(wd, path);
is_dir
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn process_event<I>(
&mut self,
wd: i32,
mask: u32,
name: &str,
q_overflow: u32,
create_flag: u32,
delete_self_flag: u32,
move_self_flag: u32,
ignore_event: I,
) -> ProcessEventOutcome
where
I: FnOnce(&str, &str) -> bool,
{
if wd == -1 && (mask & q_overflow) != 0 {
self.modified = true;
return ProcessEventOutcome::RescanTree;
}
let path = match self.watched_rmap.get(&wd).cloned() {
Some(p) => p,
None => return ProcessEventOutcome::Ignored,
};
if !ignore_event(&path, name) {
self.modified = true;
}
if (mask & create_flag) != 0 {
return ProcessEventOutcome::AddChildWatch {
parent: path.clone(),
name: name.to_string(),
};
}
if ((mask & delete_self_flag) != 0 || (mask & move_self_flag) != 0) && path == self.basedir
{
return ProcessEventOutcome::BaseDirChanged;
}
ProcessEventOutcome::Acknowledged
}
pub fn query<F>(&mut self, read_events: F) -> bool
where
F: FnOnce(),
{
read_events();
let ret = self.modified;
self.modified = false;
ret
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessEventOutcome {
RescanTree,
AddChildWatch { parent: String, name: String },
BaseDirChanged,
Acknowledged,
Ignored,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_such_dir_implements_error_traits() {
let e = NoSuchDir("/x".to_string());
assert!(e.to_string().contains("/x"));
let _: &dyn std::error::Error = &e;
}
#[test]
fn base_dir_changed_implements_error_traits() {
let e = BaseDirChanged("moved".to_string());
assert!(e.to_string().contains("moved"));
let _: &dyn std::error::Error = &e;
}
#[test]
fn dir_too_large_message_matches_upstream() {
let e = DirTooLarge {
basedir: "/data".to_string(),
};
let s = e.to_string();
assert!(s.contains("/data"));
assert!(s.contains("too large"));
assert!(s.contains("max_user_watches"));
}
#[test]
fn file_watcher_init_multiplies_expire_time_by_60() {
let w = INotifyFileWatcher::new(10.0);
assert!((w.expire_time - 600.0).abs() < 1e-9);
}
#[test]
fn file_watcher_watch_stores_wd_in_state() {
let w = INotifyFileWatcher::new(10.0);
w.watch("/foo", || Some(42));
assert!(w.is_watching("/foo"));
}
#[test]
fn file_watcher_watch_does_not_overwrite_existing() {
let w = INotifyFileWatcher::new(10.0);
let mut call_count = 0;
w.watch("/foo", || {
call_count += 1;
Some(1)
});
w.watch("/foo", || {
call_count += 1;
Some(2)
});
assert_eq!(call_count, 1);
}
#[test]
fn file_watcher_unwatch_removes_state_and_calls_rm() {
let w = INotifyFileWatcher::new(10.0);
w.watch("/foo", || Some(42));
let mut rm_called_with = 0;
w.unwatch("/foo", |wd| rm_called_with = wd);
assert_eq!(rm_called_with, 42);
assert!(!w.is_watching("/foo"));
}
#[test]
fn file_watcher_unwatch_unknown_path_no_op() {
let w = INotifyFileWatcher::new(10.0);
let mut called = false;
w.unwatch("/never-watched", |_| called = true);
assert!(!called);
}
#[test]
fn file_watcher_is_watching_false_when_not_registered() {
let w = INotifyFileWatcher::new(10.0);
assert!(!w.is_watching("/none"));
}
#[test]
fn file_watcher_query_returns_modified_flag_and_resets() {
let w = INotifyFileWatcher::new(10.0);
w.watch("/foo", || Some(1));
w.mark_modified("/foo");
let r = w.query("/foo", || {});
assert!(r);
let r2 = w.query("/foo", || {});
assert!(!r2);
}
#[test]
fn file_watcher_query_unknown_path_returns_true() {
let w = INotifyFileWatcher::new(10.0);
let r = w.query("/never-watched", || {});
assert!(r);
}
#[test]
fn file_watcher_query_calls_read_callback() {
let w = INotifyFileWatcher::new(10.0);
w.watch("/foo", || Some(1));
let mut called = false;
w.query("/foo", || called = true);
assert!(called);
}
#[test]
fn file_watcher_close_unwatches_all_paths() {
let w = INotifyFileWatcher::new(10.0);
w.watch("/a", || Some(1));
w.watch("/b", || Some(2));
let mut removed: Vec<(String, i32)> = Vec::new();
w.close(|p, wd| removed.push((p, wd)));
assert_eq!(removed.len(), 2);
let paths: Vec<&String> = removed.iter().map(|(p, _)| p).collect();
assert!(paths.iter().any(|p| *p == "/a"));
assert!(paths.iter().any(|p| *p == "/b"));
}
#[test]
fn tree_watcher_is_dummy_false() {
let t = INotifyTreeWatcher::new("/data");
assert!(!t.is_dummy);
}
#[test]
fn tree_watcher_init_modified_true() {
let t = INotifyTreeWatcher::new("/data");
assert!(t.modified);
}
#[test]
fn tree_watcher_add_watch_dir_stores_both_maps() {
let mut t = INotifyTreeWatcher::new("/data");
let is_dir = t.add_watch("/data/sub", || Some((42, true)));
assert!(is_dir);
assert_eq!(t.watched_dirs.get("/data/sub"), Some(&42));
assert_eq!(t.watched_rmap.get(&42), Some(&"/data/sub".to_string()));
}
#[test]
fn tree_watcher_add_watch_file_returns_false() {
let mut t = INotifyTreeWatcher::new("/data");
let is_dir = t.add_watch("/data/file", || Some((43, false)));
assert!(!is_dir);
assert_eq!(t.watched_dirs.get("/data/file"), Some(&43));
}
#[test]
fn tree_watcher_add_watch_failure_returns_false_and_no_state() {
let mut t = INotifyTreeWatcher::new("/data");
let is_dir = t.add_watch("/data/x", || None);
assert!(!is_dir);
assert!(t.watched_dirs.is_empty());
}
#[test]
fn tree_watcher_process_event_q_overflow_triggers_rescan() {
let mut t = INotifyTreeWatcher::new("/data");
let outcome = t.process_event(-1, 0x4000, "", 0x4000, 0x100, 0x400, 0x800, |_, _| false);
assert_eq!(outcome, ProcessEventOutcome::RescanTree);
assert!(t.modified);
}
#[test]
fn tree_watcher_process_event_unknown_wd_ignored() {
let mut t = INotifyTreeWatcher::new("/data");
let outcome = t.process_event(99, 0, "", 0x4000, 0x100, 0x400, 0x800, |_, _| false);
assert_eq!(outcome, ProcessEventOutcome::Ignored);
}
#[test]
fn tree_watcher_process_event_create_returns_add_child_watch() {
let mut t = INotifyTreeWatcher::new("/data");
t.add_watch("/data/sub", || Some((42, true)));
let outcome = t.process_event(42, 0x100, "child", 0x4000, 0x100, 0x400, 0x800, |_, _| {
false
});
match outcome {
ProcessEventOutcome::AddChildWatch { parent, name } => {
assert_eq!(parent, "/data/sub");
assert_eq!(name, "child");
}
_ => panic!("expected AddChildWatch"),
}
}
#[test]
fn tree_watcher_process_event_delete_self_on_basedir_returns_base_dir_changed() {
let mut t = INotifyTreeWatcher::new("/data");
t.add_watch("/data", || Some((1, true)));
let outcome = t.process_event(1, 0x400, "", 0x4000, 0x100, 0x400, 0x800, |_, _| false);
assert_eq!(outcome, ProcessEventOutcome::BaseDirChanged);
}
#[test]
fn tree_watcher_process_event_ignored_event_does_not_set_modified() {
let mut t = INotifyTreeWatcher::new("/data");
t.modified = false;
t.add_watch("/data/sub", || Some((42, true)));
let _ = t.process_event(42, 0x2, "x", 0x4000, 0x100, 0x400, 0x800, |_, _| true);
assert!(!t.modified);
}
#[test]
fn tree_watcher_query_returns_and_resets_modified() {
let mut t = INotifyTreeWatcher::new("/data");
t.modified = true;
let mut read_called = false;
let r = t.query(|| read_called = true);
assert!(r);
assert!(read_called);
assert!(!t.modified);
assert!(!t.query(|| {}));
}
#[test]
fn process_event_outcome_acknowledged_for_modified_only() {
let mut t = INotifyTreeWatcher::new("/data");
t.add_watch("/data/sub", || Some((42, true)));
let outcome = t.process_event(42, 0x2, "x", 0x4000, 0x100, 0x400, 0x800, |_, _| false);
assert_eq!(outcome, ProcessEventOutcome::Acknowledged);
assert!(t.modified);
}
#[test]
fn file_watcher_expire_watches_removes_expired_entries() {
let w = INotifyFileWatcher::new(0.001); w.watch("/foo", || Some(1));
let mut entries = w.entries.lock().unwrap();
if let Some(e) = entries.get_mut("/foo") {
e.last_query = INotifyFileWatcher::now() + 1_000_000.0;
}
drop(entries);
let expired = w.expire_watches();
assert_eq!(expired.len(), 1);
assert_eq!(expired[0], "/foo");
assert!(!w.is_watching("/foo"));
}
#[test]
fn tree_watcher_watch_tree_resets_and_calls_add_watches() {
let mut w = INotifyTreeWatcher::new("/data/dummy_basedir_zz_9999");
w.watched_dirs.insert("/stale".to_string(), 999);
w.watched_rmap.insert(999, "/stale".to_string());
let calls = std::sync::Arc::new(std::sync::Mutex::new(0u32));
let calls_c = calls.clone();
let r = w.watch_tree(move |_| {
*calls_c.lock().unwrap() += 1;
None
});
assert!(r.is_err());
assert_eq!(*calls.lock().unwrap(), 1);
assert!(!w.watched_dirs.contains_key("/stale"));
assert!(!w.watched_rmap.contains_key(&999));
}
#[test]
fn tree_watcher_add_watches_skips_already_known_paths() {
let mut w = INotifyTreeWatcher::new("/data/x");
w.watched_dirs.insert("/data/x".to_string(), 7);
w.watched_rmap.insert(7, "/data/x".to_string());
let calls = std::sync::Arc::new(std::sync::Mutex::new(0u32));
let calls_c = calls.clone();
let r = w.add_watches("/data/x", false, &mut |_| {
*calls_c.lock().unwrap() += 1;
Some((42, true))
});
assert!(r.is_ok());
assert_eq!(
*calls.lock().unwrap(),
0,
"already-watched path should not call add_wd"
);
}
#[test]
fn tree_watcher_add_watches_records_wd_on_success() {
let mut w = INotifyTreeWatcher::new("/data/x");
let mut calls = 0;
let r = w.add_watches("/data/synthetic_path", true, &mut |_| {
calls += 1;
Some((100, false)) });
assert!(r.is_ok());
assert_eq!(calls, 1);
assert_eq!(w.watched_dirs.get("/data/synthetic_path"), Some(&100));
assert_eq!(
w.watched_rmap.get(&100),
Some(&"/data/synthetic_path".to_string())
);
}
}