use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use crate::log;
pub const DEBOUNCE: Duration = Duration::from_millis(250);
pub const SAFETY: Duration = Duration::from_secs(10);
pub const DISCONNECTED_POLL: Duration = Duration::from_secs(1);
const QUIET_LOG: Duration = Duration::from_secs(60);
fn quiet_tick_message(dir: &str, run: u32, since_last_log: Option<Duration>) -> Option<String> {
if since_last_log.is_some_and(|d| d < QUIET_LOG) {
return None;
}
Some(if run <= 1 {
format!("tick {dir} unchanged")
} else {
format!("tick {dir} unchanged (x{run})")
})
}
pub struct StoreWatcher {
stop: Arc<AtomicBool>,
wake: Sender<()>,
#[cfg(test)]
attached: Arc<AtomicBool>,
}
impl StoreWatcher {
#[cfg(test)]
fn is_attached(&self) -> bool {
self.attached.load(Ordering::Relaxed)
}
}
impl Drop for StoreWatcher {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
let _ = self.wake.send(());
}
}
type Stat = (SystemTime, u64, u64);
fn stat(store_dir: &str) -> Option<Stat> {
let meta = std::fs::metadata(Path::new(store_dir).join("tasks.jsonl")).ok()?;
let mtime = meta.modified().ok()?;
Some((mtime, meta.len(), inode(&meta)))
}
#[cfg(unix)]
fn inode(meta: &std::fs::Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
meta.ino()
}
#[cfg(not(unix))]
fn inode(_meta: &std::fs::Metadata) -> u64 {
0
}
fn attach(store_dir: &str, raw_tx: &Sender<()>) -> Option<RecommendedWatcher> {
if !Path::new(store_dir).is_dir() {
return None;
}
let tx = raw_tx.clone();
let mut w = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = tx.send(());
}
})
.ok()?;
w.watch(Path::new(store_dir), RecursiveMode::NonRecursive)
.ok()?;
Some(w)
}
pub fn spawn(store_dir: &str, out: Sender<()>) -> StoreWatcher {
spawn_inner(store_dir, out, SAFETY)
}
fn spawn_inner(store_dir: &str, out: Sender<()>, safety: Duration) -> StoreWatcher {
let (raw_tx, raw_rx): (Sender<()>, Receiver<()>) = channel();
let mut watcher = attach(store_dir, &raw_tx);
log::line(
"watch",
&match &watcher {
Some(_) => format!("registered {store_dir}"),
None => format!("no watcher for {store_dir}; polling until it exists"),
},
);
let mut last = stat(store_dir);
let mut quiet_run: u32 = 0;
let mut quiet_logged_at: Option<Instant> = None;
let stop = Arc::new(AtomicBool::new(false));
let stopped = Arc::clone(&stop);
let attached = Arc::new(AtomicBool::new(watcher.is_some()));
let is_attached = Arc::clone(&attached);
let wake = raw_tx.clone();
let dir = store_dir.to_string();
thread::spawn(move || {
let raw_tx = raw_tx;
loop {
if stopped.load(Ordering::Relaxed) {
return;
}
let interval = match watcher {
Some(_) => safety,
None => safety.min(DISCONNECTED_POLL),
};
let received = raw_rx.recv_timeout(interval);
if stopped.load(Ordering::Relaxed) {
return;
}
match received {
Ok(()) => {
log::line("watch", &format!("event {dir}"));
while raw_rx.recv_timeout(DEBOUNCE).is_ok() {}
last = stat(&dir);
quiet_run = 0;
quiet_logged_at = None;
if out.send(()).is_err() {
return;
}
}
Err(RecvTimeoutError::Timeout) => {
let present = Path::new(&dir).is_dir();
if !present {
watcher = None;
} else if watcher.is_none() {
watcher = attach(&dir, &raw_tx);
if watcher.is_some() {
log::line("watch", &format!("registered {dir} (late)"));
}
}
is_attached.store(watcher.is_some(), Ordering::Relaxed);
let now = stat(&dir);
if now != last {
log::line("watch", &format!("tick {dir} changed"));
last = now;
quiet_run = 0;
quiet_logged_at = None;
if out.send(()).is_err() {
return;
}
} else {
quiet_run += 1;
let since = quiet_logged_at.map(|t| t.elapsed());
if let Some(msg) = quiet_tick_message(&dir, quiet_run, since) {
log::line("watch", &msg);
quiet_logged_at = Some(Instant::now());
quiet_run = 0;
}
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
});
StoreWatcher {
stop,
wake,
#[cfg(test)]
attached,
}
}
pub fn spawn_many(dirs: &[String], out: Sender<String>) -> Vec<StoreWatcher> {
dirs.iter()
.map(|dir| {
let (tx, rx) = channel::<()>();
let guard = spawn(dir, tx);
let out = out.clone();
let dir = dir.clone();
std::thread::spawn(move || {
while rx.recv().is_ok() {
if out.send(dir.clone()).is_err() {
return;
}
}
});
guard
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!("dextui-watch-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> &str {
self.0.to_str().unwrap()
}
fn write(&self, contents: &str) {
fs::write(self.0.join("tasks.jsonl"), contents).unwrap();
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn fires_when_the_store_file_changes() {
let dir = TempDir::new("fires");
let (tx, rx) = channel();
let _w = spawn(dir.path(), tx);
dir.write(r#"{"id":"a"}"#);
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"watcher did not fire on a file write"
);
}
#[test]
fn collapses_a_burst_of_writes() {
let dir = TempDir::new("burst");
let (tx, rx) = channel();
let _w = spawn(dir.path(), tx);
for i in 0..10 {
dir.write(&format!(r#"{{"id":"a","n":{i}}}"#));
thread::sleep(Duration::from_millis(20));
}
let seen = Arc::new(AtomicUsize::new(0));
let deadline = std::time::Instant::now() + Duration::from_millis(1500);
while std::time::Instant::now() < deadline {
if rx.recv_timeout(Duration::from_millis(200)).is_ok() {
seen.fetch_add(1, Ordering::SeqCst);
}
}
let n = seen.load(Ordering::SeqCst);
assert!((1..=3).contains(&n), "expected a coalesced burst, got {n}");
}
#[test]
fn a_change_reports_which_store_it_came_from() {
let dir = std::env::temp_dir().join("dextui-watch-many");
let a = dir.join("a");
let b = dir.join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let _guards = spawn_many(
&[
a.to_string_lossy().into_owned(),
b.to_string_lossy().into_owned(),
],
tx,
);
std::fs::write(b.join("tasks.jsonl"), "{}").unwrap();
let got = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert!(got.ends_with("/b"), "reported the wrong store: {got}");
}
#[test]
fn falls_back_to_the_safety_poll_when_the_store_is_missing() {
let dir = TempDir::new("missing");
let missing = dir.0.join("not-created-yet");
let (tx, rx) = channel();
let _w = spawn(missing.to_str().unwrap(), tx);
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
}
#[test]
fn stat_of_a_missing_store_is_none() {
let dir = TempDir::new("stat-missing");
assert_eq!(stat(dir.path()), None);
}
#[test]
fn stat_appears_once_the_file_does() {
let dir = TempDir::new("stat-appears");
assert_eq!(stat(dir.path()), None, "nothing written yet");
dir.write(r#"{"id":"a"}"#);
assert!(stat(dir.path()).is_some(), "the file now exists");
}
#[test]
fn stat_is_stable_when_nothing_touches_the_file() {
let dir = TempDir::new("stat-stable");
dir.write(r#"{"id":"a"}"#);
let first = stat(dir.path());
let second = stat(dir.path());
assert_eq!(first, second, "two reads with no write between them must agree");
}
#[test]
fn stat_changes_when_the_file_does() {
let dir = TempDir::new("stat-changes");
dir.write(r#"{"id":"a"}"#);
let before = stat(dir.path());
dir.write(r#"{"id":"a","extra":"field makes this a different length"}"#);
let after = stat(dir.path());
assert_ne!(before, after, "a real write must change the fingerprint");
}
const FAST: Duration = Duration::from_millis(60);
#[test]
fn a_safety_timeout_with_nothing_changed_emits_nothing() {
let dir = TempDir::new("net-quiet");
dir.write(r#"{"id":"a"}"#);
let (tx, rx) = channel();
let _w = spawn_inner(dir.path(), tx, FAST);
assert!(
rx.recv_timeout(Duration::from_millis(400)).is_err(),
"an untouched store must not emit on the safety timeout"
);
}
#[test]
fn a_change_no_notify_watcher_could_see_is_still_caught_by_the_timeout() {
let dir = TempDir::new("net-catches-misses");
let store = dir.0.join("appears-later");
let (tx, rx) = channel();
let _w = spawn_inner(store.to_str().unwrap(), tx, FAST);
assert!(
rx.recv_timeout(Duration::from_millis(150)).is_err(),
"nothing to report before the store exists"
);
fs::create_dir_all(&store).unwrap();
fs::write(store.join("tasks.jsonl"), r#"{"id":"a"}"#).unwrap();
assert!(
rx.recv_timeout(Duration::from_millis(500)).is_ok(),
"a change no notify watcher could see was not caught by the safety timeout"
);
}
#[test]
fn a_store_created_after_launch_gets_a_real_watcher_not_just_the_poll() {
let dir = TempDir::new("late-attach");
let store = dir.0.join("appears-later");
let (tx, rx) = channel();
let w = spawn_inner(store.to_str().unwrap(), tx, FAST);
assert!(!w.is_attached(), "nothing to attach to yet");
fs::create_dir_all(&store).unwrap();
fs::write(store.join("tasks.jsonl"), r#"{"id":"a"}"#).unwrap();
assert!(
rx.recv_timeout(Duration::from_secs(2)).is_ok(),
"the poll never found a store that appeared after launch"
);
assert!(
w.is_attached(),
"the store was found but never watched -- it stays on the poll's \
interval for the life of the process"
);
}
#[test]
fn a_write_immediately_after_spawn_is_not_swallowed_by_the_baseline() {
let dir = TempDir::new("baseline-race");
let store = dir.0.join("appears-immediately");
let (tx, rx) = channel();
let _w = spawn_inner(store.to_str().unwrap(), tx, FAST);
fs::create_dir_all(&store).unwrap();
fs::write(store.join("tasks.jsonl"), r#"{"id":"a"}"#).unwrap();
assert!(
rx.recv_timeout(Duration::from_secs(2)).is_ok(),
"a store that appeared in the gap after spawn was never reported"
);
}
#[test]
fn a_store_deleted_and_recreated_is_watched_again() {
let dir = TempDir::new("re-attach");
let store = dir.0.join("goes-away");
fs::create_dir_all(&store).unwrap();
let (tx, _rx) = channel();
let w = spawn_inner(store.to_str().unwrap(), tx, FAST);
assert!(w.is_attached(), "the store existed at spawn");
fs::remove_dir_all(&store).unwrap();
thread::sleep(Duration::from_millis(900));
assert!(!w.is_attached(), "still claims to watch a store that is gone");
fs::create_dir_all(&store).unwrap();
thread::sleep(Duration::from_millis(900));
assert!(w.is_attached(), "never re-attached to the recreated store");
}
#[test]
fn dropping_the_guard_stops_the_thread_promptly() {
let dir = TempDir::new("guard-stops");
dir.write(r#"{"id":"a"}"#);
let (tx, rx) = channel();
let w = spawn_inner(dir.path(), tx, FAST);
drop(w);
thread::sleep(Duration::from_millis(200));
dir.write(r#"{"id":"a","changed":true}"#);
assert!(
rx.recv_timeout(Duration::from_millis(500)).is_err(),
"a dropped watcher is still reporting changes"
);
}
#[test]
fn the_first_quiet_tick_after_activity_always_logs_in_full() {
assert_eq!(
quiet_tick_message("/x", 1, None),
Some("tick /x unchanged".to_string())
);
}
#[test]
fn a_quiet_tick_stays_silent_before_the_window_elapses() {
assert_eq!(
quiet_tick_message("/x", 5, Some(Duration::from_secs(30))),
None,
"logged again before a minute of quiet had passed"
);
}
#[test]
fn a_quiet_tick_logs_once_the_window_elapses_and_reports_the_run() {
assert_eq!(
quiet_tick_message("/x", 60, Some(QUIET_LOG)),
Some("tick /x unchanged (x60)".to_string()),
"the run should be reported once the window is up"
);
}
#[test]
fn a_lone_quiet_tick_after_a_long_gap_has_no_count_suffix() {
assert_eq!(
quiet_tick_message("/x", 1, Some(QUIET_LOG)),
Some("tick /x unchanged".to_string())
);
}
#[test]
fn a_missing_tasks_file_emits_nothing_until_it_appears() {
let dir = TempDir::new("net-file-appears");
let (tx, rx) = channel();
let _w = spawn_inner(dir.path(), tx, FAST);
assert!(
rx.recv_timeout(Duration::from_millis(150)).is_err(),
"no tasks.jsonl yet, so there is nothing to report"
);
dir.write(r#"{"id":"a"}"#);
assert!(
rx.recv_timeout(Duration::from_millis(500)).is_ok(),
"the file's appearance must be reported"
);
}
}