From 428267a7140fd0232316fc3312d0869a83fe4290 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 09:41:40 +0100 Subject: [PATCH] Refresh lid observations before rendering after resume --- compositor/src/backends/direct.rs | 56 ++-- compositor/src/backends/direct/lid.rs | 304 +++++++++++++++++++++ compositor/src/backends/direct/topology.rs | 15 +- compositor/src/main.rs | 2 + compositor/src/resume.rs | 57 ++++ compositor/src/theme.rs | 26 -- 6 files changed, 407 insertions(+), 53 deletions(-) create mode 100644 compositor/src/backends/direct/lid.rs create mode 100644 compositor/src/resume.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index f4d564f..850704c 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -6,6 +6,7 @@ use std::path::Path; use std::time::{Duration, Instant}; mod capture; +mod lid; mod output_power; mod planes; mod redraw; @@ -53,7 +54,8 @@ use crate::render::{frame_effect_metrics, sampled_output_elements}; pub struct DirectBackendState { pub session: LibSeatSession, pub active: bool, - lid_closed: bool, + lid: lid::LidState, + lid_reader: lid::Reader, topology: Topology, pub(crate) low_power: bool, devices: HashMap, @@ -69,7 +71,7 @@ pub struct DirectBackendState { impl DirectBackendState { fn can_render(&self) -> bool { - self.active && !self.topology.reconciling + self.active && !self.topology.reconciling && !self.lid.pending() } pub(crate) fn dump_scheduling_metrics(&self) { @@ -239,10 +241,12 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() libinput_context.suspend(); } + let lid_reader = lid::Reader::start(state)?; state.direct_backend = Some(DirectBackendState { session, active: session_active, - lid_closed: initial_lid_closed(), + lid: lid::LidState::default(), + lid_reader, topology: Topology::default(), low_power: super::power::low_power(false, super::power::battery_percent()), devices: HashMap::new(), @@ -263,8 +267,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .topology .devices .insert(primary_path, Default::default()); - reconcile_outputs(state, false); - sync_battery_timer(state); + lid::request_refresh(state, false); event_loop .handle() @@ -291,6 +294,10 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let handle = state.loop_handle.clone(); if let Some(backend) = state.direct_backend.as_mut() { backend.active = false; + backend.lid.pause(); + if let Some(token) = backend.lid_reader.deadline.take() { + handle.remove(token); + } backend.topology.dirty = true; if let Some(token) = backend.topology.retry_timer.take() { handle.remove(token); @@ -336,12 +343,8 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let seat = state.seat.clone(); state.idle_notifier_state.notify_activity(&seat); - reconcile_outputs(state, true); - sync_battery_timer(state); - state.reset_animation_clock(); - tracing::info!("direct session activated"); - state.lock_input_activity(); - render_all(state); + lid::request_refresh(state, true); + tracing::info!("direct session reacquired; refreshing external state"); } })?; event_loop.handle().insert_source(udev_backend, |event, _, state| { @@ -468,7 +471,7 @@ pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile]) let mut usable = 0; for device in backend.devices.values() { let mut scan = select_outputs(&device.drm, profiles, backend.low_power).map_err(|e| e.to_string())?; - apply_lid_policy(&mut scan, backend.lid_closed); + apply_lid_policy(&mut scan, backend.lid.closed()); usable += scan.selections.len(); } if usable == 0 { @@ -864,22 +867,19 @@ pub(crate) fn set_lid_closed(state: &mut Ferese, closed: bool) { let Some(backend) = state.direct_backend.as_mut() else { return; }; - if backend.lid_closed == closed { - return; + // Even an unchanged switch observation supersedes an in-flight DBus read. + if backend.lid.observe(closed) { + tracing::info!(closed, "laptop lid state changed"); + reconcile_outputs(state, false); } - backend.lid_closed = closed; - tracing::info!(closed, "laptop lid state changed"); - reconcile_outputs(state, false); } -fn initial_lid_closed() -> bool { - std::fs::read_dir("/proc/acpi/button/lid") - .ok() - .into_iter() - .flatten() - .filter_map(Result::ok) - .filter_map(|entry| std::fs::read_to_string(entry.path().join("state")).ok()) - .any(|state| state.split_whitespace().last() == Some("closed")) +pub(crate) fn system_resumed(state: &mut Ferese) { + if state.direct_backend.as_ref().is_some_and(|backend| backend.active) { + let seat = state.seat.clone(); + state.idle_notifier_state.notify_activity(&seat); + } + lid::request_refresh(state, true); } fn internal_connector(interface: connector::Interface) -> bool { @@ -1122,7 +1122,10 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) -> Result { } }; - let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed); + let lid_closed = state + .direct_backend + .as_ref() + .is_some_and(|backend| backend.lid.closed()); apply_lid_policy(&mut scan, lid_closed); device.connected_outputs = scan.connected_outputs; let mut complete = true; @@ -1562,6 +1565,7 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle output.callback_timer = None; if !backend.active || backend.topology.reconciling + || backend.lid.pending() || !device.drm.is_active() || !output.power.can_render() || state.session_lock.sleeping diff --git a/compositor/src/backends/direct/lid.rs b/compositor/src/backends/direct/lid.rs new file mode 100644 index 0000000..1ebcc8b --- /dev/null +++ b/compositor/src/backends/direct/lid.rs @@ -0,0 +1,304 @@ +//! Resume reads are asynchronous; newer switch events win over old replies. +use std::sync::mpsc; + +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Probe { + serial: u64, + revision: u64, + reactivate: bool, +} + +#[derive(Default)] +pub(super) struct LidState { + value: Option, + revision: u64, + serial: u64, + pending: Option, +} + +impl LidState { + pub fn closed(&self) -> bool { + self.value == Some(true) + } + pub fn pending(&self) -> bool { + self.pending.is_some() + } + + pub fn observe(&mut self, closed: bool) -> bool { + let changed = self.value != Some(closed); + self.value = Some(closed); + self.revision = self.revision.wrapping_add(1); + changed + } + + fn begin(&mut self, reactivate: bool) -> Probe { + self.serial = self.serial.wrapping_add(1); + let probe = Probe { + serial: self.serial, + revision: self.revision, + reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate), + }; + self.pending = Some(probe); + probe + } + + fn complete(&mut self, probe: Probe, value: Option) -> Option { + if self.pending != Some(probe) { + return None; + } + + self.pending = None; + if probe.revision == self.revision + && let Some(value) = value + { + self.observe(value); + } + Some(probe.reactivate) + } + + pub fn pause(&mut self) { + self.pending = None; + } +} + +pub(super) struct Reader { + requests: mpsc::Sender, + pub deadline: Option, +} + +fn read_logind(connection: &zbus::blocking::Connection) -> zbus::Result { + // A direct Get avoids retaining a property cache across suspend. + let value: zbus::zvariant::OwnedValue = connection + .call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.freedesktop.login1.Manager", "LidClosed"), + )? + .body() + .deserialize()?; + bool::try_from(value).map_err(Into::into) +} + +fn read_acpi() -> Option { + let mut state = None; + for entry in std::fs::read_dir("/proc/acpi/button/lid").ok()?.flatten() { + let Ok(text) = std::fs::read_to_string(entry.path().join("state")) else { + continue; + }; + match text.split_whitespace().last() { + Some("closed") => return Some(true), + Some("open") => state = Some(false), + _ => {} + } + } + state +} + +impl Reader { + pub fn start(state: &Ferese) -> Result> { + let (requests, receiver) = mpsc::channel::(); + let (replies, events) = smithay::reexports::calloop::channel::channel(); + state.loop_handle.insert_source(events, |event, _, state| { + if let smithay::reexports::calloop::channel::Event::Msg((probe, value)) = event { + complete_refresh(state, probe, value); + } + })?; + + std::thread::Builder::new() + .name("ferese-lid-reader".into()) + .spawn(move || { + while let Ok(mut probe) = receiver.recv() { + for newer in receiver.try_iter() { + probe = newer; + } + let value = zbus::blocking::connection::Builder::system() + .and_then(|builder| builder.method_timeout(Duration::from_secs(2)).build()) + .and_then(|connection| read_logind(&connection)) + .ok() + .or_else(read_acpi); + if replies.send((probe, value)).is_err() { + break; + } + } + })?; + + Ok(Self { + requests, + deadline: None, + }) + } +} + +pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + if !backend.active { + backend.topology.dirty = true; + return; + } + + if let Some(token) = backend.lid_reader.deadline.take() { + state.loop_handle.remove(token); + } + // A timer that fires while readiness is gated would otherwise leave a + // stale registration token on an unchanged output. + for device in backend.devices.values_mut() { + for output in device.outputs.values_mut() { + cancel_output_timers(&state.loop_handle, output); + } + } + let probe = backend.lid.begin(reactivate); + let sent = backend.lid_reader.requests.send(probe).is_ok(); + let timer = state + .loop_handle + .insert_source(Timer::from_duration(Duration::from_secs(3)), move |_, _, state| { + if let Some(backend) = state.direct_backend.as_mut() { + backend.lid_reader.deadline = None; + } + tracing::warn!("lid refresh deadline elapsed; retaining the latest observed state"); + complete_refresh(state, probe, None); + TimeoutAction::Drop + }); + + if sent && let Ok(token) = timer { + state.direct_backend.as_mut().unwrap().lid_reader.deadline = Some(token); + } else { + if let Ok(token) = timer { + state.loop_handle.remove(token); + } + complete_refresh(state, probe, None); + } +} + +fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + let Some(reactivate) = backend.lid.complete(probe, value) else { + return; + }; + if let Some(token) = backend.lid_reader.deadline.take() { + state.loop_handle.remove(token); + } + if !backend.active { + return; + } + + // Waking a locked session may request redraws. Hold them until the fresh + // device/connector snapshot has been reconciled. + backend.topology.reconciling = true; + state.reset_animation_clock(); + state.lock_input_activity(); + state.direct_backend.as_mut().unwrap().topology.reconciling = false; + reconcile_outputs(state, reactivate); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires dbus-daemon and private test sockets"] + fn logind_reads_are_fresh_without_property_change_signals() { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct Bus(std::process::Child); + impl Drop for Bus { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + struct Manager(Arc); + #[zbus::interface(name = "org.freedesktop.login1.Manager")] + impl Manager { + #[zbus(property)] + fn lid_closed(&self) -> bool { + self.0.load(Ordering::Relaxed) + } + } + + let mut bus = Bus(Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap()); + let mut address = String::new(); + BufReader::new(bus.0.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + let closed = Arc::new(AtomicBool::new(true)); + let server = zbus::blocking::connection::Builder::address(address.trim()) + .unwrap() + .name("org.freedesktop.login1") + .unwrap() + .serve_at("/org/freedesktop/login1", Manager(closed.clone())) + .unwrap() + .build() + .unwrap(); + let client = zbus::blocking::connection::Builder::address(address.trim()) + .unwrap() + .method_timeout(Duration::from_secs(1)) + .build() + .unwrap(); + assert!(read_logind(&client).unwrap()); + closed.store(false, Ordering::Relaxed); + assert!(!read_logind(&client).unwrap()); + server.release_name("org.freedesktop.login1").unwrap(); + assert!( + read_logind(&client).is_err(), + "missing logind must not masquerade as an open lid" + ); + } + + #[test] + fn newer_input_wins_over_a_delayed_resume_read() { + let mut lid = LidState::default(); + lid.observe(true); + let probe = lid.begin(true); + lid.observe(false); + assert_eq!(lid.complete(probe, Some(true)), Some(true)); + assert!(!lid.closed()); + } + + #[test] + fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() { + let mut lid = LidState::default(); + let probe = lid.begin(false); + lid.complete(probe, None); + assert_eq!(lid.value, None); + lid.observe(true); + let probe = lid.begin(true); + lid.complete(probe, None); + assert_eq!(lid.value, Some(true)); + } + + #[test] + fn pause_and_newer_resume_invalidate_old_replies() { + let mut lid = LidState::default(); + let old = lid.begin(true); + lid.pause(); + assert_eq!(lid.complete(old, Some(true)), None); + let current = lid.begin(true); + assert_eq!(lid.complete(old, Some(true)), None); + assert!(lid.pending()); + assert_eq!(lid.complete(current, Some(false)), Some(true)); + assert!(!lid.pending()); + } + + #[test] + fn system_wake_cannot_cancel_pending_session_reactivation() { + let mut lid = LidState::default(); + lid.begin(true); + let probe = lid.begin(false); + assert_eq!(lid.complete(probe, Some(false)), Some(true)); + } +} diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index 6561e39..2b1926f 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -114,12 +114,15 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { let Some(backend) = state.direct_backend.as_mut() else { return; }; - if !backend.topology.request(backend.active) { + if !backend.topology.request(backend.active && !backend.lid.pending()) { return; } backend.topology.reconciling = true; backend.topology.dirty = false; + if reactivate { + backend.presentation.values_mut().for_each(PresentationClock::reset_timing); + } if let Some(token) = backend.topology.retry_timer.take() { state.loop_handle.remove(token); } @@ -198,6 +201,15 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { let device = state.direct_backend.as_mut().unwrap().devices.get_mut(&node).unwrap(); device.drm.activate(true).map(|()| { for (crtc, output) in &mut device.outputs { + cancel_output_timers(&state.loop_handle, output); + output.surface.reset_buffers(); + output.primary_commit = None; + output.capture_texture = None; + output.frame_pending = false; + output.lock_frame_pending = false; + output.power.suspended(); + state.display_presentation.remove_output(&output.output); + if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) { tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output"); failed_outputs.push(*crtc); @@ -291,6 +303,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { } } + state.refresh_idle_inhibition(); sync_battery_timer(state); state.restore_output_focus(); state.relayout(); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index dc4bf2a..8d0885b 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -27,6 +27,7 @@ mod render; #[cfg(feature = "resize-metrics")] mod resize_metrics; mod resize_transaction; +mod resume; mod session_lock; mod shell_control; mod stacking; @@ -93,6 +94,7 @@ fn main() -> Result<(), Box> { let mut state = Ferese::new(&mut event_loop, display, runtime)?; state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024); theme::init(&mut event_loop, &mut state, candidate)?; + resume::init(&mut event_loop)?; overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); diff --git a/compositor/src/resume.rs b/compositor/src/resume.rs new file mode 100644 index 0000000..bc8d2b3 --- /dev/null +++ b/compositor/src/resume.rs @@ -0,0 +1,57 @@ +//! System wake is shared by hardware reconciliation and time-dependent UI state. +use std::error::Error; +use std::time::Duration; + +use calloop::EventLoop; +use calloop::channel::{Event, channel}; + +use crate::Ferese; + +pub(crate) fn init(event_loop: &mut EventLoop) -> Result<(), Box> { + let (sender, receiver) = channel(); + event_loop.handle().insert_source(receiver, |event, _, state| { + if let Event::Msg(()) = event { + crate::backends::direct::system_resumed(state); + state.refresh_theme(); + } + })?; + + std::thread::Builder::new() + .name("ferese-system-resume".into()) + .spawn(move || { + let mut warned = false; + loop { + let monitor = || -> zbus::Result { + let connection = zbus::blocking::connection::Builder::system()? + .method_timeout(Duration::from_secs(2)) + .build()?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + )?; + for message in proxy.receive_signal("PrepareForSleep")? { + let (sleeping,): (bool,) = message.body().deserialize()?; + if !sleeping && sender.send(()).is_err() { + return Ok(false); + } + } + Ok(true) + }; + + match monitor() { + Ok(false) => break, + Ok(true) => warned = false, + Err(error) if !warned => { + tracing::warn!(%error, "system resume monitor unavailable; retrying connection"); + warned = true; + } + Err(_) => {} + } + // Recovery only: a healthy listener blocks on DBus signals. + std::thread::sleep(Duration::from_secs(5)); + } + })?; + Ok(()) +} diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index 9c1cce0..5ff5791 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -265,32 +265,6 @@ pub(crate) fn init( } paths.lock().unwrap().insert(PathBuf::from("/etc/localtime")); let wakeup = state.loop_signal.clone(); - let resume_sender = sender.clone(); - let resume_wakeup = wakeup.clone(); - std::thread::Builder::new() - .name("ferese-theme-resume".into()) - .spawn(move || { - let monitor = || -> zbus::Result<()> { - let connection = zbus::blocking::Connection::system()?; - let proxy = zbus::blocking::Proxy::new( - &connection, - "org.freedesktop.login1", - "/org/freedesktop/login1", - "org.freedesktop.login1.Manager", - )?; - for message in proxy.receive_signal("PrepareForSleep")? { - let (sleeping,): (bool,) = message.body().deserialize()?; - if !sleeping { - let _ = resume_sender.try_send(()); - resume_wakeup.wakeup(); - } - } - Ok(()) - }; - if let Err(error) = monitor() { - tracing::warn!(%error, "theme resume monitor unavailable"); - } - })?; let system_sender = sender.clone(); let system_wakeup = wakeup.clone(); std::thread::Builder::new()