diff options
| author | Ivan Molodetskikh <yalterz@gmail.com> | 2024-02-21 10:45:03 +0400 |
|---|---|---|
| committer | Ivan Molodetskikh <yalterz@gmail.com> | 2024-02-21 10:45:03 +0400 |
| commit | 9e60b344d06979ade51e49eaa766198e3133b909 (patch) | |
| tree | f57d794a14dcc23ff15967b75ec7b11faa584366 /src/utils/watcher.rs | |
| parent | 2c01cde9beaacd8abf5f5b3117b9e006349bd633 (diff) | |
| download | niri-9e60b344d06979ade51e49eaa766198e3133b909.tar.gz niri-9e60b344d06979ade51e49eaa766198e3133b909.tar.bz2 niri-9e60b344d06979ade51e49eaa766198e3133b909.zip | |
Move watcher to utils
Diffstat (limited to 'src/utils/watcher.rs')
| -rw-r--r-- | src/utils/watcher.rs | 348 |
1 files changed, 348 insertions, 0 deletions
diff --git a/src/utils/watcher.rs b/src/utils/watcher.rs new file mode 100644 index 00000000..84fdab5c --- /dev/null +++ b/src/utils/watcher.rs @@ -0,0 +1,348 @@ +//! File modification watcher. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread; +use std::time::Duration; + +use smithay::reexports::calloop::channel::SyncSender; + +pub struct Watcher { + should_stop: Arc<AtomicBool>, +} + +impl Drop for Watcher { + fn drop(&mut self) { + self.should_stop.store(true, Ordering::SeqCst); + } +} + +impl Watcher { + pub fn new(path: PathBuf, changed: SyncSender<()>) -> Self { + Self::with_start_notification(path, changed, None) + } + + pub fn with_start_notification( + path: PathBuf, + changed: SyncSender<()>, + started: Option<mpsc::SyncSender<()>>, + ) -> Self { + let should_stop = Arc::new(AtomicBool::new(false)); + + { + let should_stop = should_stop.clone(); + thread::Builder::new() + .name(format!("Filesystem Watcher for {}", path.to_string_lossy())) + .spawn(move || { + // this "should" be as simple as mtime, but it does not quite work in practice; + // it doesn't work if the config is a symlink, and its target changes but the + // new target and old target have identical mtimes. + // + // in practice, this does not occur on any systems other than nix. + // because, on nix practically everything is a symlink to /nix/store + // and due to reproducibility, /nix/store keeps no mtime (= 1970-01-01) + // so, symlink targets change frequently when mtime doesn't. + let mut last_props = path + .canonicalize() + .and_then(|canon| Ok((canon.metadata()?.modified()?, canon))) + .ok(); + + if let Some(started) = started { + let _ = started.send(()); + } + + loop { + thread::sleep(Duration::from_millis(500)); + + if should_stop.load(Ordering::SeqCst) { + break; + } + + if let Ok(new_props) = path + .canonicalize() + .and_then(|canon| Ok((canon.metadata()?.modified()?, canon))) + { + if last_props.as_ref() != Some(&new_props) { + trace!("file changed: {}", path.to_string_lossy()); + + if let Err(err) = changed.send(()) { + warn!("error sending change notification: {err:?}"); + break; + } + + last_props = Some(new_props); + } + } + } + + debug!("exiting watcher thread for {}", path.to_string_lossy()); + }) + .unwrap(); + } + + Self { should_stop } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::fs::File; + use std::io::Write; + use std::sync::atomic::AtomicU8; + + use calloop::channel::sync_channel; + use calloop::EventLoop; + use smithay::reexports::rustix::fs::{futimens, Timestamps}; + use smithay::reexports::rustix::time::Timespec; + use xshell::{cmd, Shell}; + + use super::*; + + fn check( + setup: impl FnOnce(&Shell) -> Result<(), Box<dyn Error>>, + change: impl FnOnce(&Shell) -> Result<(), Box<dyn Error>>, + ) { + let sh = Shell::new().unwrap(); + let temp_dir = sh.create_temp_dir().unwrap(); + sh.change_dir(temp_dir.path()); + // let dir = sh.create_dir("xshell").unwrap(); + // sh.change_dir(dir); + + let mut config_path = sh.current_dir(); + config_path.push("niri"); + config_path.push("config.kdl"); + + setup(&sh).unwrap(); + + let changed = AtomicU8::new(0); + + let mut event_loop = EventLoop::try_new().unwrap(); + let loop_handle = event_loop.handle(); + + let (tx, rx) = sync_channel(1); + let (started_tx, started_rx) = mpsc::sync_channel(1); + let _watcher = Watcher::with_start_notification(config_path.clone(), tx, Some(started_tx)); + loop_handle + .insert_source(rx, |_, _, _| { + changed.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + started_rx.recv().unwrap(); + + // HACK: if we don't sleep, files might have the same mtime. + thread::sleep(Duration::from_millis(100)); + + change(&sh).unwrap(); + + event_loop + .dispatch(Duration::from_millis(750), &mut ()) + .unwrap(); + + assert_eq!(changed.load(Ordering::SeqCst), 1); + + // Verify that the watcher didn't break. + sh.write_file(&config_path, "c").unwrap(); + + event_loop + .dispatch(Duration::from_millis(750), &mut ()) + .unwrap(); + + assert_eq!(changed.load(Ordering::SeqCst), 2); + } + + #[test] + fn change_file() { + check( + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + |sh| { + sh.write_file("niri/config.kdl", "b")?; + Ok(()) + }, + ); + } + + #[test] + fn create_file() { + check( + |sh| { + sh.create_dir("niri")?; + Ok(()) + }, + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + ); + } + + #[test] + fn create_dir_and_file() { + check( + |_sh| Ok(()), + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + ); + } + + #[test] + fn change_linked_file() { + check( + |sh| { + sh.write_file("niri/config2.kdl", "a")?; + cmd!(sh, "ln -s config2.kdl niri/config.kdl").run()?; + Ok(()) + }, + |sh| { + sh.write_file("niri/config2.kdl", "b")?; + Ok(()) + }, + ); + } + + #[test] + fn change_file_in_linked_dir() { + check( + |sh| { + sh.write_file("niri2/config.kdl", "a")?; + cmd!(sh, "ln -s niri2 niri").run()?; + Ok(()) + }, + |sh| { + sh.write_file("niri2/config.kdl", "b")?; + Ok(()) + }, + ); + } + + #[test] + fn recreate_file() { + check( + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + |sh| { + sh.remove_path("niri/config.kdl")?; + sh.write_file("niri/config.kdl", "b")?; + Ok(()) + }, + ); + } + + #[test] + fn recreate_dir() { + check( + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + |sh| { + sh.remove_path("niri")?; + sh.write_file("niri/config.kdl", "b")?; + Ok(()) + }, + ); + } + + #[test] + fn swap_dir() { + check( + |sh| { + sh.write_file("niri/config.kdl", "a")?; + Ok(()) + }, + |sh| { + sh.write_file("niri2/config.kdl", "b")?; + sh.remove_path("niri")?; + cmd!(sh, "mv niri2 niri").run()?; + Ok(()) + }, + ); + } + + #[test] + fn swap_just_link() { + // NixOS setup: link path changes, mtime stays constant. + check( + |sh| { + let mut dir = sh.current_dir(); + dir.push("niri"); + sh.create_dir(&dir)?; + + let mut d2 = dir.clone(); + d2.push("config2.kdl"); + let mut c2 = File::create(d2).unwrap(); + write!(c2, "a")?; + c2.flush()?; + futimens( + &c2, + &Timestamps { + last_access: Timespec { + tv_sec: 0, + tv_nsec: 0, + }, + last_modification: Timespec { + tv_sec: 0, + tv_nsec: 0, + }, + }, + )?; + c2.sync_all()?; + drop(c2); + + let mut d3 = dir.clone(); + d3.push("config3.kdl"); + let mut c3 = File::create(d3).unwrap(); + write!(c3, "b")?; + c3.flush()?; + futimens( + &c3, + &Timestamps { + last_access: Timespec { + tv_sec: 0, + tv_nsec: 0, + }, + last_modification: Timespec { + tv_sec: 0, + tv_nsec: 0, + }, + }, + )?; + c3.sync_all()?; + drop(c3); + + cmd!(sh, "ln -s config2.kdl niri/config.kdl").run()?; + Ok(()) + }, + |sh| { + cmd!(sh, "unlink niri/config.kdl").run()?; + cmd!(sh, "ln -s config3.kdl niri/config.kdl").run()?; + Ok(()) + }, + ); + } + + #[test] + fn swap_dir_link() { + check( + |sh| { + sh.write_file("niri2/config.kdl", "a")?; + cmd!(sh, "ln -s niri2 niri").run()?; + Ok(()) + }, + |sh| { + sh.write_file("niri3/config.kdl", "b")?; + cmd!(sh, "unlink niri").run()?; + cmd!(sh, "ln -s niri3 niri").run()?; + Ok(()) + }, + ); + } +} |
