From e980bb2e432432fca1b51cb3662eba097dd5c000 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 14 Oct 2026 13:28:10 +0100 Subject: [PATCH] fix: schedule pending resizes with bounded deadlines (#42) Co-authored-by: Samuel Onoja --- compositor/src/backends/direct.rs | 39 ++++++ compositor/src/resize_transaction.rs | 11 ++ compositor/src/state/prediction.rs | 173 ++++++++++++++++++++++-- compositor/src/state/window_registry.rs | 29 +++- crates/ferese-animation/src/lib.rs | 30 +++- 5 files changed, 268 insertions(+), 14 deletions(-) diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index ac3ebdd..1b718f5 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -1668,10 +1668,37 @@ fn animation_fallbacks(state: &mut Ferese) -> Vec<(DrmNode, crtc::Handle, Instan fallbacks } +// Pending buffers are events with deadlines, not visual animations. Recompute +// from current output identities and waits so commit, replacement and teardown +// cancel stale wakeups, including mirrored and sleeping outputs. +fn resize_deadlines(state: &Ferese) -> Vec<(Output, Instant)> { + let Some(backend) = state.direct_backend.as_ref().filter(|backend| backend.can_render()) else { + return Vec::new(); + }; + if state.session_lock.sleeping { + return Vec::new(); + } + backend + .devices + .values() + .filter(|device| device.drm.is_active()) + .flat_map(|device| device.outputs.values()) + .filter(|output| output.power.can_render()) + .filter_map(|output| { + let scene = backend.scene_output(state, &output.output); + Some(( + output.output.clone(), + state.start_time + state.output_resize_deadline(&scene)?, + )) + }) + .collect() +} + fn arm_animation_timer(state: &mut Ferese) { let deadline = animation_fallbacks(state) .iter() .map(|(_, _, deadline)| *deadline) + .chain(resize_deadlines(state).into_iter().map(|(_, deadline)| deadline)) .min(); let Some(backend) = state.direct_backend.as_mut() else { return; @@ -1698,6 +1725,18 @@ fn arm_animation_timer(state: &mut Ferese) { backend.scheduling_metrics.fallback_wakes += 1; } + let now = Instant::now(); + let expired_outputs = resize_deadlines(state) + .into_iter() + .filter_map(|(output, deadline)| (deadline <= now).then_some(output)) + .collect::>(); + if !expired_outputs.is_empty() { + // Retire waits before rearming, even if a DRM submission is + // in flight. redraw advances the shared clock and requests the + // released outputs plus any final animation frames elsewhere. + render_on(state, &expired_outputs); + } + // Revalidate identities, power state, activity and normal frame chains // at dispatch. Disconnected/replaced outputs never get a stale request. for (node, crtc, deadline) in animation_fallbacks(state) { diff --git a/compositor/src/resize_transaction.rs b/compositor/src/resize_transaction.rs index 3caba29..b6202cb 100644 --- a/compositor/src/resize_transaction.rs +++ b/compositor/src/resize_transaction.rs @@ -25,6 +25,17 @@ impl ResizeTransaction { } } + pub(crate) fn deadline(self) -> Duration { + self.started + DEADLINE + } + + // A replacement configure changes readiness, not the bound on an + // uninterrupted wait. Clearing the transaction starts a fresh interaction. + pub(crate) fn superseded_by(self, mut replacement: Self) -> Self { + replacement.started = self.started; + replacement + } + pub(crate) fn serial(self) -> Serial { self.serial } diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 15a6a3c..7f24f00 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -189,6 +189,40 @@ impl Ferese { }) } + pub(crate) fn output_resize_deadline(&self, output: &Output) -> Option { + self.output_window_records(output, false) + .filter(|(id, _, _)| self.window_belongs_to_output(*id, output)) + .flat_map(|(id, record, _)| { + // Client-size waits are retired while visiting mapped surfaces. + // Hidden tabs/workspaces must not arm an already-expired timer. + let mapped = self + .windows + .window(id) + .is_some_and(|window| self.space.element_location(window).is_some()); + [ + record.resize.map(|wait| wait.deadline()), + record + .geometry + .filter(|_| mapped) + .and_then(|geometry| geometry.client.commit_deadline()), + ] + }) + .flatten() + .min() + } + + fn snapshot_is_waiting(&self, id: WindowId, geometry: &WindowGeometry) -> bool { + self.presentation_dependencies.reflow_blocked(id, &self.windows) + || geometry.client.committed_size.is_some_and(|size| { + crate::presentation::resize_needs_old_frame( + geometry.visual.current, + geometry.logical, + size.width, + size.height, + ) + }) + } + pub(crate) fn output_has_animations(&self, output: &Output) -> bool { if self .render @@ -213,7 +247,7 @@ impl Ferese { return true; } - self.output_window_records(output, false).any(|(id, record, _)| { + self.output_window_records(output, true).any(|(id, record, blocked)| { let Some(mut geometry) = record.geometry else { return false; }; @@ -222,9 +256,8 @@ impl Ferese { return false; } - if record.resize.is_some() - || record.opening.is_some() - || self.render.snapshot(&id).is_some() + if record.opening.is_some() + || (self.render.snapshot(&id).is_some() && !self.snapshot_is_waiting(id, &geometry)) || record.focus.as_ref().is_some_and(|focus| focus.is_animating()) || record.shadow.as_ref().is_some_and(|shadow| shadow.is_animating()) || record @@ -235,7 +268,7 @@ impl Ferese { return true; } - if geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled) { + if !blocked && geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled) { return true; } @@ -243,13 +276,18 @@ impl Ferese { return false; }; - self.viewports - .get(&workspace) - .is_some_and(ViewportPresentation::needs_tick) - || record.coupled_width.as_ref().is_some_and(|(_, width)| { - let mut width = *width; - width.advance(Duration::ZERO, self.viewport_spring_config) - }) + (!self + .presentation_dependencies + .viewport_blocked(workspace, &self.windows) + && self + .viewports + .get(&workspace) + .is_some_and(ViewportPresentation::needs_tick)) + || (!blocked + && record.coupled_width.as_ref().is_some_and(|(_, width)| { + let mut width = *width; + width.advance(Duration::ZERO, self.viewport_spring_config) + })) }) } @@ -374,6 +412,117 @@ fn predict_geometry( mod tests { use super::*; + #[test] + fn pending_resize_waits_without_refresh_cadence_and_recovers() { + if !crate::startup_tests::private_runtime( + "state::prediction::tests::pending_resize_waits_without_refresh_cadence_and_recovers", + ) { + return; + } + use crate::frame_scheduler::FrameScheduler; + let mut event_loop = EventLoop::try_new().unwrap(); + let display = Display::new().unwrap(); + let config = crate::config::Config::default().runtime_config().unwrap(); + let mut state = Ferese::new(&mut event_loop, display, config).unwrap(); + let output = Output::new( + "resize-idle".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (1920, 1080).into(), + refresh: 60_000, + }), + None, + None, + Some((0, 0).into()), + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "resize-idle".into()); + let id = WindowId(1); + state.workspaces.insert_window(id, Axis::Horizontal, 0.5).unwrap(); + let settled = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None); + state.windows.records.insert( + id, + super::super::window_registry::WindowRecord { + geometry: Some(settled), + ..Default::default() + }, + ); + state.windows.set_transaction( + id, + crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO), + ); + state.rebuild_presentation_dependencies(); + assert_eq!(state.output_resize_deadline(&output), Some(Duration::from_millis(300))); + let mut held = settled; + held.visual.set_target(Rect::new(100., 0., 400., 300.)); + state.windows.set_geometry(id, held); + // Geometry which cannot advance is not visual activity either. + assert!(!state.output_has_animations(&output)); + for hz in [60, 120, 240] { + let interval = Duration::from_nanos(1_000_000_000 / hz); + let mut scheduler = FrameScheduler::new(interval); + scheduler.request(Duration::ZERO); + let mut attempts = 0; + for tick in 0..(hz * 3 / 10) { + let now = interval * tick as u32; + if scheduler.plan(now).is_some() { + let plan = scheduler.begin().unwrap(); + attempts += 1; + scheduler.rendered(plan, now, Duration::ZERO, false); + if state.sample_frame(&output, Duration::ZERO).animating { + scheduler.request(now); + } + } + } + assert_eq!(attempts, 1, "pending-only render attempts at {hz} Hz"); + } + // Real fades remain live even while buffer-dependent geometry is held. + state.windows.update(id, |record| { + let mut opening = AnimatedValue::new(0.0); + opening.set_target(1.0); + record.opening = Some(opening); + }); + assert!(state.output_has_animations(&output)); + state.windows.update(id, |record| record.opening = None); + // A client commit removes the wait and wakes its held geometry. + let mut moving = settled; + moving.visual.set_target(Rect::new(100., 0., 400., 300.)); + state.windows.set_geometry(id, moving); + state.windows.clear_transaction(&id); + state.release_presentation_dependencies(); + assert_eq!(state.output_resize_deadline(&output), None); + assert!(state.output_has_animations(&output)); + state.windows.geometry_mut(&id).unwrap().client.request_size( + ClientSize { + width: 800, + height: 300, + }, + Duration::ZERO, + ); + assert_eq!( + state.output_resize_deadline(&output), + None, + "unmapped client-size waits cannot arm expired wakeups" + ); + // Timeout processing still releases a pending wait. + state.windows.set_transaction( + id, + crate::resize_transaction::ResizeTransaction::new(10.into(), Duration::ZERO), + ); + state.advance_animations_at(Duration::ZERO, Duration::from_millis(300)); + assert!(state.windows.transaction(&id).is_none()); + state.unregister_output(&output); + assert_eq!(state.output_resize_deadline(&output), None); + assert!(!state.output_has_animations(&output)); + } + #[test] #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] fn focus_effect_sampling_preserves_activation_and_output_local_progress() { diff --git a/compositor/src/state/window_registry.rs b/compositor/src/state/window_registry.rs index 21c2240..2ff1c5d 100644 --- a/compositor/src/state/window_registry.rs +++ b/compositor/src/state/window_registry.rs @@ -153,7 +153,13 @@ impl WindowRegistry { } pub fn set_transaction(&mut self, id: WindowId, transaction: ResizeTransaction) { - self.update(id, |record| record.resize = Some(transaction)); + self.update(id, |record| { + record.resize = Some( + record + .resize + .map_or(transaction, |pending| pending.superseded_by(transaction)), + ); + }); } pub fn clear_transaction(&mut self, id: &WindowId) { @@ -366,6 +372,27 @@ mod tests { assert!(windows.record(second).is_some()); } + #[test] + fn repeated_configures_preserve_the_pending_wait_bound() { + use std::time::Duration; + let mut windows = WindowRegistry::default(); + let id = windows.allocate_id(); + windows.register("stalled", id); + for serial in 1u32..=30 { + let now = Duration::from_millis(u64::from(serial - 1) * 10); + windows.set_transaction(id, ResizeTransaction::new(serial.into(), now)); + windows.expire_transactions(now); + assert!(windows.transaction(&id).is_some()); + assert!(!windows.transaction(&id).unwrap().accepts(Some((serial - 1).into()))); + } + windows.expire_transactions(Duration::from_millis(300)); + assert!(windows.transaction(&id).is_none()); + windows.set_transaction(id, ResizeTransaction::new(31.into(), Duration::from_millis(400))); + assert!(!windows.transaction(&id).unwrap().expired(Duration::from_millis(699))); + windows.expire_transactions(Duration::from_millis(700)); + assert!(windows.transaction(&id).is_none()); + } + #[test] fn expired_resize_releases_only_its_barrier_and_preserves_geometry() { use std::time::Duration; diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index 288460f..d394d94 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -257,7 +257,7 @@ impl ClientGeometry { } self.last_configured_size = Some(size); - self.waiting_since = Some(now); + self.waiting_since.get_or_insert(now); true } @@ -276,6 +276,11 @@ impl ClientGeometry { self.waiting_since.is_some() } + /// The hard bound for the current uninterrupted client-buffer wait. + pub fn commit_deadline(&self) -> Option { + self.waiting_since.map(|started| started + CLIENT_COMMIT_TIMEOUT) + } + pub fn timed_out(&self, now: Duration) -> bool { self.waiting_since .is_some_and(|started| now.saturating_sub(started) >= CLIENT_COMMIT_TIMEOUT) @@ -545,6 +550,29 @@ fn finite_or(value: f64, fallback: f64) -> f64 { mod tests { use super::*; + #[test] + fn replacing_size_requests_does_not_extend_a_stalled_client_wait() { + let mut client = ClientGeometry::default(); + for width in 1..=50 { + assert!(client.request_size( + ClientSize { width, height: 100 }, + Duration::from_millis((width as u64 - 1) * 10) + )); + } + assert_eq!(client.commit_deadline(), Some(Duration::from_millis(500))); + assert!(!client.commit(ClientSize { width: 49, height: 100 })); + assert!(client.expire_wait(Duration::from_millis(499)).is_none()); + assert_eq!( + client.expire_wait(Duration::from_millis(500)), + Some(ClientSize { width: 50, height: 100 }) + ); + assert_eq!(client.commit_deadline(), None); + assert!(client.request_size(ClientSize { width: 51, height: 100 }, Duration::from_millis(600))); + assert_eq!(client.commit_deadline(), Some(Duration::from_millis(1100))); + assert!(client.commit(ClientSize { width: 51, height: 100 })); + assert_eq!(client.commit_deadline(), None); + } + #[test] fn returning_fullscreen_window_does_not_resume_hidden_zoom() { let normal = Rect::new(100.0, 50.0, 900.0, 700.0);