//! Filesystem events wake the loop; only pending edits need a debounce timer. use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use std::{ io::Read, path::PathBuf, sync::mpsc, time::{Duration, Instant}, }; fn read_source(path: &std::path::Path) -> Result { let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let mut source = String::new(); file.take(60 * 1024 + 1) .read_to_string(&mut source) .map_err(|e| e.to_string())?; if source.len() > 60 * 1024 { return Err("configuration exceeds the 60 KiB live-reload limit".into()); } Ok(source) } pub(crate) struct ConfigMonitor { _watcher: RecommendedWatcher, events: mpsc::Receiver<()>, path: PathBuf, dirty: Option, observed: Option, } impl ConfigMonitor { #[cfg(test)] pub(crate) fn new(path: PathBuf) -> notify::Result { Self::with_wakeup(path, None) } pub(crate) fn with_wakeup( path: PathBuf, wakeup: Option, ) -> notify::Result { let (sender, events) = mpsc::sync_channel(1); let target = path.clone(); let event_wakeup = wakeup.clone(); let mut watcher = notify::recommended_watcher(move |event: notify::Result| { if let Ok(event) = event && !event.kind.is_access() && event .paths .iter() .any(|p| p == &target || target.starts_with(p)) { let _ = sender.try_send(()); if let Some(wakeup) = &event_wakeup { wakeup.wakeup(); } } })?; // Watch the directory, not the inode: atomic editor saves replace it. let mut directory = path.parent().unwrap_or(std::path::Path::new(".")); while !directory.is_dir() { directory = directory.parent().unwrap_or(std::path::Path::new(".")); } watcher.watch( directory, if Some(directory) == path.parent() { RecursiveMode::NonRecursive } else { RecursiveMode::Recursive }, )?; let initial_dirty = path.is_file().then(Instant::now); if initial_dirty.is_some() && let Some(wakeup) = wakeup { wakeup.wakeup(); } Ok(Self { _watcher: watcher, events, path, dirty: initial_dirty, observed: None, }) } pub(crate) fn next_deadline(&self) -> Option { self.dirty.map(|since| since + Duration::from_millis(120)) } pub(crate) fn poll(&mut self, now: Instant) -> Option> { if self.events.try_iter().next().is_some() { self.dirty = Some(now); } if self .dirty .is_none_or(|since| now.saturating_duration_since(since) < Duration::from_millis(120)) { return None; } self.dirty = None; let source = match read_source(&self.path) { Ok(source) => source, Err(error) => { return Some(Err(format!( "{}: {error}; retaining active config", self.path.display() ))); } }; if self.observed.as_ref() == Some(&source) { return None; } self.observed = Some(source.clone()); Some(Ok(source)) } } impl crate::Ferese { pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> { if source.len() > 60 * 1024 { return Err("configuration exceeds the 60 KiB live-reload limit".into()); } if self.config_source.as_ref() == Some(&source) { return Ok(()); } // Validate every runtime field before mutating any scene/input state. let config = crate::config::Config::parse_source(&source).map_err(|e| e.to_string())?; let runtime = config.runtime_config().map_err(|e| e.to_string())?; self.apply_runtime_config(runtime)?; self.config_source = Some(source.clone()); self.shell_resources.retain(|resource| { if let Ok(shell) = resource.upgrade() { if smithay::reexports::wayland_server::Resource::version(&shell) >= 2 { crate::shell_control::send_shell_config(&shell, &source); } true } else { false } }); tracing::info!("configuration reloaded live"); Ok(()) } pub(crate) fn reload_config(&mut self) -> Result<(), String> { let path = crate::config::config_path().ok_or("config directory is unavailable")?; let source = read_source(&path)?; self.reload_config_source(source) } } #[cfg(test)] mod tests { use super::*; struct Directory(PathBuf); impl Directory { fn new() -> Self { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let path = std::env::temp_dir().join(format!( "ferese-reload-test-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); std::fs::create_dir(&path).unwrap(); Self(path) } } impl Drop for Directory { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn runtime_validation_rejects_bad_edits_before_application() { for source in [ "animations {\n speed 0\n}\n", r##"theme { focus-ring { gradient { from "bad" to "#ffffff" } } } "##, "status {\n low-battery-threshold \"bad\"\n}\n", "layout {\n inner-gap -1\n}\n", ] { assert!( crate::config::Config::parse_source(source) .and_then(|config| config.runtime_config()) .is_err() ); } assert!( crate::config::Config::parse_source("animations {\n speed 0.75\n}\n") .unwrap() .runtime_config() .is_ok() ); } #[test] fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() { let directory = Directory::new(); let path = directory.0.join("config.kdl"); std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap(); let mut monitor = ConfigMonitor::new(path.clone()).unwrap(); monitor.dirty = Some(Instant::now() - Duration::from_secs(1)); assert!(monitor.poll(Instant::now()).unwrap().is_ok()); // With no event, even a deliberately missing path is never read. let original_path = monitor.path.clone(); monitor.path = directory.0.join("missing"); assert!(monitor.poll(Instant::now()).is_none()); monitor.path = original_path; let temporary = directory.0.join("config.kdl.new"); std::fs::write(&temporary, "animations {\n speed 0.75\n}\n").unwrap(); std::fs::rename(&temporary, &path).unwrap(); let deadline = Instant::now() + Duration::from_secs(3); loop { if let Some(result) = monitor.poll(Instant::now()) { assert_eq!(result.unwrap(), "animations {\n speed 0.75\n}\n"); break; } assert!( Instant::now() < deadline, "atomic save notification never settled" ); std::thread::sleep(Duration::from_millis(10)); } } #[test] fn source_size_limit_is_bounded_and_debounce_coalesces_edits() { let directory = Directory::new(); let path = directory.0.join("config.kdl"); std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap(); assert!(read_source(&path).is_err()); std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap(); let mut monitor = ConfigMonitor::new(path).unwrap(); let now = Instant::now(); monitor.dirty = Some(now); assert_eq!( monitor.next_deadline(), Some(now + Duration::from_millis(120)) ); assert!(monitor.poll(now + Duration::from_millis(119)).is_none()); assert!( monitor .poll(now + Duration::from_millis(121)) .unwrap() .is_ok() ); assert_eq!(monitor.next_deadline(), None); } }