From 4da59f390684fb5072d253e3be73379845df0156 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 27 Sep 2026 07:08:03 +0100 Subject: [PATCH] Debounce config reloads only after filesystem notifications --- compositor/src/main.rs | 45 ++++++++++++++++++++++++---------------- compositor/src/reload.rs | 28 ++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 22cd6b3..61c2c53 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -78,30 +78,16 @@ fn main() -> Result<(), Box> { state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024); overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; - let mut monitor = - config::config_path().and_then(|path| match reload::ConfigMonitor::new(path) { + let mut monitor = config::config_path().and_then( + |path| match reload::ConfigMonitor::with_wakeup(path, Some(event_loop.get_signal())) { Ok(monitor) => Some(monitor), Err(error) => { warn!(%error, "automatic config watching unavailable; use feresectl reload-config"); None } - }); - use calloop::timer::{TimeoutAction, Timer}; - event_loop.handle().insert_source( - Timer::from_duration(Duration::from_millis(150)), - move |_, _, state: &mut Ferese| { - if let Some(result) = monitor - .as_mut() - .and_then(|monitor| monitor.poll(std::time::Instant::now())) - { - if let Err(error) = result.and_then(|source| state.reload_config_source(source)) { - warn!(%error, "config reload rejected; retaining last working config"); - } - } - TimeoutAction::ToDuration(Duration::from_millis(150)) }, - )?; - + ); + use calloop::timer::{TimeoutAction, Timer}; info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities); let runner = std::rc::Rc::new(std::cell::RefCell::new(daemon::Runner::new( @@ -117,7 +103,30 @@ fn main() -> Result<(), Box> { TimeoutAction::ToDuration(Duration::from_secs(1)) }, )?; + let reload_timer_pending = std::rc::Rc::new(std::cell::Cell::new(false)); + let reload_handle = event_loop.handle(); let result = event_loop.run(None, &mut state, |state| { + if let Some(monitor) = &mut monitor { + if let Some(result) = monitor.poll(std::time::Instant::now()) { + if let Err(error) = result.and_then(|source| state.reload_config_source(source)) { + warn!(%error, "config reload rejected; retaining last working config"); + } + } + // At most one timer exists. Edits arriving during debounce move the + // deadline; the old timer wakes once and is replaced if necessary. + if !reload_timer_pending.get() + && let Some(deadline) = monitor.next_deadline() + { + let pending = reload_timer_pending.clone(); + match reload_handle.insert_source(Timer::from_deadline(deadline), move |_, _, _| { + pending.set(false); + TimeoutAction::Drop + }) { + Ok(_) => reload_timer_pending.set(true), + Err(error) => warn!(%error, "could not schedule config debounce"), + } + } + } // The decoder wakes the loop after publishing its result, including // when no output has a pending frame. let wallpaper_changed = state.wallpaper.poll(); diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index 9e95e6e..da6e49a 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -1,4 +1,4 @@ -//! Filesystem events only: idle timer ticks never read/stat the config. +//! Filesystem events wake the loop; only pending edits need a debounce timer. use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use std::{ io::Read, @@ -28,9 +28,18 @@ pub(crate) struct ConfigMonitor { } 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 @@ -41,6 +50,9 @@ impl ConfigMonitor { .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. @@ -57,6 +69,11 @@ impl ConfigMonitor { }, )?; 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, @@ -66,6 +83,10 @@ impl ConfigMonitor { }) } + 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); @@ -221,6 +242,10 @@ mod tests { 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 @@ -228,5 +253,6 @@ mod tests { .unwrap() .is_ok() ); + assert_eq!(monitor.next_deadline(), None); } }