From 554eff6885aa29252a8c35dad3f0f6121b16665c Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 09:34:46 +0000 Subject: [PATCH 1/4] fix(gpui-linux): make the Wayland render loop demand-driven Wayland frame callbacks only fire after a commit the compositor paints. Park the loop when idle instead of committing empty heartbeat frames, and wake from GPUI via schedule_frame when a window still needs work. Zed-Origin: eb354c8d504071bdb79110a7a5c9d374c2864113 Co-authored-by: freefcw --- crates/gpui-linux/src/linux/wayland/client.rs | 58 +++- crates/gpui-linux/src/linux/wayland/window.rs | 296 +++++++++++++++--- crates/gpui/src/app.rs | 48 ++- crates/gpui/src/app/async_context.rs | 4 +- crates/gpui/src/platform.rs | 2 +- crates/gpui/src/window.rs | 32 +- 6 files changed, 379 insertions(+), 61 deletions(-) diff --git a/crates/gpui-linux/src/linux/wayland/client.rs b/crates/gpui-linux/src/linux/wayland/client.rs index dfc66c6..9afd59b 100644 --- a/crates/gpui-linux/src/linux/wayland/client.rs +++ b/crates/gpui-linux/src/linux/wayland/client.rs @@ -10,6 +10,7 @@ use std::{ use ashpd::WindowIdentifier; use calloop::{ EventLoop, LoopHandle, + ping::Ping, timer::{TimeoutAction, Timer}, }; use calloop_wayland_source::WaylandSource; @@ -181,6 +182,10 @@ fn set_ime_cursor_rectangle_after_done( } } +/// Pacing for retry ticks: a fixed 60Hz interval. Retries only occur for throttled or +/// failed-present frames, so matching the output's actual refresh rate wouldn't be observable. +const FRAME_RETRY_INTERVAL: Duration = Duration::from_micros(16_667); + #[derive(Clone)] pub struct Globals { pub qh: QueueHandle, @@ -203,6 +208,7 @@ pub struct Globals { pub gesture_manager: Option, pub system_bell: Option, pub executor: ForegroundExecutor, + pub frame_ping: Ping, } impl Globals { @@ -211,6 +217,7 @@ impl Globals { executor: ForegroundExecutor, qh: QueueHandle, seat: wl_seat::WlSeat, + frame_ping: Ping, ) -> Self { Globals { activation: globals.bind(&qh, 1..=1, ()).ok(), @@ -244,6 +251,7 @@ impl Globals { system_bell: globals.bind(&qh, 1..=1, ()).ok(), executor, qh, + frame_ping, } } } @@ -458,6 +466,45 @@ impl WaylandClientStatePtr { .expect("The pointer should always be valid when dispatching in wayland") } + pub fn dispatch_scheduled_frames(&self) { + let Some(client) = self.0.upgrade() else { + return; + }; + // Release the client borrow before ticking: the tick re-enters GPUI, which can + // borrow the client again (e.g. IME updates). + let windows = client + .borrow() + .windows + .values() + .cloned() + .collect::>(); + for window in windows { + window.scheduled_frame_fired(); + } + } + + /// Queue a retry tick for `surface_id` one refresh interval from now. An immediate + /// retry would spin against the frame-rate throttle that deferred the draw in the + /// first place. + pub fn schedule_frame_retry(&self, surface_id: &ObjectId) { + let client = self.get_client(); + let state = client.borrow(); + let surface_id = surface_id.clone(); + if let Err(err) = state.loop_handle.insert_source( + Timer::from_duration(FRAME_RETRY_INTERVAL), + move |_, _, this| { + let client = this.get_client(); + let window = get_window(&mut client.borrow_mut(), &surface_id); + if let Some(window) = window { + window.retry_timer_fired(); + } + TimeoutAction::Drop + }, + ) { + log::error!("Failed to schedule frame retry: {err}"); + } + } + pub fn get_serial(&self, kind: SerialKind) -> Serial { self.0.upgrade().unwrap().borrow().serial_tracker.get(kind) } @@ -683,12 +730,21 @@ impl WaylandClient { let gpu_context = Rc::new(RefCell::new(None)); + let (frame_ping, frame_ping_source) = + calloop::ping::make_ping().expect("Failed to create the frame ping"); + handle + .insert_source(frame_ping_source, |_, _, client| { + client.dispatch_scheduled_frames(); + }) + .unwrap(); + let seat = seat.unwrap(); let globals = Globals::new( globals, common.foreground_executor.clone(), qh.clone(), seat.clone(), + frame_ping, ); let data_device = globals @@ -1205,7 +1261,7 @@ impl Dispatch for WaylandClientStatePtr { drop(state); if let wl_callback::Event::Done { .. } = event { - window.frame(); + window.frame_callback_fired(); } } } diff --git a/crates/gpui-linux/src/linux/wayland/window.rs b/crates/gpui-linux/src/linux/wayland/window.rs index ede4f99..43c35d1 100644 --- a/crates/gpui-linux/src/linux/wayland/window.rs +++ b/crates/gpui-linux/src/linux/wayland/window.rs @@ -1,11 +1,12 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::{Cell, Ref, RefCell, RefMut}, ffi::c_void, ptr::NonNull, rc::Rc, sync::Arc, }; +use calloop::ping::Ping; use collections::HashMap; use futures::channel::oneshot::Receiver; @@ -14,7 +15,7 @@ use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_callback, wl_output, wl_surface}, }; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; @@ -89,7 +90,6 @@ struct InProgressConfigure { pub struct WaylandWindowState { role: WaylandWindowRole, - acknowledged_first_configure: bool, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -112,8 +112,9 @@ pub struct WaylandWindowState { handle: AnyWindowHandle, active: bool, hovered: bool, - force_render_after_recovery: bool, - renderer_presented: bool, + redraw_requested: bool, + presentation: PresentationState, + pending_frame_callback: Option, in_progress_configure: Option, resize_throttle: bool, in_progress_window_controls: Option, @@ -174,6 +175,8 @@ impl WaylandWindowRole { pub struct WaylandWindowStatePtr { state: Rc>, callbacks: Rc>, + frame_loop: Rc>, + frame_ping: Ping, } impl WaylandWindowState { @@ -217,7 +220,6 @@ impl WaylandWindowState { Ok(Self { role, - acknowledged_first_configure: false, surface, app_id: options.app_id, blur: None, @@ -242,8 +244,9 @@ impl WaylandWindowState { handle, active: false, hovered: false, - force_render_after_recovery: false, - renderer_presented: false, + redraw_requested: false, + presentation: PresentationState::Unpresented, + pending_frame_callback: None, in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, @@ -283,6 +286,75 @@ impl WaylandWindowState { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PresentationState { + Unpresented, + Presented, + RetryBeforeFirstPresent, + RetryAfterPresent, +} + +impl PresentationState { + fn requires_presentation(self) -> bool { + matches!( + self, + Self::RetryBeforeFirstPresent | Self::RetryAfterPresent + ) + } + + fn failed(self) -> Self { + match self { + Self::Unpresented | Self::RetryBeforeFirstPresent => Self::RetryBeforeFirstPresent, + Self::Presented | Self::RetryAfterPresent => Self::RetryAfterPresent, + } + } +} + +#[cfg(test)] +mod presentation_state_tests { + use super::PresentationState; + + #[test] + fn failure_tracks_whether_the_surface_has_presented() { + assert_eq!( + PresentationState::Unpresented.failed(), + PresentationState::RetryBeforeFirstPresent + ); + assert_eq!( + PresentationState::RetryBeforeFirstPresent.failed(), + PresentationState::RetryBeforeFirstPresent + ); + assert_eq!( + PresentationState::Presented.failed(), + PresentationState::RetryAfterPresent + ); + assert_eq!( + PresentationState::RetryAfterPresent.failed(), + PresentationState::RetryAfterPresent + ); + } + + #[test] + fn only_retry_states_require_presentation() { + assert!(!PresentationState::Unpresented.requires_presentation()); + assert!(!PresentationState::Presented.requires_presentation()); + assert!(PresentationState::RetryBeforeFirstPresent.requires_presentation()); + assert!(PresentationState::RetryAfterPresent.requires_presentation()); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameLoop { + Unconfigured, + Ticking, + RescheduleRequested, + PresentationFailed, + AwaitingCallback, + Scheduled, + RetryScheduled, + Parked, +} + pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); pub enum ImeInput { InsertText(String), @@ -293,6 +365,8 @@ pub enum ImeInput { impl Drop for WaylandWindow { fn drop(&mut self) { + self.0.frame_loop.set(FrameLoop::Parked); + let mut state = self.0.state.borrow_mut(); let surface_id = state.surface.id(); let client = state.client.clone(); @@ -368,6 +442,7 @@ impl WaylandWindow { .map(|viewporter| viewporter.get_viewport(&surface, &globals.qh, ())); let mouse_passthrough = params.mouse_passthrough; + let frame_ping = globals.frame_ping.clone(); let this = Self(WaylandWindowStatePtr { state: Rc::new(RefCell::new(WaylandWindowState::new( @@ -383,6 +458,8 @@ impl WaylandWindow { gpu_resource_budget, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), + frame_loop: Rc::new(Cell::new(FrameLoop::Unconfigured)), + frame_ping, }); if mouse_passthrough { @@ -608,23 +685,123 @@ impl WaylandWindowStatePtr { } pub fn frame(&self) { + self.frame_loop.set(FrameLoop::Ticking); let mut state = self.state.borrow_mut(); - state.surface.frame(&state.globals.qh, state.surface.id()); state.resize_throttle = false; - let force_render = state.force_render_after_recovery; - state.force_render_after_recovery = false; + // GPUI may throttle this tick without calling draw, so leave the request + // latched until a draw actually reaches the renderer. + let force_render = state.redraw_requested; + let require_presentation = state.presentation.requires_presentation(); drop(state); - let mut cb = self.callbacks.borrow_mut(); - if let Some(fun) = cb.request_frame.as_mut() { - fun(RequestFrameOptions { - force_render, - ..Default::default() - }); - self.update_ime_enabled(); + let mut callbacks = self.callbacks.borrow_mut(); + let Some(request_frame_callback) = callbacks.request_frame.as_mut() else { + self.frame_loop.set(FrameLoop::Parked); + return; + }; + request_frame_callback(RequestFrameOptions { + force_render, + require_presentation, + }); + self.update_ime_enabled(); + drop(callbacks); + + self.complete_frame(); + } + + fn complete_frame(&self) { + let mut state = self.state.borrow_mut(); + if is_unconfigured_layer_shell(&state.role) { + self.frame_loop.set(FrameLoop::Unconfigured); + return; + } + + let frame_loop = self.frame_loop.get(); + if frame_loop == FrameLoop::AwaitingCallback { + return; + } + + if state.presentation.requires_presentation() { + // Before the first present, or when throttling skipped draw, a + // callback may never arrive. Otherwise let the compositor pace + // retries so an occluded window does not keep polling. + if frame_loop == FrameLoop::PresentationFailed + && state.presentation == PresentationState::RetryAfterPresent + { + if state.pending_frame_callback.is_none() { + let callback = state.surface.frame(&state.globals.qh, state.surface.id()); + state.pending_frame_callback = Some(callback); + } + state.surface.commit(); + self.frame_loop.set(FrameLoop::AwaitingCallback); + return; + } + + self.frame_loop.set(FrameLoop::RetryScheduled); + let surface_id = state.surface.id(); + let client = state.client.clone(); + drop(state); + client.schedule_frame_retry(&surface_id); + return; + } + + if frame_loop == FrameLoop::RescheduleRequested || state.redraw_requested { + self.frame_loop.set(FrameLoop::RetryScheduled); + let surface_id = state.surface.id(); + let client = state.client.clone(); + drop(state); + client.schedule_frame_retry(&surface_id); + return; + } + + self.frame_loop.set(FrameLoop::Parked); + } + + pub fn frame_callback_fired(&self) { + // Another wl_surface commit may have carried this callback while a retry + // timer owned the render-loop wakeup. + self.state.borrow_mut().pending_frame_callback = None; + if self.frame_loop.get() == FrameLoop::AwaitingCallback { + self.frame(); + } + } + + pub fn scheduled_frame_fired(&self) { + if self.frame_loop.get() == FrameLoop::Scheduled { + self.frame(); + } + } + + pub fn retry_timer_fired(&self) { + if self.frame_loop.get() == FrameLoop::RetryScheduled { + self.frame(); } } + pub fn is_configured(&self) -> bool { + self.frame_loop.get() != FrameLoop::Unconfigured + } + + pub fn schedule_frame(&self) { + match self.frame_loop.get() { + FrameLoop::Parked => { + self.frame_loop.set(FrameLoop::Scheduled); + self.frame_ping.ping(); + } + FrameLoop::Ticking => { + self.frame_loop.set(FrameLoop::RescheduleRequested); + } + // A wake is already armed: a ping or retry timer is in flight, or a + // presented buffer guarantees a compositor frame callback. + _ => {} + } + } + + fn request_redraw(&self) { + self.state.borrow_mut().redraw_requested = true; + self.schedule_frame(); + } + fn update_ime_enabled(&self) { let mut state = self.state.borrow_mut(); if !state.active { @@ -696,7 +873,7 @@ impl WaylandWindowStatePtr { } } } - let mut state = self.state.borrow_mut(); + let state = self.state.borrow_mut(); let xdg_surface = match &state.role { WaylandWindowRole::XdgToplevel { xdg_surface, .. } => xdg_surface.clone(), WaylandWindowRole::LayerShell { .. } => return, @@ -718,11 +895,12 @@ impl WaylandWindowStatePtr { window_geometry.size.height, ); - let request_frame_callback = !state.acknowledged_first_configure; - if request_frame_callback { - state.acknowledged_first_configure = true; - drop(state); + let initial_configure = !self.is_configured(); + drop(state); + if initial_configure { self.frame(); + } else { + self.request_redraw(); } } } @@ -749,11 +927,15 @@ impl WaylandWindowStatePtr { } WEnum::Value(_) => { log::warn!("Unknown decoration mode"); + return; } WEnum::Unknown(v) => { log::warn!("Unknown decoration mode: {}", v); + return; } } + update_window(self.state.borrow_mut()); + self.request_redraw(); } } @@ -780,7 +962,12 @@ impl WaylandWindowStatePtr { layer_surface.ack_configure(serial); drop(state); self.resize(size); - self.frame(); + let initial_configure = !self.is_configured(); + if initial_configure { + self.frame(); + } else { + self.request_redraw(); + } false } zwlr_layer_surface_v1::Event::Closed => true, @@ -791,6 +978,7 @@ impl WaylandWindowStatePtr { pub fn handle_fractional_scale_event(&self, event: wp_fractional_scale_v1::Event) { if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event { self.rescale(scale as f32 / 120.0); + self.request_redraw(); } } @@ -926,7 +1114,10 @@ impl WaylandWindowStatePtr { state.surface.set_buffer_scale(scale); drop(state); self.rescale(scale as f32); + } else { + drop(state); } + self.request_redraw(); } wl_surface::Event::Leave { output } => { state.outputs.remove(&output.id()); @@ -938,7 +1129,10 @@ impl WaylandWindowStatePtr { state.surface.set_buffer_scale(scale); drop(state); self.rescale(scale as f32); + } else { + drop(state); } + self.request_redraw(); } wl_surface::Event::PreferredBufferScale { factor } => { // We use `WpFractionalScale` instead to set the scale if it's available @@ -946,6 +1140,7 @@ impl WaylandWindowStatePtr { state.surface.set_buffer_scale(factor); drop(state); self.rescale(factor as f32); + self.request_redraw(); } } _ => {} @@ -1294,8 +1489,12 @@ impl PlatformWindow for WaylandWindow { fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { let mut state = self.borrow_mut(); + if state.background_appearance == background_appearance { + return; + } state.background_appearance = background_appearance; update_window(state); + self.0.request_redraw(); } fn minimize(&self) { @@ -1374,6 +1573,11 @@ impl PlatformWindow for WaylandWindow { fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); + if is_unconfigured_layer_shell(&state.role) { + state.redraw_requested = true; + return; + } + if state.renderer.device_lost() { let raw_window = RawWindow { window: state.surface.id().as_ptr().cast::(), @@ -1389,27 +1593,31 @@ impl PlatformWindow for WaylandWindow { log::warn!("GPU recovery failed, will retry on next frame: {err}"); } - state.force_render_after_recovery = true; + state.redraw_requested = true; return; } - state.renderer_presented = state.renderer.draw(scene); + // Surface state changed during this GPUI tick is included in this presentation. + state.redraw_requested = false; + if state.pending_frame_callback.is_none() { + let callback = state.surface.frame(&state.globals.qh, state.surface.id()); + state.pending_frame_callback = Some(callback); + } + if state.renderer.draw(scene) { + state.presentation = PresentationState::Presented; + self.0.frame_loop.set(FrameLoop::AwaitingCallback); + } else { + state.presentation = state.presentation.failed(); + self.0.frame_loop.set(FrameLoop::PresentationFailed); + } if state.renderer.needs_redraw() { - state.force_render_after_recovery = true; + state.redraw_requested = true; } } - fn completed_frame(&self) { - let mut state = self.borrow_mut(); - if is_unconfigured_layer_shell(&state.role) { - state.renderer_presented = false; - return; - } - if !state.renderer_presented { - state.surface.commit(); - } - state.renderer_presented = false; + fn schedule_frame(&self) { + self.0.schedule_frame(); } #[cfg(any(test, feature = "test-support"))] @@ -1482,7 +1690,10 @@ impl PlatformWindow for WaylandWindow { if let Some(decoration) = decoration { decoration.set_mode(window_decorations_to_xdg(decorations)); update_window(state); + } else { + drop(state); } + self.0.request_redraw(); } fn window_controls(&self) -> WindowControls { @@ -1494,6 +1705,7 @@ impl PlatformWindow for WaylandWindow { if Some(inset) != state.client_inset { state.client_inset = Some(inset); update_window(state); + self.0.request_redraw(); } } @@ -1520,6 +1732,7 @@ impl PlatformWindow for WaylandWindow { let mut state = self.borrow_mut(); state.visible = true; let size = state.bounds.size; + let remapping_layer_shell = matches!(&state.role, WaylandWindowRole::LayerShell { .. }); match &mut state.role { WaylandWindowRole::LayerShell { configured, @@ -1531,8 +1744,15 @@ impl PlatformWindow for WaylandWindow { } WaylandWindowRole::XdgToplevel { .. } => {} } - state.surface.frame(&state.globals.qh, state.surface.id()); + // Commit so the compositor sends a configure. Do not request a fake + // frame callback; the demand-driven loop waits for configure or a ping. state.surface.commit(); + drop(state); + if remapping_layer_shell { + self.0.frame_loop.set(FrameLoop::Unconfigured); + } else { + self.0.request_redraw(); + } } fn hide(&self) { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index b48cfea..0894c77 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1786,6 +1786,15 @@ impl App { } if self.pending_effects.is_empty() { + for window in self.windows.values().filter_map(|window| window.as_ref()) { + if window.invalidator.is_dirty() + || window.needs_present.get() + || !window.next_frame_callbacks.borrow().is_empty() + { + window.platform_window.schedule_frame(); + } + } + break; } } @@ -2970,17 +2979,48 @@ impl<'a, T> Drop for GpuiBorrow<'a, T> { #[cfg(test)] mod test { - use std::{cell::RefCell, rc::Rc, sync::Arc}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, + }; use rand::{SeedableRng, rngs::StdRng}; use super::{Application, ApplicationHandle, NullHttpClient}; use crate::{ - AppContext, AppResourceProfile, BackgroundExecutor, ForegroundExecutor, Platform, QuitMode, - TestAppContext, TestDispatcher, TestPlatform, TrayIconClickEvent, TrayIconEvent, - TrayIconRenderingMode, WindowAppearance, point, px, + AppContext, AppResourceProfile, BackgroundExecutor, Context, Empty, ForegroundExecutor, + IntoElement, Platform, QuitMode, Render, TestAppContext, TestDispatcher, TestPlatform, + TrayIconClickEvent, TrayIconEvent, TrayIconRenderingMode, Window, WindowAppearance, point, + px, }; + struct RenderCounter(Rc>); + + impl Render for RenderCounter { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.0.set(self.0.get() + 1); + Empty + } + } + + #[crate::test] + fn async_app_refresh_flushes_refresh_effect(cx: &mut TestAppContext) { + let render_count = Rc::new(Cell::new(0)); + + let _window = cx.add_window({ + let render_count = render_count.clone(); + move |_, _| RenderCounter(render_count) + }); + + cx.run_until_parked(); + let render_count_before_refresh = render_count.get(); + + cx.to_async().refresh().unwrap(); + + assert_eq!(render_count.get(), render_count_before_refresh + 1); + } + #[test] fn test_with_platform_uses_injected_platform() { let dispatcher = Arc::new(TestDispatcher::new(StdRng::seed_from_u64(0))); diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index e833516..ab3047d 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -124,7 +124,9 @@ impl AsyncApp { pub fn refresh(&self) -> Result<()> { let app = self.app.upgrade().context("app was released")?; let mut lock = app.borrow_mut(); - lock.refresh_windows(); + // A direct call would leave the refresh effect queued, which cannot wake + // a platform render loop that has already parked. + lock.update(|cx| cx.refresh_windows()); Ok(()) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 0896390..d848fda 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -757,7 +757,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn render_to_image(&self, _scene: &Scene) -> anyhow::Result { anyhow::bail!("render_to_image not implemented for this platform") } - fn completed_frame(&self) {} + fn schedule_frame(&self) {} fn sprite_atlas(&self) -> Arc; // macOS specific methods diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 7538cdc..a5e568a 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1059,7 +1059,7 @@ pub struct Window { next_hitbox_id: HitboxId, pub(crate) next_tooltip_id: TooltipId, pub(crate) tooltip_bounds: Option, - next_frame_callbacks: Rc>>, + pub(crate) next_frame_callbacks: Rc>>, pub(crate) dirty_views: FxHashSet, focus_listeners: SubscriberSet<(), AnyWindowFocusListener>, pub(crate) focus_lost_listeners: SubscriberSet<(), AnyObserver>, @@ -1382,11 +1382,11 @@ impl Window { // unwinds. Remember force_render so the deferred frame still // bypasses the view cache. // - // Returning here skips `complete_frame`, which on Wayland would - // stall the window's frame callbacks (no `surface.commit()`) — - // but calling it would hit the App borrow panic above, and this - // branch is unreachable there in practice: only Windows pumps - // platform events (and thus requests frames) mid-draw. + // Returning here skips a demand-driven `schedule_frame` retry. + // Calling into App would panic on its already-mutable borrow, + // and this branch is unreachable on Wayland in practice: only + // Windows pumps platform events (and thus requests frames) + // mid-draw. if draw_in_progress() { log::debug!("deferring re-entrant window draw request"); deferred_force_render |= request_frame_options.force_render; @@ -1425,12 +1425,11 @@ impl Window { if now.duration_since(last_frame) < min_interval { // Don't lose a pending forced render to throttling. deferred_force_render |= force_render; - // Must still complete the frame on platforms that require it. - // On Wayland, `surface.frame()` was already called to request the - // next frame callback, so we must call `surface.commit()` (via - // `complete_frame`) or the compositor won't send another callback. + // Deferred by throttling: ask demand-driven platforms to retry. handle - .update(&mut cx, |_, window, _| window.complete_frame()) + .update(&mut cx, |_, window, _| { + window.platform_window.schedule_frame(); + }) .log_err(); return; } @@ -1477,7 +1476,11 @@ impl Window { handle .update(&mut cx, |_, window, _| { - window.complete_frame(); + if window.invalidator.is_dirty() + || !window.next_frame_callbacks.borrow().is_empty() + { + window.platform_window.schedule_frame(); + } }) .log_err(); } @@ -2096,6 +2099,7 @@ impl Window { /// Schedule the given closure to be run directly after the current frame is rendered. pub fn on_next_frame(&self, callback: impl FnOnce(&mut Window, &mut App) + 'static) { RefCell::borrow_mut(&self.next_frame_callbacks).push(Box::new(callback)); + self.platform_window.schedule_frame(); } /// Schedule a frame to be drawn on the next animation frame. @@ -2453,10 +2457,6 @@ impl Window { self.capslock } - fn complete_frame(&self) { - self.platform_window.completed_frame(); - } - /// Produces a new frame and assigns it to `rendered_frame`. To actually show /// the contents of the new `Scene`, use `present`. #[profiling::function] From ab2fb46c245f4552f2c5f7df70e70edcdc70801f Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 09:35:30 +0000 Subject: [PATCH 2/4] perf(gpui-linux): prewarm Linux font match caches Expose TextSystem::prewarm_fonts and warm cosmic-text get_font_matches for the requested fonts so shaping does not pay that cost on the hot path. Zed-Origin: f1d27d545e79f98ffde7ebf3229f8eb8bad791aa Co-authored-by: freefcw --- crates/gpui-linux/src/linux/text_system.rs | 138 ++++++++++++++++----- crates/gpui/src/platform.rs | 2 + crates/gpui/src/text_system.rs | 16 +++ 3 files changed, 125 insertions(+), 31 deletions(-) diff --git a/crates/gpui-linux/src/linux/text_system.rs b/crates/gpui-linux/src/linux/text_system.rs index d116d35..b55a15f 100644 --- a/crates/gpui-linux/src/linux/text_system.rs +++ b/crates/gpui-linux/src/linux/text_system.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ Attrs, AttrsList, CacheKey, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, SwashCache, + FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, SwashCache, Weight, }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, @@ -59,6 +59,27 @@ struct LoadedFont { user_fallback_chain: Arc<[(FontId, SharedString)]>, } +struct FontMatchProperties { + primary_family_name: SharedString, + stretch: Stretch, + style: Style, + weight: Weight, + features: CosmicFontFeatures, + fallback_chain: Arc<[(FontId, SharedString)]>, +} + +impl FontMatchProperties { + fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> { + Attrs::new() + .metadata(font_id.0) + .family(Family::Name(family_name)) + .stretch(self.stretch) + .style(self.style) + .weight(self.weight) + .font_features(self.features.clone()) + } +} + impl CosmicTextSystem { pub(crate) fn new() -> Self { // todo(linux) make font loading non-blocking @@ -133,6 +154,10 @@ impl PlatformTextSystem for CosmicTextSystem { Ok(candidates[ix]) } + fn prewarm_fonts(&self, font_ids: &[FontId]) { + self.0.write().prewarm_fonts(font_ids); + } + fn font_metrics(&self, font_id: FontId) -> FontMetrics { let metrics = self .0 @@ -204,6 +229,43 @@ impl CosmicTextSystemState { &self.loaded_fonts[font_id.0] } + fn font_match_properties(&self, font_id: FontId) -> Option { + let loaded_font = self.loaded_font(font_id); + let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { + log::warn!("font face not found in database for font_id {:?}", font_id); + return None; + }; + let Some(first_family) = face.families.first() else { + log::warn!("font face has no family names for font_id {:?}", font_id); + return None; + }; + + Some(FontMatchProperties { + primary_family_name: first_family.0.clone().into(), + stretch: face.stretch, + style: face.style, + weight: face.weight, + features: loaded_font.features.clone(), + fallback_chain: Arc::clone(&loaded_font.user_fallback_chain), + }) + } + + fn prewarm_fonts(&mut self, font_ids: &[FontId]) { + for &font_id in font_ids { + let Some(properties) = self.font_match_properties(font_id) else { + continue; + }; + let primary_attributes = + properties.attributes(font_id, &properties.primary_family_name); + self.font_system.get_font_matches(&primary_attributes); + + for (fallback_id, fallback_name) in &*properties.fallback_chain { + let fallback_attributes = properties.attributes(*fallback_id, fallback_name); + self.font_system.get_font_matches(&fallback_attributes); + } + } + } + fn font_weight(&self, font_id: cosmic_text::fontdb::ID) -> cosmic_text::Weight { self.font_system .db() @@ -547,38 +609,19 @@ impl CosmicTextSystemState { let mut offs = 0; for run in font_runs { let run_end = offs + run.len; - let loaded_font = self.loaded_font(run.font_id); - let font = self.font_system.db().face(loaded_font.font.id()).unwrap(); - - let primary_family = font.families.first().unwrap().0.clone(); - let primary_stretch = font.stretch; - let primary_style = font.style; - let primary_weight = font.weight; - let primary_features = loaded_font.features.clone(); - let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain); - - let primary_attrs = Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&primary_family)) - .stretch(primary_stretch) - .style(primary_style) - .weight(primary_weight) - .font_features(primary_features.clone()); - - let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain + let Some(properties) = self.font_match_properties(run.font_id) else { + offs = run_end; + continue; + }; + + let primary_attrs = properties.attributes(run.font_id, &properties.primary_family_name); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = properties + .fallback_chain .iter() - .map(|(fallback_id, fallback_family)| { - Attrs::new() - .metadata(fallback_id.0) - .family(Family::Name(fallback_family)) - .stretch(primary_stretch) - .style(primary_style) - .weight(primary_weight) - .font_features(primary_features.clone()) - }) + .map(|(font_id, family_name)| properties.attributes(*font_id, family_name)) .collect(); - let spans = if fallback_chain.is_empty() { + let spans = if properties.fallback_chain.is_empty() { smallvec::smallvec![RunSpan { start: offs, end: run_end, @@ -587,7 +630,14 @@ impl CosmicTextSystemState { } else { let loaded_fonts = &self.loaded_fonts; let covers = |font_id: FontId, ch: char| charmap_covers(loaded_fonts, font_id, ch); - compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) + compute_run_spans( + text, + offs, + run.len, + run.font_id, + &properties.fallback_chain, + &covers, + ) }; for span in spans { @@ -823,6 +873,32 @@ mod tests { include_bytes!("../../test_data/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); const LILEX: &[u8] = include_bytes!("../../test_data/fonts/lilex/Lilex-Regular.ttf"); + #[test] + fn prewarm_fonts_is_safe_for_loaded_and_fallback_fonts() { + let text_system = CosmicTextSystem::new(); + text_system + .add_fonts(vec![Cow::Borrowed(IBM_PLEX_SANS), Cow::Borrowed(LILEX)]) + .unwrap(); + + let primary_family = family_name(IBM_PLEX_SANS); + let fallback_family = family_name(LILEX); + let mut primary_font = font(primary_family); + primary_font.fallbacks = Some(FontFallbacks::from_fonts(vec![fallback_family])); + let primary_id = text_system.font_id(&primary_font).unwrap(); + + text_system.prewarm_fonts(&[primary_id]); + + let layout = text_system.layout_line( + "AB", + px(16.), + &[FontRun { + len: 2, + font_id: primary_id, + }], + ); + assert!(!layout.runs.is_empty()); + } + #[test] fn layout_line_uses_configured_font_fallbacks_for_missing_glyphs() { let text_system = CosmicTextSystem::new(); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index d848fda..44896f2 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -872,6 +872,8 @@ pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; fn all_font_names(&self) -> Vec; fn font_id(&self, descriptor: &Font) -> Result; + /// Prewarm any system font caches needed to shape text. + fn prewarm_fonts(&self, _font_ids: &[FontId]) {} fn font_metrics(&self, font_id: FontId) -> FontMetrics; fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index f07f61b..0811e0f 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -161,6 +161,22 @@ impl TextSystem { self.platform_text_system.add_fonts(fonts) } + /// Prewarm any system font caches needed to shape text. + /// + /// This may be expensive, so callers should generally invoke it on a + /// background executor. Missing entries are still populated on demand by + /// the normal shaping path. + pub fn prewarm_fonts(&self, fonts: &[Font]) { + let mut font_ids = SmallVec::<[FontId; 8]>::new(); + for font in fonts { + let font_id = self.resolve_font(font); + if !font_ids.contains(&font_id) { + font_ids.push(font_id); + } + } + self.platform_text_system.prewarm_fonts(&font_ids); + } + /// Returns the platform-recommended glyph dilation level for the given foreground color. /// Used to compensate perceived stroke weight on platforms where the OS text renderer /// applies font-smoothing differently for light vs dark glyphs. From ec811a9688a54b95b8d9d364ec7e0965f52a04c2 Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 10:18:13 +0000 Subject: [PATCH 3/4] fix(gpui-linux): pace post-present retries and keep hide() unmapped Key compositor-paced retries on PresentationState::RetryAfterPresent so frame() setting Ticking no longer skips the occluded-surface path. Skip buffer attach and frame scheduling while visible is false so a refresh cannot remap a hidden XDG or configured layer-shell window. Co-authored-by: freefcw --- crates/gpui-linux/src/linux/wayland/window.rs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/gpui-linux/src/linux/wayland/window.rs b/crates/gpui-linux/src/linux/wayland/window.rs index 43c35d1..7fcb11d 100644 --- a/crates/gpui-linux/src/linux/wayland/window.rs +++ b/crates/gpui-linux/src/linux/wayland/window.rs @@ -308,6 +308,13 @@ impl PresentationState { Self::Presented | Self::RetryAfterPresent => Self::RetryAfterPresent, } } + + /// After the first successful present, pace retries with compositor frame + /// callbacks so an occluded surface does not keep polling. Before the first + /// present a callback may never arrive, so those retries use the timer. + fn compositor_paced_retry(self) -> bool { + self == Self::RetryAfterPresent + } } #[cfg(test)] @@ -341,6 +348,19 @@ mod presentation_state_tests { assert!(PresentationState::RetryBeforeFirstPresent.requires_presentation()); assert!(PresentationState::RetryAfterPresent.requires_presentation()); } + + #[test] + fn failed_present_after_a_successful_frame_uses_compositor_pacing() { + // frame() sets FrameLoop::Ticking before complete_frame, so the retry + // policy must follow presentation state rather than PresentationFailed. + let after_failed_present = PresentationState::Presented.failed(); + assert_eq!(after_failed_present, PresentationState::RetryAfterPresent); + assert!(after_failed_present.compositor_paced_retry()); + assert!(PresentationState::RetryAfterPresent.compositor_paced_retry()); + assert!(!PresentationState::RetryBeforeFirstPresent.compositor_paced_retry()); + assert!(!PresentationState::Unpresented.compositor_paced_retry()); + assert!(!PresentationState::Presented.compositor_paced_retry()); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -685,6 +705,12 @@ impl WaylandWindowStatePtr { } pub fn frame(&self) { + if !self.state.borrow().visible { + self.state.borrow_mut().redraw_requested = true; + self.frame_loop.set(FrameLoop::Parked); + return; + } + self.frame_loop.set(FrameLoop::Ticking); let mut state = self.state.borrow_mut(); state.resize_throttle = false; @@ -711,6 +737,10 @@ impl WaylandWindowStatePtr { fn complete_frame(&self) { let mut state = self.state.borrow_mut(); + if !state.visible { + self.frame_loop.set(FrameLoop::Parked); + return; + } if is_unconfigured_layer_shell(&state.role) { self.frame_loop.set(FrameLoop::Unconfigured); return; @@ -723,11 +753,11 @@ impl WaylandWindowStatePtr { if state.presentation.requires_presentation() { // Before the first present, or when throttling skipped draw, a - // callback may never arrive. Otherwise let the compositor pace - // retries so an occluded window does not keep polling. - if frame_loop == FrameLoop::PresentationFailed - && state.presentation == PresentationState::RetryAfterPresent - { + // callback may never arrive. After a successful present, pace + // retries with the compositor so an occluded window does not keep + // polling at 60 Hz. This depends on presentation state because + // frame() has already set Ticking. + if state.presentation.compositor_paced_retry() { if state.pending_frame_callback.is_none() { let callback = state.surface.frame(&state.globals.qh, state.surface.id()); state.pending_frame_callback = Some(callback); @@ -783,6 +813,9 @@ impl WaylandWindowStatePtr { } pub fn schedule_frame(&self) { + if !self.state.borrow().visible { + return; + } match self.frame_loop.get() { FrameLoop::Parked => { self.frame_loop.set(FrameLoop::Scheduled); @@ -1573,7 +1606,7 @@ impl PlatformWindow for WaylandWindow { fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); - if is_unconfigured_layer_shell(&state.role) { + if !state.visible || is_unconfigured_layer_shell(&state.role) { state.redraw_requested = true; return; } @@ -1758,6 +1791,7 @@ impl PlatformWindow for WaylandWindow { fn hide(&self) { let mut state = self.borrow_mut(); state.visible = false; + self.0.frame_loop.set(FrameLoop::Parked); if is_unconfigured_layer_shell(&state.role) { return; } From c209002548c4622800c0dad2268d7c3eda6190d2 Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 11:07:02 +0000 Subject: [PATCH 4/4] ci(gpui): follow sourced scripts in shellcheck shellcheck without -x cannot follow verify-common.sh, and the first release-archive loop never uses manifest_path. Point shellcheck at scripts/ and ignore the unused TSV column. Co-authored-by: freefcw --- .github/workflows/gpui-feature-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gpui-feature-matrix.yml b/.github/workflows/gpui-feature-matrix.yml index 5a1fc0d..75fc4c1 100644 --- a/.github/workflows/gpui-feature-matrix.yml +++ b/.github/workflows/gpui-feature-matrix.yml @@ -104,7 +104,7 @@ jobs: persist-credentials: false - name: Check shell scripts - run: shellcheck scripts/*.sh + run: shellcheck --source-path=scripts -x scripts/*.sh - name: Check workflow syntax run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.9