aboutsummaryrefslogtreecommitdiff
path: root/src/utils/watcher.rs
diff options
context:
space:
mode:
authorIvan Molodetskikh <yalterz@gmail.com>2024-02-21 10:45:03 +0400
committerIvan Molodetskikh <yalterz@gmail.com>2024-02-21 10:45:03 +0400
commit9e60b344d06979ade51e49eaa766198e3133b909 (patch)
treef57d794a14dcc23ff15967b75ec7b11faa584366 /src/utils/watcher.rs
parent2c01cde9beaacd8abf5f5b3117b9e006349bd633 (diff)
downloadniri-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.rs348
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(())
+ },
+ );
+ }
+}