utils/watcher: Support config includes

This commit is contained in:
Ivan Molodetskikh
2025-09-28 07:52:17 +03:00
parent 0a33dca5fd
commit 0673260249
2 changed files with 126 additions and 15 deletions

View File

@@ -153,6 +153,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
warn!("{err:?}"); warn!("{err:?}");
Config::load_default() Config::load_default()
}); });
let config_includes = config_load_result.includes;
let spawn_at_startup = mem::take(&mut config.spawn_at_startup); let spawn_at_startup = mem::take(&mut config.spawn_at_startup);
let spawn_sh_at_startup = mem::take(&mut config.spawn_sh_at_startup); let spawn_sh_at_startup = mem::take(&mut config.spawn_sh_at_startup);
@@ -239,7 +240,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
watcher::setup(&mut state, &config_path); watcher::setup(&mut state, &config_path, config_includes);
// Spawn commands from cli and auto-start. // Spawn commands from cli and auto-start.
spawn(cli.command, None); spawn(cli.command, None);

View File

@@ -1,11 +1,12 @@
//! File modification watcher. //! File modification watcher.
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use std::{io, thread}; use std::{io, thread};
use niri_config::{Config, ConfigPath}; use niri_config::{Config, ConfigParseResult, ConfigPath};
use smithay::reexports::calloop::channel::SyncSender; use smithay::reexports::calloop::channel::SyncSender;
use crate::niri::State; use crate::niri::State;
@@ -22,6 +23,9 @@ struct WatcherInner {
/// Last observed props of the watched file. /// Last observed props of the watched file.
last_props: Option<Props>, last_props: Option<Props>,
/// Last observed props for included files.
includes: HashMap<PathBuf, Option<Props>>,
} }
/// Properties of the watched file. /// Properties of the watched file.
@@ -48,17 +52,18 @@ enum CheckResult {
} }
impl Watcher { impl Watcher {
pub fn new<T: Send + 'static>( pub fn new(
path: ConfigPath, path: ConfigPath,
mut process: impl FnMut(&ConfigPath) -> T + Send + 'static, includes: Vec<PathBuf>,
changed: SyncSender<T>, mut process: impl FnMut(&ConfigPath) -> ConfigParseResult<Config, ()> + Send + 'static,
changed: SyncSender<Result<Config, ()>>,
) -> Self { ) -> Self {
let (load_config, load_config_rx) = mpsc::channel(); let (load_config, load_config_rx) = mpsc::channel();
thread::Builder::new() thread::Builder::new()
.name(format!("Filesystem Watcher for {path:?}")) .name(format!("Filesystem Watcher for {path:?}"))
.spawn(move || { .spawn(move || {
let mut inner = WatcherInner::new(path); let mut inner = WatcherInner::new(path, includes);
loop { loop {
let mut should_load = match load_config_rx.recv_timeout(POLLING_INTERVAL) { let mut should_load = match load_config_rx.recv_timeout(POLLING_INTERVAL) {
@@ -77,12 +82,19 @@ impl Watcher {
} }
if should_load { if should_load {
let rv = process(&inner.path); let res = process(&inner.path);
if let Err(err) = changed.send(rv) { if let Err(err) = changed.send(res.config) {
warn!("error sending change notification: {err:?}"); warn!("error sending change notification: {err:?}");
break; break;
} }
// There's a bit of time here between reading the config and reading
// properties of included files where an included file could change and
// remain unnoticed by the watcher. Not sure there's any good way around it
// though since we don't know the final set of includes until the config is
// parsed.
inner.set_includes(res.includes);
} }
} }
@@ -117,9 +129,16 @@ impl Props {
} }
impl WatcherInner { impl WatcherInner {
pub fn new(path: ConfigPath) -> Self { pub fn new(path: ConfigPath, includes: Vec<PathBuf>) -> Self {
let last_props = Props::from_config_path(&path).ok(); let last_props = Props::from_config_path(&path).ok();
Self { path, last_props }
let mut rv = Self {
path,
last_props,
includes: HashMap::new(),
};
rv.set_includes(includes);
rv
} }
pub fn check(&mut self) -> CheckResult { pub fn check(&mut self) -> CheckResult {
@@ -128,20 +147,42 @@ impl WatcherInner {
self.last_props = Some(new_props); self.last_props = Some(new_props);
CheckResult::Changed CheckResult::Changed
} else { } else {
for (path, last_props) in &mut self.includes {
let new_props = Props::from_path(path).ok();
// If an include goes missing while the main config file is unchanged, we
// consider that a change and reload.
if *last_props != new_props {
return CheckResult::Changed;
}
}
CheckResult::Unchanged CheckResult::Unchanged
} }
} else { } else {
CheckResult::Missing CheckResult::Missing
} }
} }
fn set_includes(&mut self, includes: Vec<PathBuf>) {
self.includes = includes
.into_iter()
.map(|path| {
let props = Props::from_path(&path).ok();
(path, props)
})
.collect();
}
} }
pub fn setup(state: &mut State, config_path: &ConfigPath) { pub fn setup(state: &mut State, config_path: &ConfigPath, includes: Vec<PathBuf>) {
// Parsing the config actually takes > 20 ms on my beefy machine, so let's do it on the // Parsing the config actually takes > 20 ms on my beefy machine, so let's do it on the
// watcher thread. // watcher thread.
let process = |path: &ConfigPath| { let process = |path: &ConfigPath| {
path.load().config.map_err(|err| { path.load().map_config_res(|res| {
warn!("{err:?}"); res.map_err(|err| {
warn!("{err:?}");
})
}) })
}; };
@@ -162,7 +203,8 @@ pub fn setup(state: &mut State, config_path: &ConfigPath) {
) )
.unwrap(); .unwrap();
state.niri.config_file_watcher = Some(Watcher::new(config_path.clone(), process, tx)); let watcher = Watcher::new(config_path.clone(), includes, process, tx);
state.niri.config_file_watcher = Some(watcher);
} }
#[cfg(test)] #[cfg(test)]
@@ -266,8 +308,9 @@ mod tests {
sh, config_path, .. sh, config_path, ..
} = self; } = self;
let includes = config_path.load().includes;
let mut test = TestUtil { let mut test = TestUtil {
watcher: WatcherInner::new(config_path), watcher: WatcherInner::new(config_path, includes),
}; };
// don't trigger before we start // don't trigger before we start
@@ -319,6 +362,8 @@ mod tests {
let actual = fs::read_to_string(new_path).unwrap(); let actual = fs::read_to_string(new_path).unwrap();
assert_eq!(actual, expected, "wrong file contents"); assert_eq!(actual, expected, "wrong file contents");
self.watcher.set_includes(Config::load(new_path).includes);
self.pass_time(); self.pass_time();
} }
} }
@@ -509,6 +554,71 @@ mod tests {
}) })
} }
#[test]
fn change_included_file() -> Result {
TestPath::Explicit("niri/config.kdl")
.setup(|sh| {
sh.write_file("niri/config.kdl", "include \"colors.kdl\"")?;
sh.write_file("niri/colors.kdl", "// Colors")
})
.assert_initial("include \"colors.kdl\"")
.run(|sh, test| {
sh.write_file("niri/colors.kdl", "// Updated colors")?;
test.assert_changed_to("include \"colors.kdl\"");
Ok(())
})
}
#[test]
fn remove_included_file() -> Result {
TestPath::Explicit("niri/config.kdl")
.setup(|sh| {
sh.write_file("niri/config.kdl", "include \"colors.kdl\"")?;
sh.write_file("niri/colors.kdl", "// Colors")
})
.assert_initial("include \"colors.kdl\"")
.run(|sh, test| {
sh.remove_path("niri/colors.kdl")?;
test.assert_changed_to("include \"colors.kdl\"");
Ok(())
})
}
#[test]
fn nested_includes() -> Result {
TestPath::Explicit("niri/config.kdl")
.setup(|sh| {
sh.write_file("niri/config.kdl", "include \"a.kdl\"")?;
sh.write_file("niri/a.kdl", "include \"b.kdl\"")?;
sh.write_file("niri/b.kdl", "// b content")
})
.assert_initial("include \"a.kdl\"")
.run(|sh, test| {
sh.write_file("niri/b.kdl", "// updated b")?;
test.assert_changed_to("include \"a.kdl\"");
Ok(())
})
}
#[test]
fn broken_include_still_gets_watched() -> Result {
TestPath::Explicit("niri/config.kdl")
.setup(|sh| {
sh.write_file("niri/config.kdl", "include \"colors.kdl\"")?;
sh.write_file("niri/colors.kdl", "broken")
})
.assert_initial("include \"colors.kdl\"")
.run(|sh, test| {
sh.write_file("niri/colors.kdl", "// Fixed")?;
test.assert_changed_to("include \"colors.kdl\"");
Ok(())
})
}
// Important: On systems like NixOS, mtime is not kept for config files. // Important: On systems like NixOS, mtime is not kept for config files.
// So, this is testing that the watcher handles that correctly. // So, this is testing that the watcher handles that correctly.
fn create_epoch(path: impl AsRef<Path>, content: &str) -> Result { fn create_epoch(path: impl AsRef<Path>, content: &str) -> Result {