aboutsummaryrefslogtreecommitdiff
path: root/src/utils/watcher.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/watcher.rs')
-rw-r--r--src/utils/watcher.rs660
1 files changed, 453 insertions, 207 deletions
diff --git a/src/utils/watcher.rs b/src/utils/watcher.rs
index 069e67f8..635e95e7 100644
--- a/src/utils/watcher.rs
+++ b/src/utils/watcher.rs
@@ -3,9 +3,10 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
-use std::thread;
-use std::time::Duration;
+use std::time::{Duration, SystemTime};
+use std::{io, thread};
+use niri_config::ConfigPath;
use smithay::reexports::calloop::channel::SyncSender;
pub struct Watcher {
@@ -20,58 +21,76 @@ impl Drop for Watcher {
impl Watcher {
pub fn new<T: Send + 'static>(
- path: PathBuf,
- process: impl FnMut(&Path) -> T + Send + 'static,
+ path: ConfigPath,
+ process: impl FnMut(&ConfigPath) -> T + Send + 'static,
changed: SyncSender<T>,
) -> Self {
- Self::with_start_notification(path, process, changed, None)
+ let interval = Duration::from_millis(500);
+ Self::with_start_notification(path, process, changed, None, interval)
}
pub fn with_start_notification<T: Send + 'static>(
- path: PathBuf,
- mut process: impl FnMut(&Path) -> T + Send + 'static,
+ config_path: ConfigPath,
+ mut process: impl FnMut(&ConfigPath) -> T + Send + 'static,
changed: SyncSender<T>,
started: Option<mpsc::SyncSender<()>>,
+ polling_interval: Duration,
) -> 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()))
+ .name(format!("Filesystem Watcher for {config_path:?}"))
.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.
+ // this "should" be as simple as storing the last seen mtime,
+ // and if the contents change without updating mtime, we ignore it.
//
- // 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)
+ // but that breaks if the config is a symlink, and its target
+ // changes but the new target and old target have identical mtimes.
+ // in which case we should *not* ignore it; this is an entirely different file.
+ //
+ // in practice, this edge case does not occur on systems other than nix.
+ // because, on nix, everything is a symlink to /nix/store
+ // and /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();
+ //
+ // therefore, we must also store the canonical path, along with its mtime
+
+ fn see_path(path: &Path) -> io::Result<(SystemTime, PathBuf)> {
+ let canon = path.canonicalize()?;
+ let mtime = canon.metadata()?.modified()?;
+ Ok((mtime, canon))
+ }
+
+ fn see(config_path: &ConfigPath) -> io::Result<(SystemTime, PathBuf)> {
+ match config_path {
+ ConfigPath::Explicit(path) => see_path(path),
+ ConfigPath::Regular {
+ user_path,
+ system_path,
+ } => see_path(user_path).or_else(|_| see_path(system_path)),
+ }
+ }
+
+ let mut last_props = see(&config_path).ok();
if let Some(started) = started {
let _ = started.send(());
}
loop {
- thread::sleep(Duration::from_millis(500));
+ thread::sleep(polling_interval);
if should_stop.load(Ordering::SeqCst) {
break;
}
- if let Ok(new_props) = path
- .canonicalize()
- .and_then(|canon| Ok((canon.metadata()?.modified()?, canon)))
- {
+ if let Ok(new_props) = see(&config_path) {
if last_props.as_ref() != Some(&new_props) {
- trace!("file changed: {}", path.to_string_lossy());
+ trace!("config file changed");
- let rv = process(&path);
+ let rv = process(&config_path);
if let Err(err) = changed.send(rv) {
warn!("error sending change notification: {err:?}");
@@ -83,7 +102,7 @@ impl Watcher {
}
}
- debug!("exiting watcher thread for {}", path.to_string_lossy());
+ debug!("exiting watcher thread for {config_path:?}");
})
.unwrap();
}
@@ -95,262 +114,489 @@ impl Watcher {
#[cfg(test)]
mod tests {
use std::error::Error;
- use std::fs::File;
+ use std::fs::{self, File, FileTimes};
use std::io::Write;
- use std::sync::atomic::AtomicU8;
- use calloop::channel::sync_channel;
+ use calloop::channel::{sync_channel, Event};
use calloop::EventLoop;
- use smithay::reexports::rustix::fs::{futimens, Timestamps};
- use smithay::reexports::rustix::time::Timespec;
- use xshell::{cmd, Shell};
+ use xshell::{cmd, Shell, TempDir};
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);
+ type Result<T = (), E = Box<dyn Error>> = std::result::Result<T, E>;
+
+ fn canon(config_path: &ConfigPath) -> &PathBuf {
+ match config_path {
+ ConfigPath::Explicit(path) => path,
+ ConfigPath::Regular {
+ user_path,
+ system_path,
+ } => {
+ if user_path.exists() {
+ user_path
+ } else {
+ system_path
+ }
+ }
+ }
+ }
+
+ enum TestPath<P> {
+ Explicit(P),
+ Regular { user_path: P, system_path: P },
+ }
+
+ impl<P: AsRef<Path>> TestPath<P> {
+ fn setup<Discard>(
+ self,
+ setup: impl FnOnce(&Shell) -> xshell::Result<Discard>,
+ ) -> TestSetup {
+ self.setup_any(|sh| {
+ _ = setup(sh)?;
+ Ok(())
})
- .unwrap();
- started_rx.recv().unwrap();
+ }
+
+ fn without_setup(self) -> TestSetup {
+ self.setup_any(|_| Ok(())).assert_initial_not_exists()
+ }
+
+ fn setup_any(self, setup: impl FnOnce(&Shell) -> Result) -> TestSetup {
+ let sh = Shell::new().unwrap();
+ let temp_dir = sh.create_temp_dir().unwrap();
+ sh.change_dir(temp_dir.path());
+
+ let dir = sh.current_dir();
+ let config_path = match self {
+ TestPath::Explicit(path) => ConfigPath::Explicit(dir.join(path)),
+ TestPath::Regular {
+ user_path,
+ system_path,
+ } => ConfigPath::Regular {
+ user_path: dir.join(user_path),
+ system_path: dir.join(system_path),
+ },
+ };
+
+ setup(&sh).unwrap();
+
+ TestSetup {
+ sh,
+ config_path,
+ _temp_dir: temp_dir,
+ }
+ }
+ }
+
+ struct TestSetup {
+ sh: Shell,
+ config_path: ConfigPath,
+ _temp_dir: TempDir,
+ }
+
+ impl TestSetup {
+ fn assert_initial_not_exists(self) -> Self {
+ let canon = canon(&self.config_path);
+ assert!(!canon.exists(), "initial should not exist");
+ self
+ }
+
+ fn assert_initial(self, expected: &str) -> Self {
+ let canon = canon(&self.config_path);
+ assert!(canon.exists(), "initial should exist at {canon:?}");
+ let actual = fs::read_to_string(canon).unwrap();
+ assert_eq!(actual, expected, "initial file contents do not match");
+ self
+ }
+
+ fn run(self, body: impl FnOnce(&Shell, &mut TestUtil) -> Result) -> Result {
+ let TestSetup {
+ sh, config_path, ..
+ } = self;
+
+ let (tx, rx) = sync_channel(1);
+ let (started_tx, started_rx) = mpsc::sync_channel(1);
+
+ let _watcher = Watcher::with_start_notification(
+ config_path,
+ |config_path| canon(config_path).clone(),
+ tx,
+ Some(started_tx),
+ Duration::from_millis(100),
+ );
+
+ started_rx.recv()?;
+
+ let event_loop = EventLoop::try_new()?;
+ event_loop
+ .handle()
+ .insert_source(rx, |event, (), latest_path| {
+ if let Event::Msg(path) = event {
+ *latest_path = Some(path);
+ }
+ })?;
+
+ let mut test = TestUtil { event_loop };
+
+ // don't trigger before we start
+ test.assert_unchanged();
+ // pass_time() inside assert_unchanged() ensures that mtime
+ // isn't the same as the initial time
+
+ body(&sh, &mut test)?;
+
+ // nothing should trigger after the test runs
+ test.assert_unchanged();
+
+ Ok(())
+ }
+ }
- // HACK: if we don't sleep, files might have the same mtime.
- thread::sleep(Duration::from_millis(100));
+ struct TestUtil<'a> {
+ event_loop: EventLoop<'a, Option<PathBuf>>,
+ }
- change(&sh).unwrap();
+ impl<'a> TestUtil<'a> {
+ fn pass_time(&self) {
+ thread::sleep(Duration::from_millis(50));
+ }
- event_loop
- .dispatch(Duration::from_millis(750), &mut ())
- .unwrap();
+ fn assert_unchanged(&mut self) {
+ let mut new_path = None;
+ self.event_loop
+ .dispatch(Duration::from_millis(150), &mut new_path)
+ .unwrap();
+ assert_eq!(
+ new_path, None,
+ "watcher should not have noticed any changes"
+ );
- assert_eq!(changed.load(Ordering::SeqCst), 1);
+ self.pass_time();
+ }
- // Verify that the watcher didn't break.
- sh.write_file(&config_path, "c").unwrap();
+ fn assert_changed_to(&mut self, expected: &str) {
+ let mut new_path = None;
+ self.event_loop
+ .dispatch(Duration::from_millis(150), &mut new_path)
+ .unwrap();
+ let Some(new_path) = new_path else {
+ panic!("watcher should have noticed a change, but it didn't");
+ };
+ let actual = fs::read_to_string(&new_path).unwrap();
+ assert_eq!(actual, expected, "watcher gave the wrong file");
- event_loop
- .dispatch(Duration::from_millis(750), &mut ())
- .unwrap();
+ self.pass_time();
+ }
+ }
+
+ #[test]
+ fn change_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
+ sh.write_file("niri/config.kdl", "b")?;
+ test.assert_changed_to("b");
- assert_eq!(changed.load(Ordering::SeqCst), 2);
+ Ok(())
+ })
}
#[test]
- fn change_file() {
- check(
- |sh| {
+ fn overwrite_but_dont_change_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
sh.write_file("niri/config.kdl", "a")?;
+ test.assert_changed_to("a");
+
Ok(())
- },
- |sh| {
- sh.write_file("niri/config.kdl", "b")?;
- Ok(())
- },
- );
+ })
}
#[test]
- fn create_file() {
- check(
- |sh| {
- sh.create_dir("niri")?;
+ fn touch_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
+ cmd!(sh, "touch niri/config.kdl").run()?;
+ test.assert_changed_to("a");
+
Ok(())
- },
- |sh| {
+ })
+ }
+
+ #[test]
+ fn create_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.create_dir("niri"))
+ .assert_initial_not_exists()
+ .run(|sh, test| {
sh.write_file("niri/config.kdl", "a")?;
+ test.assert_changed_to("a");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn create_dir_and_file() {
- check(
- |_sh| Ok(()),
- |sh| {
+ fn create_dir_and_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .without_setup()
+ .run(|sh, test| {
sh.write_file("niri/config.kdl", "a")?;
+ test.assert_changed_to("a");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn change_linked_file() {
- check(
- |sh| {
+ fn change_linked_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| {
sh.write_file("niri/config2.kdl", "a")?;
- cmd!(sh, "ln -s config2.kdl niri/config.kdl").run()?;
- Ok(())
- },
- |sh| {
+ cmd!(sh, "ln -sf config2.kdl niri/config.kdl").run()
+ })
+ .assert_initial("a")
+ .run(|sh, test| {
sh.write_file("niri/config2.kdl", "b")?;
+ test.assert_changed_to("b");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn change_file_in_linked_dir() {
- check(
- |sh| {
+ fn change_file_in_linked_dir() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| {
sh.write_file("niri2/config.kdl", "a")?;
- cmd!(sh, "ln -s niri2 niri").run()?;
- Ok(())
- },
- |sh| {
+ cmd!(sh, "ln -s niri2 niri").run()
+ })
+ .assert_initial("a")
+ .run(|sh, test| {
sh.write_file("niri2/config.kdl", "b")?;
+ test.assert_changed_to("b");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn recreate_file() {
- check(
- |sh| {
- sh.write_file("niri/config.kdl", "a")?;
+ fn remove_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
+ sh.remove_path("niri/config.kdl")?;
+ test.assert_unchanged();
+
+ Ok(())
+ })
+ }
+
+ #[test]
+ fn remove_dir() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
+ sh.remove_path("niri")?;
+ test.assert_unchanged();
+
Ok(())
- },
- |sh| {
+ })
+ }
+
+ #[test]
+ fn recreate_file() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
sh.remove_path("niri/config.kdl")?;
sh.write_file("niri/config.kdl", "b")?;
+ test.assert_changed_to("b");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn recreate_dir() {
- check(
- |sh| {
+ fn recreate_dir() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| {
sh.write_file("niri/config.kdl", "a")?;
Ok(())
- },
- |sh| {
+ })
+ .assert_initial("a")
+ .run(|sh, test| {
sh.remove_path("niri")?;
sh.write_file("niri/config.kdl", "b")?;
+ test.assert_changed_to("b");
+
Ok(())
- },
- );
+ })
}
#[test]
- fn swap_dir() {
- check(
- |sh| {
- sh.write_file("niri/config.kdl", "a")?;
- Ok(())
- },
- |sh| {
+ fn swap_dir() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| sh.write_file("niri/config.kdl", "a"))
+ .assert_initial("a")
+ .run(|sh, test| {
sh.write_file("niri2/config.kdl", "b")?;
sh.remove_path("niri")?;
cmd!(sh, "mv niri2 niri").run()?;
+ test.assert_changed_to("b");
+
Ok(())
- },
- );
+ })
+ }
+
+ #[test]
+ fn swap_dir_link() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup(|sh| {
+ sh.write_file("niri2/config.kdl", "a")?;
+ cmd!(sh, "ln -s niri2 niri").run()
+ })
+ .assert_initial("a")
+ .run(|sh, test| {
+ sh.write_file("niri3/config.kdl", "b")?;
+ sh.remove_path("niri")?;
+ cmd!(sh, "ln -s niri3 niri").run()?;
+ test.assert_changed_to("b");
+
+ Ok(())
+ })
+ }
+
+ // Important: On systems like NixOS, mtime is not kept for config files.
+ // So, this is testing that the watcher handles that correctly.
+ fn create_epoch(path: impl AsRef<Path>, content: &str) -> Result {
+ let mut file = File::create(path)?;
+ file.write_all(content.as_bytes())?;
+ file.set_times(
+ FileTimes::new()
+ .set_accessed(SystemTime::UNIX_EPOCH)
+ .set_modified(SystemTime::UNIX_EPOCH),
+ )?;
+ file.sync_all()?;
+ 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");
+ fn swap_just_link() -> Result {
+ TestPath::Explicit("niri/config.kdl")
+ .setup_any(|sh| {
+ let dir = sh.current_dir().join("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);
+ create_epoch(dir.join("config2.kdl"), "a")?;
+ create_epoch(dir.join("config3.kdl"), "b")?;
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()?;
+ })
+ .assert_initial("a")
+ .run(|sh, test| {
+ cmd!(sh, "ln -sf config3.kdl niri/config.kdl").run()?;
+ test.assert_changed_to("b");
+
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(())
- },
- );
+ fn swap_many_regular() -> Result {
+ TestPath::Regular {
+ user_path: "user-niri/config.kdl",
+ system_path: "system-niri/config.kdl",
+ }
+ .setup(|sh| sh.write_file("system-niri/config.kdl", "system config"))
+ .assert_initial("system config")
+ .run(|sh, test| {
+ sh.write_file("user-niri/config.kdl", "user config")?;
+ test.assert_changed_to("user config");
+
+ cmd!(sh, "touch system-niri/config.kdl").run()?;
+ test.assert_unchanged();
+
+ sh.remove_path("system-niri")?;
+ test.assert_unchanged();
+
+ sh.write_file("system-niri/config.kdl", "new system config")?;
+ test.assert_unchanged();
+
+ sh.remove_path("user-niri")?;
+ test.assert_changed_to("new system config");
+
+ sh.write_file("system-niri/config.kdl", "updated system config")?;
+ test.assert_changed_to("updated system config");
+
+ sh.write_file("user-niri/config.kdl", "new user config")?;
+ test.assert_changed_to("new user config");
+
+ Ok(())
+ })
+ }
+
+ #[test]
+ fn swap_many_links_regular_like_nix() -> Result {
+ TestPath::Regular {
+ user_path: "user-niri/config.kdl",
+ system_path: "system-niri/config.kdl",
+ }
+ .setup_any(|sh| {
+ let store = sh.current_dir().join("store");
+
+ sh.create_dir(&store)?;
+
+ create_epoch(store.join("gen1"), "gen 1")?;
+ create_epoch(store.join("gen2"), "gen 2")?;
+ create_epoch(store.join("gen3"), "gen 3")?;
+
+ sh.create_dir("user-niri")?;
+ sh.create_dir("system-niri")?;
+
+ Ok(())
+ })
+ .assert_initial_not_exists()
+ .run(|sh, test| {
+ let store = sh.current_dir().join("store");
+ test.assert_unchanged();
+
+ cmd!(sh, "ln -s {store}/gen1 user-niri/config.kdl").run()?;
+ test.assert_changed_to("gen 1");
+
+ cmd!(sh, "ln -s {store}/gen2 system-niri/config.kdl").run()?;
+ test.assert_unchanged();
+
+ cmd!(sh, "unlink user-niri/config.kdl").run()?;
+ test.assert_changed_to("gen 2");
+
+ cmd!(sh, "ln -s {store}/gen3 user-niri/config.kdl").run()?;
+ test.assert_changed_to("gen 3");
+
+ cmd!(sh, "ln -sf {store}/gen1 system-niri/config.kdl").run()?;
+ test.assert_unchanged();
+
+ cmd!(sh, "unlink system-niri/config.kdl").run()?;
+ test.assert_unchanged();
+
+ cmd!(sh, "ln -s {store}/gen1 system-niri/config.kdl").run()?;
+ test.assert_unchanged();
+
+ cmd!(sh, "unlink user-niri/config.kdl").run()?;
+ test.assert_changed_to("gen 1");
+
+ Ok(())
+ })
}
}