use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use notify::{
event::{EventKind, ModifyKind},
RecursiveMode, Watcher,
};
const EWMA_DECAY_PER_SEC: f64 = 0.87;
const PRUNE_IDLE: Duration = Duration::from_secs(30);
const MAX_TRACKED: usize = 4096;
pub const HISTORY_LEN: usize = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityKind {
Modified,
Created,
Removed,
Metadata,
Renamed,
Other,
}
impl ActivityKind {
pub fn label(&self) -> &'static str {
match self {
ActivityKind::Modified => "modify",
ActivityKind::Created => "create",
ActivityKind::Removed => "remove",
ActivityKind::Metadata => "meta",
ActivityKind::Renamed => "rename",
ActivityKind::Other => "other",
}
}
fn from_event(kind: &EventKind) -> Self {
match kind {
EventKind::Create(_) => ActivityKind::Created,
EventKind::Remove(_) => ActivityKind::Removed,
EventKind::Modify(ModifyKind::Name(_)) => ActivityKind::Renamed,
EventKind::Modify(ModifyKind::Metadata(_)) => ActivityKind::Metadata,
EventKind::Modify(_) => ActivityKind::Modified,
_ => ActivityKind::Other,
}
}
}
#[derive(Debug, Clone)]
pub struct FileActivity {
pub path: PathBuf,
pub events_per_sec: f64,
pub total_events: u64,
pub last_kind: ActivityKind,
pub last_seen: Instant,
pub history: VecDeque<f64>,
pub pushed: u64,
}
#[derive(Default)]
pub struct HotFileState {
pub activity: HashMap<PathBuf, FileActivity>,
pub total_events: u64,
pub watch_roots: Vec<PathBuf>,
pub error: Option<String>,
pub installing: bool,
}
impl HotFileState {
fn record(&mut self, path: PathBuf, kind: ActivityKind) {
self.total_events += 1;
let now = Instant::now();
let entry = self
.activity
.entry(path.clone())
.or_insert_with(|| FileActivity {
path,
events_per_sec: 0.0,
total_events: 0,
last_kind: kind,
last_seen: now,
history: VecDeque::new(),
pushed: 0,
});
entry.total_events += 1;
entry.last_kind = kind;
entry.events_per_sec += 1.0;
entry.last_seen = now;
}
}
pub struct HotFileWatcher {
pub state: Arc<Mutex<HotFileState>>,
_watcher: Arc<Mutex<Option<notify::RecommendedWatcher>>>,
stop: Arc<AtomicBool>,
stop_gate: Arc<Mutex<()>>,
handle: Option<thread::JoinHandle<()>>,
}
type RootHook = Box<dyn FnMut(&Path) + Send + 'static>;
impl HotFileWatcher {
fn idle() -> Self {
Self {
state: Arc::new(Mutex::new(HotFileState::default())),
_watcher: Arc::new(Mutex::new(None)),
stop: Arc::new(AtomicBool::new(false)),
stop_gate: Arc::new(Mutex::new(())),
handle: None,
}
}
pub fn start(roots: &[&Path]) -> Self {
if roots.is_empty() {
return Self::idle();
}
let roots: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
let state = Arc::new(Mutex::new(HotFileState::default()));
let stop = Arc::new(AtomicBool::new(false));
let stop_gate = Arc::new(Mutex::new(()));
let watcher = install(&roots, &state, &stop, &stop_gate, None);
Self {
state,
_watcher: Arc::new(Mutex::new(watcher)),
stop,
stop_gate,
handle: None,
}
}
pub fn start_background(roots: &[&Path]) -> Self {
if roots.is_empty() {
return Self::start(roots);
}
Self::start_background_inner(roots, || (), || (), None)
}
fn start_background_inner<F, G, H, I>(
roots: &[&Path],
before_install: F,
after_install: H,
mut on_root: Option<RootHook>,
) -> Self
where
F: FnOnce() -> G + Send + 'static,
G: Send + 'static,
H: FnOnce() -> I + Send + 'static,
I: Send + 'static,
{
let roots: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
let state = Arc::new(Mutex::new(HotFileState::default()));
state.lock().unwrap().installing = true;
let watcher = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false));
let stop_gate = Arc::new(Mutex::new(()));
let state_t = state.clone();
let watcher_t = watcher.clone();
let stop_t = stop.clone();
let stop_gate_t = stop_gate.clone();
let roots_t = roots.clone();
let spawned = thread::Builder::new()
.name("hot-files-watch".into())
.spawn(move || {
let _before = before_install();
let hook: Option<&mut dyn FnMut(&Path)> = match on_root.as_mut() {
Some(hook) => Some(&mut **hook),
None => None,
};
let installed = install(&roots_t, &state_t, &stop_t, &stop_gate_t, hook);
let _after = after_install();
if let Some(w) = installed {
let _gate = stop_gate_t.lock().unwrap();
if !stop_t.load(Ordering::Relaxed) {
*watcher_t.lock().unwrap() = Some(w);
}
}
});
let handle = match spawned {
Ok(handle) => Some(handle),
Err(e) => {
let mut s = state.lock().unwrap();
s.installing = false;
s.error = Some(format!("watcher thread failed to start: {}", e));
None
}
};
Self {
state,
_watcher: watcher,
stop,
stop_gate,
handle,
}
}
#[cfg(test)]
pub fn wait(&self) {
if let Some(handle) = &self.handle {
while !handle.is_finished() {
thread::sleep(Duration::from_millis(1));
}
}
}
pub fn decay(&self, elapsed: Duration) {
let mut s = self.state.lock().unwrap();
let now = Instant::now();
let factor = EWMA_DECAY_PER_SEC.powf(elapsed.as_secs_f64());
s.activity.retain(|_, a| {
if now.duration_since(a.last_seen) > PRUNE_IDLE {
return false;
}
if a.history.len() == HISTORY_LEN {
a.history.pop_front();
}
a.history.push_back(a.events_per_sec);
a.pushed += 1;
a.events_per_sec *= factor;
if a.events_per_sec < 0.01 {
a.events_per_sec = 0.0;
}
true
});
if s.activity.len() > MAX_TRACKED {
let mut by_age: Vec<(PathBuf, Instant)> = s
.activity
.iter()
.map(|(k, v)| (k.clone(), v.last_seen))
.collect();
by_age.sort_by_key(|(_, t)| *t);
let drop_n = s.activity.len() - MAX_TRACKED;
for (k, _) in by_age.into_iter().take(drop_n) {
s.activity.remove(&k);
}
}
}
pub fn top(&self, n: usize) -> Vec<FileActivity> {
let s = self.state.lock().unwrap();
let mut v: Vec<FileActivity> = s.activity.values().cloned().collect();
v.sort_by(|a, b| {
b.events_per_sec
.partial_cmp(&a.events_per_sec)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.total_events.cmp(&a.total_events))
});
v.truncate(n);
v
}
pub fn pool_activity(&self, fs: &[crate::collect::FsTick]) -> HashMap<String, PoolHot> {
let s = self.state.lock().unwrap();
let active: Vec<(PathBuf, f64)> = s
.activity
.values()
.filter(|a| a.events_per_sec > 0.0)
.map(|a| (a.path.clone(), a.events_per_sec))
.collect();
attribute_to_pools(&active, fs, &s.watch_roots)
}
pub fn installing(&self) -> bool {
self.state.lock().unwrap().installing
}
pub fn snapshot_meta(&self) -> (u64, Vec<PathBuf>, Option<String>) {
let s = self.state.lock().unwrap();
(s.total_events, s.watch_roots.clone(), s.error.clone())
}
pub fn active_count(&self) -> usize {
self.state.lock().unwrap().activity.len()
}
}
impl Drop for HotFileWatcher {
fn drop(&mut self) {
{
let _gate = self
.stop_gate
.lock()
.unwrap_or_else(PoisonError::into_inner);
self.stop.store(true, Ordering::Relaxed);
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
s.watch_roots.clear();
s.error = None;
s.installing = false;
}
if let Some(handle) = self.handle.take() {
if handle.is_finished() {
let _ = handle.join();
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PoolHot {
pub events_per_sec: f64,
pub top: Vec<(PathBuf, f64)>,
pub watched: bool,
}
pub const POOL_HOT_TOP: usize = 3;
pub fn zfs_mount_roots(fs: &[crate::collect::FsTick]) -> Vec<PathBuf> {
fs.iter()
.filter(|f| f.fs_type == "zfs" && f.mount != "/")
.map(|f| PathBuf::from(&f.mount))
.collect()
}
fn pool_of(dataset: &str) -> &str {
dataset.split('/').next().unwrap_or(dataset)
}
pub fn attribute_to_pools(
activity: &[(PathBuf, f64)],
fs: &[crate::collect::FsTick],
roots: &[PathBuf],
) -> HashMap<String, PoolHot> {
let mut out: HashMap<String, PoolHot> = HashMap::new();
for f in fs.iter().filter(|f| f.fs_type == "zfs") {
let mount = Path::new(&f.mount);
let covered = roots
.iter()
.any(|r| mount.starts_with(r) || r.starts_with(mount));
let e = out.entry(pool_of(&f.device).to_string()).or_default();
e.watched |= covered;
}
if out.is_empty() {
return out;
}
let mut parents: std::collections::HashSet<&Path> = std::collections::HashSet::new();
for (path, _) in activity {
for a in path.ancestors().skip(1) {
if !parents.insert(a) {
break; }
}
}
for (path, rate) in activity {
if parents.contains(path.as_path()) {
continue;
}
let owner = fs
.iter()
.filter(|f| path.starts_with(&f.mount))
.max_by_key(|f| Path::new(&f.mount).components().count());
let Some(owner) = owner.filter(|f| f.fs_type == "zfs") else {
continue;
};
let e = out.entry(pool_of(&owner.device).to_string()).or_default();
e.events_per_sec += rate;
e.top.push((path.clone(), *rate));
}
for e in out.values_mut() {
e.top
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
e.top.truncate(POOL_HOT_TOP);
}
out
}
fn install(
roots: &[PathBuf],
state: &Arc<Mutex<HotFileState>>,
stop: &AtomicBool,
stop_gate: &Mutex<()>,
mut on_root: Option<&mut dyn FnMut(&Path)>,
) -> Option<notify::RecommendedWatcher> {
if stop.load(Ordering::Relaxed) {
return None;
}
let state_w = state.clone();
let watcher_result =
notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
let Ok(event) = result else { return };
let kind = ActivityKind::from_event(&event.kind);
let Ok(mut s) = state_w.lock() else { return };
for p in event.paths {
s.record(p, kind);
}
});
let mut watcher = match watcher_result {
Ok(w) => w,
Err(e) => {
let _gate = stop_gate.lock().unwrap();
if !stop.load(Ordering::Relaxed) {
let mut s = state.lock().unwrap();
s.installing = false;
s.error = Some(format!("watcher init failed: {}", e));
}
return None;
}
};
let mut errors: Vec<String> = Vec::new();
let mut watched: Vec<PathBuf> = Vec::new();
for r in roots {
if stop.load(Ordering::Relaxed) {
return None;
}
if !r.exists() {
errors.push(format!("{}: no such path", r.display()));
continue;
}
match watcher.watch(r, RecursiveMode::Recursive) {
Ok(()) => watched.push(r.clone()),
Err(e) if is_unreadable_descendant(r, &e) => {
let skipped = watch_readable_tree(&mut watcher, r, stop);
watched.push(r.clone());
if let Some(first) = skipped.first() {
errors.push(format!(
"{}: {} unreadable director{} not watched (e.g. {})",
r.display(),
skipped.len(),
if skipped.len() == 1 { "y" } else { "ies" },
first.display()
));
}
}
Err(e) => errors.push(describe_watch_error(r, &e)),
}
if stop.load(Ordering::Relaxed) {
return None;
}
if let Some(hook) = on_root.as_deref_mut() {
hook(r.as_path());
}
}
{
let _gate = stop_gate.lock().unwrap();
if stop.load(Ordering::Relaxed) {
return None;
}
let mut s = state.lock().unwrap();
s.watch_roots = watched;
s.installing = false;
if !errors.is_empty() {
s.error = Some(errors.join("; "));
}
}
Some(watcher)
}
pub fn default_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = std::env::var_os("HOME") {
roots.push(PathBuf::from(home));
}
}
#[cfg(target_os = "windows")]
{
if let Some(profile) = std::env::var_os("USERPROFILE") {
roots.push(PathBuf::from(profile));
} else if let Some(home) = std::env::var_os("HOME") {
roots.push(PathBuf::from(home));
}
if let Some(temp) = std::env::var_os("TEMP") {
roots.push(PathBuf::from(temp));
}
}
#[cfg(target_os = "macos")]
{
roots.push(PathBuf::from("/private/var/log"));
roots.push(PathBuf::from("/private/tmp"));
}
#[cfg(target_os = "linux")]
{
roots.push(PathBuf::from("/var/log"));
roots.push(PathBuf::from("/tmp"));
}
prune_nested_roots(roots)
}
fn is_unreadable_descendant(root: &Path, e: ¬ify::Error) -> bool {
matches!(&e.kind, notify::ErrorKind::Io(io)
if io.kind() == std::io::ErrorKind::PermissionDenied)
&& e.paths.first().is_some_and(|p| p != root)
}
fn watch_readable_tree(watcher: &mut impl Watcher, root: &Path, stop: &AtomicBool) -> Vec<PathBuf> {
let mut skipped = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if stop.load(Ordering::Relaxed) {
break;
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => {
skipped.push(dir);
continue;
}
};
match watcher.watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => {}
Err(e) if matches!(e.kind, notify::ErrorKind::MaxFilesWatch) => {
skipped.push(dir);
break;
}
Err(_) => {
skipped.push(dir);
continue;
}
}
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
stack.push(entry.path());
}
}
}
skipped
}
fn describe_watch_error(root: &Path, e: ¬ify::Error) -> String {
if matches!(e.kind, notify::ErrorKind::MaxFilesWatch) {
let fix = if cfg!(target_os = "linux") {
"raise fs.inotify.max_user_watches, or watch a narrower path"
} else {
"watch a narrower path"
};
return format!(
"{}: out of file watches — watching is recursive, and costs one \
per directory underneath this path. To fix: {fix}.",
root.display()
);
}
let reason = match &e.kind {
notify::ErrorKind::Io(io) => io.to_string(),
_ => e.to_string(),
};
match e.paths.first() {
Some(p) if p != root => format!(
"{}: skipped — {reason} on {} (watching is recursive, so one \
unreadable directory underneath fails the whole root)",
root.display(),
p.display()
),
_ => format!("{}: {reason}", root.display()),
}
}
pub fn prune_nested_roots(roots: Vec<PathBuf>) -> Vec<PathBuf> {
let mut result: Vec<PathBuf> = Vec::new();
for candidate in roots {
let already_covered = result.iter().any(|kept| {
if let (Ok(c_cand), Ok(c_kept)) = (candidate.canonicalize(), kept.canonicalize()) {
c_cand.starts_with(&c_kept)
} else {
candidate.starts_with(kept)
}
});
if already_covered {
continue;
}
result.retain(|kept| {
let kept_is_child =
if let (Ok(c_kept), Ok(c_cand)) = (kept.canonicalize(), candidate.canonicalize()) {
c_kept.starts_with(&c_cand)
} else {
kept.starts_with(&candidate)
};
!kept_is_child
});
result.push(candidate);
}
result
}
pub fn resolve_roots(replace: Option<Vec<PathBuf>>, extra: &[PathBuf]) -> Vec<PathBuf> {
let mut roots = replace.unwrap_or_else(default_roots);
roots.extend_from_slice(extra);
prune_nested_roots(roots)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extra_roots_add_to_the_defaults_and_replacements_stand_in_for_them() {
let extra = vec![PathBuf::from("/srv/data")];
let with_defaults = resolve_roots(None, &extra);
assert!(with_defaults.len() > 1, "defaults should still be present");
assert!(with_defaults.contains(&PathBuf::from("/srv/data")));
let replaced = resolve_roots(Some(vec![PathBuf::from("/only")]), &extra);
assert_eq!(
replaced,
vec![PathBuf::from("/only"), PathBuf::from("/srv/data")],
"an explicit list replaces the defaults but still takes the extras"
);
}
#[test]
fn duplicate_roots_collapse_to_one_banner_entry() {
let roots = resolve_roots(
Some(vec![PathBuf::from("/a"), PathBuf::from("/b")]),
&[PathBuf::from("/a")],
);
assert_eq!(roots, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
}
#[test]
fn nested_roots_collapse_into_parent() {
let roots = resolve_roots(
Some(vec![PathBuf::from("/a"), PathBuf::from("/a/b")]),
&[PathBuf::from("/a/b/c")],
);
assert_eq!(roots, vec![PathBuf::from("/a")]);
let inverted = resolve_roots(Some(vec![PathBuf::from("/a/b")]), &[PathBuf::from("/a")]);
assert_eq!(inverted, vec![PathBuf::from("/a")]);
}
struct TempDirGuard(std::path::PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn nested_roots_pruning_with_real_filesystem_paths() {
let temp_dir = std::env::temp_dir().join(format!("dw_test_{}", std::process::id()));
let _guard = TempDirGuard(temp_dir.clone());
let parent = temp_dir.join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let roots = resolve_roots(Some(vec![parent.clone(), child.clone()]), &[]);
assert_eq!(roots, vec![parent.clone()]);
let inverted = resolve_roots(Some(vec![child]), std::slice::from_ref(&parent));
assert_eq!(inverted, vec![parent]);
}
#[test]
fn an_exhausted_watch_budget_names_the_knob_that_fixes_it() {
let e = notify::Error {
kind: notify::ErrorKind::MaxFilesWatch,
paths: Vec::new(),
};
let msg = describe_watch_error(Path::new("/big/tree"), &e);
assert!(msg.contains("/big/tree"), "{msg}");
assert!(msg.contains("recursive"), "{msg}");
#[cfg(target_os = "linux")]
assert!(msg.contains("max_user_watches"), "{msg}");
}
#[test]
fn a_permission_error_names_the_directory_that_actually_failed() {
let e = notify::Error {
kind: notify::ErrorKind::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
paths: vec![PathBuf::from("/tmp/systemd-private-abc")],
};
let msg = describe_watch_error(Path::new("/tmp"), &e);
assert!(msg.contains("/tmp/systemd-private-abc"), "{msg}");
assert!(msg.contains("recursive"), "{msg}");
assert!(!msg.contains('['), "{msg}");
}
#[test]
fn a_bad_root_is_named_without_silencing_the_good_ones() {
let dir = std::env::temp_dir().join(format!("diskwatch-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let missing = dir.join("diskwatch-does-not-exist-49bd2f");
let w = HotFileWatcher::start_background(&[dir.as_path(), missing.as_path()]);
w.wait();
let (_, roots, err) = w.snapshot_meta();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(roots, vec![dir.clone()], "the good root is still watched");
let err = err.expect("the missing root should be reported");
assert!(
err.contains("diskwatch-does-not-exist-49bd2f"),
"the error should name the offending path, got: {err}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn an_unreadable_subdirectory_does_not_lose_the_root() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("diskwatch-unreadable-{}", std::process::id()));
let good = dir.join("good");
let locked = dir.join("locked");
std::fs::create_dir_all(&good).expect("good dir");
std::fs::create_dir_all(locked.join("inner")).expect("locked dir");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let readable = std::fs::read_dir(&locked).is_ok();
let w = HotFileWatcher::start_background(&[dir.as_path()]);
w.wait();
let (_, roots, err) = w.snapshot_meta();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
if readable {
return; }
assert_eq!(roots, vec![dir.clone()], "the root is still watched");
let err = err.expect("the skipped directory should be reported");
assert!(err.contains("locked"), "{err}");
assert!(!err.contains("fails the whole root"), "{err}");
}
#[test]
fn background_startup_returns_before_installation_runs() {
let dir = std::env::temp_dir().join(format!("diskwatch-gate-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let released_by_test = Arc::new(AtomicBool::new(false));
let released_w = released_by_test.clone();
let w = HotFileWatcher::start_background_inner(
&[dir.as_path()],
move || {
let released = release_rx.recv_timeout(Duration::from_secs(5)).is_ok();
released_w.store(released, Ordering::SeqCst);
},
|| (),
None,
);
assert!(w.installing());
assert!(w.snapshot_meta().1.is_empty());
let _ = release_tx.send(());
w.wait();
assert!(!w.installing());
assert!(
released_by_test.load(Ordering::SeqCst),
"start_background returned only after installation had run"
);
let (_, roots, err) = w.snapshot_meta();
assert_eq!(roots, vec![dir.clone()]);
assert!(err.is_none(), "{err:?}");
}
#[test]
fn wait_is_repeatable_and_reports_the_final_state() {
let dir = std::env::temp_dir().join(format!("diskwatch-wait-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let missing = dir.join("diskwatch-wait-missing");
let w = HotFileWatcher::start_background(&[dir.as_path(), missing.as_path()]);
w.wait();
let first = w.snapshot_meta();
w.wait();
let second = w.snapshot_meta();
assert_eq!(first, second, "repeated wait() must not change the result");
assert_eq!(second.1, vec![dir.clone()]);
assert!(second
.2
.as_deref()
.is_some_and(|e| e.contains("no such path")));
}
#[test]
fn synchronous_start_finishes_installation_before_returning() {
let dir = std::env::temp_dir().join(format!("diskwatch-sync-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let missing = dir.join("diskwatch-sync-missing");
let w = HotFileWatcher::start(&[dir.as_path(), missing.as_path()]);
let (_, roots, err) = w.snapshot_meta();
assert_eq!(roots, vec![dir.clone()]);
assert!(err
.expect("missing root reported")
.contains("diskwatch-sync-missing"));
}
#[test]
fn drop_survives_a_poisoned_state_lock() {
let w = HotFileWatcher::start(&[]);
let state = w.state.clone();
let _ = std::thread::spawn(move || {
let _held = state.lock().unwrap();
panic!("poison the state lock");
})
.join();
assert!(w.state.is_poisoned());
drop(w);
}
#[test]
fn empty_roots_finish_with_no_error() {
for w in [
HotFileWatcher::start_background(&[]),
HotFileWatcher::start(&[]),
] {
w.wait();
assert!(
w._watcher.lock().unwrap().is_none(),
"nothing to watch must not build an OS watcher"
);
let (events, roots, err) = w.snapshot_meta();
assert_eq!(events, 0);
assert!(roots.is_empty());
assert!(err.is_none(), "{err:?}");
}
}
#[test]
fn a_cancelled_install_installs_and_publishes_nothing() {
let dir = std::env::temp_dir().join(format!("diskwatch-cancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let state = Arc::new(Mutex::new(HotFileState::default()));
let stop = AtomicBool::new(true);
let stop_gate = Mutex::new(());
let w = install(std::slice::from_ref(&dir), &state, &stop, &stop_gate, None);
assert!(w.is_none(), "a cancelled install must not return a watcher");
let s = state.lock().unwrap();
assert!(s.watch_roots.is_empty());
assert!(s.error.is_none());
}
#[cfg(target_os = "linux")]
fn file_key(path: &Path) -> (u64, u64) {
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::metadata(path).expect("metadata");
(metadata.dev(), metadata.ino())
}
#[cfg(target_os = "linux")]
fn inotify_watched_files() -> Option<std::collections::HashSet<(u64, u64)>> {
let mut files = std::collections::HashSet::new();
for entry in std::fs::read_dir("/proc/self/fdinfo").ok()?.flatten() {
let Ok(info) = std::fs::read_to_string(entry.path()) else {
continue;
};
for line in info.lines() {
let mut fields = line.split_whitespace();
if fields.next() != Some("inotify") {
continue;
}
let (mut sdev, mut ino) = (None, None);
for field in fields {
if let Some(hex) = field.strip_prefix("sdev:") {
sdev = u64::from_str_radix(hex, 16).ok();
} else if let Some(hex) = field.strip_prefix("ino:") {
ino = u64::from_str_radix(hex, 16).ok();
}
}
if let (Some(sdev), Some(ino)) = (sdev, ino) {
files.insert((new_encode_dev(sdev), ino));
}
}
}
Some(files)
}
#[cfg(target_os = "linux")]
fn new_encode_dev(dev: u64) -> u64 {
let major = (dev >> 20) & 0xfff;
let minor = dev & 0xfffff;
((minor & 0xff) | (major << 8) | ((minor & 0xfffff00) << 12)) & 0xffff_ffff
}
#[cfg(target_os = "linux")]
fn wait_for_watch_release(key: (u64, u64)) -> Option<bool> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let live = inotify_watched_files()?;
if !live.contains(&key) {
return Some(true);
}
if Instant::now() >= deadline {
return Some(false);
}
thread::sleep(Duration::from_millis(1));
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_cancelled_install_releases_the_watches_it_already_made() {
let dir = std::env::temp_dir().join(format!("diskwatch-partial-{}", std::process::id()));
let first = dir.join("first");
let second = dir.join("second");
std::fs::create_dir_all(&first).expect("first dir");
std::fs::create_dir_all(&second).expect("second dir");
let _guard = TempDirGuard(dir.clone());
let first_key = file_key(&first);
let observed = inotify_watched_files();
if let Some(before) = &observed {
assert!(
!before.contains(&first_key),
"the test directory is already watched"
);
}
let state = Arc::new(Mutex::new(HotFileState::default()));
let stop = AtomicBool::new(false);
let stop_gate = Mutex::new(());
let mut roots_seen = 0;
let mut watched_at_cancel = None;
let mut on_root = |root: &Path| {
roots_seen += 1;
watched_at_cancel = inotify_watched_files().map(|w| w.contains(&file_key(root)));
stop.store(true, Ordering::Relaxed);
};
let watcher = install(
&[first.clone(), second.clone()],
&state,
&stop,
&stop_gate,
Some(&mut on_root),
);
assert!(watcher.is_none(), "a cancelled install must return nothing");
assert_eq!(
roots_seen, 1,
"the cancelled install must not touch the second root"
);
assert_ne!(
watched_at_cancel,
Some(false),
"the first root was not watched when the cancellation arrived"
);
assert_ne!(
wait_for_watch_release(first_key),
Some(false),
"the partial watch must be released with the cancelled install"
);
let s = state.lock().unwrap();
assert!(
s.watch_roots.is_empty(),
"a cancelled install publishes nothing"
);
assert!(s.error.is_none(), "{:?}", s.error);
}
#[cfg(target_os = "linux")]
#[test]
fn a_cancelled_fallback_install_abandons_the_watcher_it_was_building() {
use std::os::unix::fs::PermissionsExt;
struct RestoreMode(PathBuf);
impl Drop for RestoreMode {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
}
let dir =
std::env::temp_dir().join(format!("diskwatch-fallback-cancel-{}", std::process::id()));
let locked = dir.join("locked");
let readable = dir.join("readable");
std::fs::create_dir_all(locked.join("inner")).expect("locked dir");
std::fs::create_dir_all(&readable).expect("readable dir");
let _guard = TempDirGuard(dir.clone());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let _mode = RestoreMode(locked.clone());
if std::fs::read_dir(&locked).is_ok() {
return; }
let control_state = Arc::new(Mutex::new(HotFileState::default()));
let control_stop = AtomicBool::new(false);
let control_watcher = install(
std::slice::from_ref(&dir),
&control_state,
&control_stop,
&Mutex::new(()),
None,
);
assert!(
control_watcher.is_some(),
"the control install must complete"
);
let control = control_state.lock().unwrap();
assert_eq!(control.watch_roots, vec![dir.clone()]);
let control_err = control
.error
.clone()
.expect("the skipped directory should be reported");
assert!(control_err.contains("locked"), "{control_err}");
drop(control);
drop(control_watcher);
let state = Arc::new(Mutex::new(HotFileState::default()));
let stop = AtomicBool::new(false);
let stop_gate = Mutex::new(());
let mut on_root = |_: &Path| stop.store(true, Ordering::Relaxed);
let watcher = install(
std::slice::from_ref(&dir),
&state,
&stop,
&stop_gate,
Some(&mut on_root),
);
assert!(
watcher.is_none(),
"a cancelled install must not hand back the half-built watcher"
);
let s = state.lock().unwrap();
assert!(
s.watch_roots.is_empty(),
"a cancelled install publishes nothing, got {:?}",
s.watch_roots
);
assert!(s.error.is_none(), "{:?}", s.error);
drop(s);
assert_ne!(
wait_for_watch_release(file_key(&readable)),
Some(false),
"the abandoned fallback must release the watches it already made"
);
}
#[test]
fn a_cancelled_walk_leaves_the_tree_alone() {
let missing = PathBuf::from("/diskwatch-cancelled-walk-does-not-exist");
let mut watcher =
notify::recommended_watcher(|_: notify::Result<notify::Event>| {}).unwrap();
let cancelled = AtomicBool::new(true);
assert!(watch_readable_tree(&mut watcher, &missing, &cancelled).is_empty());
let running = AtomicBool::new(false);
assert_eq!(
watch_readable_tree(&mut watcher, &missing, &running),
vec![missing]
);
}
struct CancelAfterNWatches<'a> {
inner: notify::RecommendedWatcher,
stop: Option<&'a AtomicBool>,
remaining: usize,
}
impl Watcher for CancelAfterNWatches<'_> {
fn new<F: notify::EventHandler>(
handler: F,
config: notify::Config,
) -> notify::Result<Self> {
Ok(Self {
inner: notify::RecommendedWatcher::new(handler, config)?,
stop: None,
remaining: 0,
})
}
fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
let res = self.inner.watch(path, mode);
if self.remaining > 0 {
self.remaining -= 1;
if self.remaining == 0 {
if let Some(stop) = self.stop {
stop.store(true, Ordering::Relaxed);
}
}
}
res
}
fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
self.inner.unwatch(path)
}
fn kind() -> notify::WatcherKind {
notify::RecommendedWatcher::kind()
}
}
#[test]
fn a_cancelled_walk_stops_before_the_next_directory() {
let dir = std::env::temp_dir().join(format!("diskwatch-midwalk-{}", std::process::id()));
let deep = dir.join("deep");
let other = dir.join("other");
std::fs::create_dir_all(&deep).expect("deep dir");
std::fs::create_dir_all(&other).expect("other dir");
let _guard = TempDirGuard(dir.clone());
let stop = AtomicBool::new(false);
let skipped = {
let mut watcher = CancelAfterNWatches {
inner: notify::recommended_watcher(|_: notify::Result<notify::Event>| {}).unwrap(),
stop: Some(&stop),
remaining: 1,
};
let skipped = watch_readable_tree(&mut watcher, &dir, &stop);
assert!(
stop.load(Ordering::Relaxed),
"the walk must have reached the point that cancels it"
);
#[cfg(target_os = "linux")]
if let Some(live) = inotify_watched_files() {
assert!(
live.contains(&file_key(&dir)),
"the root should be watched by the time the walk stops"
);
assert!(
!live.contains(&file_key(&deep)),
"an abandoned walk must not watch a queued child"
);
assert!(
!live.contains(&file_key(&other)),
"an abandoned walk must not watch a queued child"
);
}
skipped
};
assert!(
skipped.is_empty(),
"an abandoned walk reports nothing: {:?}",
skipped
);
#[cfg(target_os = "linux")]
assert_ne!(
wait_for_watch_release(file_key(&dir)),
Some(false),
"the abandoned walk's own watch must be released with it"
);
}
#[test]
fn dropping_a_watcher_during_initialization_cancels_the_worker() {
struct ExitSignal(std::sync::mpsc::Sender<()>);
impl Drop for ExitSignal {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
let dir = std::env::temp_dir().join(format!("diskwatch-drop-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let missing = dir.join("diskwatch-drop-missing");
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let (exit_tx, exit_rx) = std::sync::mpsc::channel::<()>();
let w = HotFileWatcher::start_background_inner(
&[dir.as_path(), missing.as_path()],
move || {
let _ = release_rx.recv_timeout(Duration::from_secs(5));
ExitSignal(exit_tx)
},
|| (),
None,
);
let state = w.state.clone();
drop(w);
assert!(
exit_rx.try_recv().is_err(),
"Drop waited for the in-flight installation"
);
let _ = release_tx.send(());
exit_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker must terminate after Drop requests cancellation");
let s = state.lock().unwrap();
assert!(s.watch_roots.is_empty(), "{:?}", s.watch_roots);
assert!(s.error.is_none(), "{:?}", s.error);
}
#[test]
fn a_cancelled_transfer_rolls_back_the_published_roots() {
struct ExitSignal(std::sync::mpsc::Sender<()>);
impl Drop for ExitSignal {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
let dir = std::env::temp_dir().join(format!("diskwatch-rollback-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
let (reached_tx, reached_rx) = std::sync::mpsc::channel::<()>();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let (exit_tx, exit_rx) = std::sync::mpsc::channel::<()>();
let w = HotFileWatcher::start_background_inner(
&[dir.as_path()],
|| (),
move || {
reached_tx.send(()).expect("signal the transfer point");
let _ = release_rx.recv_timeout(Duration::from_secs(5));
ExitSignal(exit_tx)
},
None,
);
let state = w.state.clone();
let watcher_slot = w._watcher.clone();
reached_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker must reach the transfer point");
assert_eq!(state.lock().unwrap().watch_roots, vec![dir.clone()]);
assert!(watcher_slot.lock().unwrap().is_none());
drop(w);
assert!(exit_rx.try_recv().is_err(), "Drop waited out the transfer");
{
let s = state.lock().unwrap();
assert!(
s.watch_roots.is_empty(),
"a publication that landed before Drop must be retired by it"
);
assert!(s.error.is_none(), "{:?}", s.error);
}
let _ = release_tx.send(());
exit_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker must terminate after the cancellation");
assert!(
watcher_slot.lock().unwrap().is_none(),
"a cancelled transfer must not store the watcher"
);
#[cfg(target_os = "linux")]
assert_ne!(
wait_for_watch_release(file_key(&dir)),
Some(false),
"the abandoned watcher must release its watch"
);
let s = state.lock().unwrap();
assert!(s.watch_roots.is_empty(), "{:?}", s.watch_roots);
assert!(s.error.is_none(), "{:?}", s.error);
}
#[test]
fn a_publication_racing_drop_leaves_no_stale_state() {
struct ExitSignal(std::sync::mpsc::Sender<()>);
impl Drop for ExitSignal {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
const ROUNDS: usize = 256;
let dir = std::env::temp_dir().join(format!("diskwatch-racelock-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let _guard = TempDirGuard(dir.clone());
for round in 0..ROUNDS {
let (reached_tx, reached_rx) = std::sync::mpsc::channel::<()>();
let (go_tx, go_rx) = std::sync::mpsc::channel::<()>();
let (exit_tx, exit_rx) = std::sync::mpsc::channel::<()>();
let w = HotFileWatcher::start_background_inner(
&[dir.as_path()],
|| (),
move || ExitSignal(exit_tx),
Some(Box::new(move |_root: &Path| {
reached_tx.send(()).expect("signal the last root");
let _ = go_rx.recv_timeout(Duration::from_secs(5));
})),
);
let state = w.state.clone();
let (held_tx, held_rx) = std::sync::mpsc::channel::<()>();
let (let_go_tx, let_go_rx) = std::sync::mpsc::channel::<()>();
let state_holder = state.clone();
let holder = thread::spawn(move || {
let _guard = state_holder.lock().unwrap();
held_tx.send(()).expect("signal the held lock");
let _ = let_go_rx.recv_timeout(Duration::from_secs(5));
});
held_rx
.recv_timeout(Duration::from_secs(5))
.expect("the holder must take the state lock");
reached_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker must reach the last root");
if round == 0 {
#[cfg(target_os = "linux")]
if let Some(seen) = inotify_watched_files() {
assert!(
seen.contains(&file_key(&dir)),
"the root must be watched before the race starts"
);
}
}
let (dropped_tx, dropped_rx) = std::sync::mpsc::channel::<()>();
let _ = go_tx.send(());
let dropper = thread::spawn(move || {
drop(w);
let _ = dropped_tx.send(());
});
let _ = let_go_tx.send(());
dropped_rx
.recv_timeout(Duration::from_secs(5))
.expect("Drop must return without waiting out the install");
holder.join().expect("holder thread");
dropper.join().expect("dropper thread");
{
let s = state.lock().unwrap();
assert!(
s.watch_roots.is_empty(),
"round {round}: roots after Drop: {:?}",
s.watch_roots
);
assert!(s.error.is_none(), "round {round}: error {:?}", s.error);
}
exit_rx
.recv_timeout(Duration::from_secs(5))
.expect("the worker must terminate after the cancellation");
#[cfg(target_os = "linux")]
assert_ne!(
wait_for_watch_release(file_key(&dir)),
Some(false),
"round {round}: the abandoned watcher must release its watch"
);
{
let s = state.lock().unwrap();
assert!(
s.watch_roots.is_empty(),
"round {round}: roots after the worker exited: {:?}",
s.watch_roots
);
assert!(s.error.is_none(), "round {round}: error {:?}", s.error);
}
}
}
fn mount(dev: &str, at: &str, ty: &str) -> crate::collect::FsTick {
crate::collect::FsTick {
mount: at.into(),
device: dev.into(),
fs_type: ty.into(),
size_bytes: 0,
used_bytes: 0,
avail_bytes: 0,
inode_pct: None,
is_removable: false,
is_system: false,
ro_image: false,
ignored: false,
}
}
fn act(items: &[(&str, f64)]) -> Vec<(PathBuf, f64)> {
items.iter().map(|(p, r)| (PathBuf::from(p), *r)).collect()
}
#[test]
fn activity_lands_on_the_pool_that_owns_the_mount() {
let fs = vec![
mount("/dev/nvme0n1p2", "/", "ext4"),
mount("tank/data", "/tank/data", "zfs"),
mount("bpool/boot", "/boot", "zfs"),
];
let roots = vec![PathBuf::from("/tank/data"), PathBuf::from("/boot")];
let a = act(&[
("/tank/data/db.sqlite", 40.0),
("/tank/data/log", 10.0),
("/boot/grub/grubenv", 2.0),
("/home/x/file", 99.0), ]);
let out = attribute_to_pools(&a, &fs, &roots);
assert_eq!(out["tank"].events_per_sec, 50.0);
assert_eq!(out["tank"].top[0].0, PathBuf::from("/tank/data/db.sqlite"));
assert_eq!(out["bpool"].events_per_sec, 2.0);
assert!(out["tank"].watched);
}
#[test]
fn a_parent_directory_event_is_not_counted_twice() {
let fs = vec![mount("tank/data", "/tank/data", "zfs")];
let roots = vec![PathBuf::from("/tank/data")];
let a = act(&[("/tank/data/db", 30.0), ("/tank/data/db/x", 30.0)]);
let out = attribute_to_pools(&a, &fs, &roots);
assert_eq!(out["tank"].events_per_sec, 30.0);
assert_eq!(out["tank"].top.len(), 1);
}
#[test]
fn the_deepest_mount_wins_over_an_enclosing_dataset() {
let fs = vec![
mount("tank/data", "/tank/data", "zfs"),
mount("/dev/sdb1", "/tank/data/usb", "ext4"),
];
let roots = vec![PathBuf::from("/tank/data")];
let a = act(&[("/tank/data/usb/f", 5.0), ("/tank/data/g", 1.0)]);
let out = attribute_to_pools(&a, &fs, &roots);
assert_eq!(out["tank"].events_per_sec, 1.0);
}
#[test]
fn an_unwatched_pool_is_distinguished_from_an_idle_one() {
let fs = vec![mount("tank/data", "/tank/data", "zfs")];
let out = attribute_to_pools(&[], &fs, &[PathBuf::from("/home/x")]);
assert!(!out["tank"].watched);
let out = attribute_to_pools(&[], &fs, &[PathBuf::from("/tank")]);
assert!(out["tank"].watched);
assert_eq!(out["tank"].events_per_sec, 0.0);
}
#[test]
fn zfs_mounts_become_default_roots_except_slash() {
let fs = vec![
mount("rpool/ROOT", "/", "zfs"),
mount("tank/data", "/tank/data", "zfs"),
mount("/dev/sda1", "/mnt", "ext4"),
];
assert_eq!(zfs_mount_roots(&fs), vec![PathBuf::from("/tank/data")]);
}
}