git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commita976f4211164c59a048bf91a03cf45ea5bc21e4f
parent3877dd6679
authorLucas Galante <[email protected]>
date2026-07-20 12:49
fix: pair set_buffer_scale with a matching-size present; exit on fatal protocol errors

Sending set_buffer_scale from the scale event races in-flight presents of
old-scale buffers — a fatal invalid_size protocol error (seen on resume,
when outputs bounce 2→1→2). The request now goes out in render(), ordered
right before the only attach+commit on the surface, and only when the
pending swapchain extent is divisible by the new scale; resize() snaps
physical dimensions up to that divisibility so a fractional logical size
can't queue an illegal extent.

Also: calloop's WaylandSource swallows non-Io queue-flush errors, so a dead
display used to spin the event loop forever — check conn.protocol_error()
each iteration and exit.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/backend/window_runner.rs | 45 ++++++++++++++++++++++++++++++++++++++++++--
 src/vk/renderer.rs           |  2 +-
 2 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index a2e9bd1..7490cc0 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2436,6 +2436,10 @@ pub struct EngineState<A: Application> {
     pub swash_cache: glyphon::SwashCache,
     
     pub scale_factor: f64,
+    /// The buffer scale last sent to the surface. Updated in [`Self::render`],
+    /// paired with the present that commits a matching-size buffer — never on
+    /// the scale event itself, which races in-flight presents of old buffers.
+    pub committed_buffer_scale: i32,
     pub logical_width: f32,
     pub logical_height: f32,
     
@@ -2496,7 +2500,13 @@ impl<A: Application> EngineState<A> {
             self.logical_width = w;
             self.logical_height = h;
             if let Some(ref mut renderer) = self.renderer {
-                renderer.resize((w as f64 * self.scale_factor) as u32, (h as f64 * self.scale_factor) as u32);
+                // wl_surface requires buffer dimensions divisible by the buffer
+                // scale; snap up so a fractional logical size can't queue an
+                // illegal swapchain extent.
+                let s = (self.scale_factor.round() as u32).max(1);
+                let pw = ((w as f64 * self.scale_factor).round() as u32).max(1).div_ceil(s) * s;
+                let ph = ((h as f64 * self.scale_factor).round() as u32).max(1).div_ceil(s) * s;
+                renderer.resize(pw, ph);
             }
             let scale = self.scale_factor;
             self.inner.as_mut().unwrap().handle_resize(w, h, scale);
@@ -2760,6 +2770,23 @@ impl<A: Application> EngineState<A> {
             self.frame_callback_pending = true;
         }
 
+        // Commit the buffer scale together with a buffer it is legal for: the
+        // present inside draw_frame_2d is the only commit on this surface, so
+        // sending the request here orders it right before a matching-size
+        // attach+commit. Skipped while the pending extent isn't divisible (a
+        // transition frame) — the old committed scale stays legal for it.
+        if let Some(ref surface) = self.surface {
+            let s = (self.scale_factor.round() as i32).max(1);
+            let e = renderer.pending_extent();
+            if s != self.committed_buffer_scale
+                && e.width % s as u32 == 0
+                && e.height % s as u32 == 0
+            {
+                surface.set_buffer_scale(s);
+                self.committed_buffer_scale = s;
+            }
+        }
+
         // Direct renderer staging (3D scenes, RT panes, app-shaped text).
         if self.inner.as_mut().unwrap().stage_renderer(
             renderer,
@@ -2794,7 +2821,10 @@ impl<A: Application> CompositorHandler for EngineState<A> {
         _surface: &wl_surface::WlSurface,
         scale_factor: i32,
     ) {
-        _surface.set_buffer_scale(scale_factor);
+        // Don't send set_buffer_scale here: an in-flight present can commit an
+        // old-scale-sized buffer right after it, which is a fatal invalid_size
+        // protocol error (seen on resume, when outputs bounce 2→1→2). The scale
+        // request is sent in `render`, paired with a matching-size present.
         self.scale_factor = scale_factor as f64;
         self.resize(self.logical_width, self.logical_height);
         self.redraw = true;
@@ -3571,6 +3601,7 @@ pub fn run<A: Application>() {
         font_system: None,
         swash_cache: glyphon::SwashCache::new(),
         scale_factor: 1.0,
+        committed_buffer_scale: 1,
         logical_width: 0.0,
         logical_height: 0.0,
         exit: false,
@@ -3609,6 +3640,7 @@ pub fn run<A: Application>() {
 
     let surface = engine_state.compositor_state.create_surface(&qh);
     surface.set_buffer_scale(scale as i32);
+    engine_state.committed_buffer_scale = scale as i32;
 
     if settings.app_id.starts_with("cce-status") {
         let compositor = engine_state.compositor_state.wl_compositor();
@@ -3687,6 +3719,15 @@ pub fn run<A: Application>() {
             log::error!("[window_runner] Event loop error: {:?}", e);
             break;
         }
+        // A protocol error kills the connection permanently, but it surfaces
+        // through queue flushes whose errors calloop's WaylandSource swallows
+        // (it only treats Io errors as fatal) — without this check the loop
+        // spins forever on a dead display while wayland-backend re-prints the
+        // error on every flush attempt.
+        if let Some(perr) = conn.protocol_error() {
+            log::error!("[window_runner] Fatal Wayland protocol error, exiting: {perr}");
+            break;
+        }
         if engine_state.exit {
             break;
         }
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 13433f3..8ad9af5 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1064,7 +1064,7 @@ impl VkRenderer {
 
     /// The extent the next `draw_frame` will render at: the pending size when a
     /// swapchain rebuild is queued, otherwise the live one.
-    fn pending_extent(&self) -> vk::Extent2D {
+    pub fn pending_extent(&self) -> vk::Extent2D {
         if self.swapchain_dirty { self.desired_extent } else { self.extent }
     }