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

commiteb26b875731e6a7fdf25750fcc17ac95d0cead83
parent71ecaa2826
authorLucas Galante <[email protected]>
date2026-07-31 08:50
fix: present only the exact extent the logical size and scale call for

The resume-bounce guard checked divisibility, but an even-sized
old-scale buffer divides cleanly by the new scale: the mispaired
commit is protocol-legal, the compositor reads it as a self-resize,
and the window comes back from suspend at exactly half size (the
odd-size case at least dies loudly). Root observed in the wild: the
swapchain rebuild honored surface caps over the requested extent and
then overwrote desired_extent with the result, so pending_extent
could never disagree again and the halved size stuck.

- window_runner: one buffer_geometry() formula feeds both resize()
  and the render guard; render skips (and re-requests) any pending
  extent that isn't exactly logical x scale, before the frame-callback
  request. A skipped present now also clears frame_callback_pending —
  the out-of-date path could stall the demand-driven loop waiting on
  a callback no commit latches.
- vk/renderer: a rebuild that can't honor the requested extent keeps
  the request, requeues itself, and draw_frame skips the present
  instead of committing a wrong-size buffer.

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

 src/backend/window_runner.rs | 90 ++++++++++++++++++++++++++------------------
 src/vk/renderer.rs           | 30 +++++++++++++--
 2 files changed, 79 insertions(+), 41 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 029e766..ff2347a 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2640,23 +2640,35 @@ impl<A: Application> EngineState<A> {
         self.logical_height = height_logical;
     }
 
+    /// Buffer scale and physical extent for a logical size under the current
+    /// scale factor: rounded, then snapped up so the extent divides by the
+    /// buffer scale (a wl_surface requirement). In forced-scale mode the
+    /// surface stays at buffer_scale 1 (the compositor believes scale 1).
+    ///
+    /// This is the single source of the buffer-size formula: `resize` sizes
+    /// the swapchain with it and `render` refuses to present any extent that
+    /// disagrees with it — a mispaired buffer/scale commit is how the resume
+    /// output bounce halved even-sized windows (buffer at the old scale's
+    /// size, new scale latched; the compositor reads it as a self-resize).
+    fn buffer_geometry(scale_factor: f64, w: f32, h: f32) -> (i32, u32, u32) {
+        let s = if crate::scale::forced_scale().is_some() {
+            1
+        } else {
+            (scale_factor.round() as i32).max(1)
+        };
+        let su = s as u32;
+        let pw = ((w as f64 * scale_factor).round() as u32).max(1).div_ceil(su) * su;
+        let ph = ((h as f64 * scale_factor).round() as u32).max(1).div_ceil(su) * su;
+        (s, pw, ph)
+    }
+
     pub fn resize(&mut self, w: f32, h: f32) {
         let (w, h) = self.inner.as_ref().unwrap().adjust_size(w, h);
         if w > 0.0 && h > 0.0 {
             self.logical_width = w;
             self.logical_height = h;
+            let (_, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
             if let Some(ref mut renderer) = self.renderer {
-                // wl_surface requires buffer dimensions divisible by the buffer
-                // scale; snap up so a fractional logical size can't queue an
-                // illegal swapchain extent. In forced-scale mode the surface
-                // stays at buffer_scale 1 (the compositor believes scale 1).
-                let s = if crate::scale::forced_scale().is_some() {
-                    1
-                } else {
-                    (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;
@@ -2920,35 +2932,32 @@ impl<A: Application> EngineState<A> {
         // 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.
+        // attach+commit.
+        //
+        // Present only the EXACT extent the current logical size and scale
+        // call for. Divisibility is not enough: mid scale-transition (the
+        // resume output bounce) the pending extent can belong to the other
+        // scale, and an even-sized old-scale buffer divides cleanly by the
+        // new scale — the commit is protocol-legal, so the compositor reads
+        // it as a self-resize to half/double and reconfigures the window to
+        // match (how the color editor came back from suspend at exactly half
+        // size with the divisibility guard green). Odd sizes at least die
+        // loudly (invalid_size). On mismatch, re-request the right extent
+        // and skip — before the frame-callback request below, so the loop
+        // isn't left waiting on a callback no commit will ever latch.
         if let Some(ref surface) = self.surface {
-            let s = if crate::scale::forced_scale().is_some() {
-                1
-            } else {
-                (self.scale_factor.round() as i32).max(1)
-            };
+            let (s, epw, eph) =
+                Self::buffer_geometry(self.scale_factor, self.logical_width, self.logical_height);
             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;
-            }
-            // Never present a buffer the latching scale can't legally
-            // describe: mid scale-transition (resume output bounce) the
-            // extent can belong to the other scale, and committing it is a
-            // fatal invalid_size for odd sizes and a half/double-size window
-            // for even ones. Skip the frame — before the frame-callback
-            // request below, so the loop isn't left waiting on a callback no
-            // commit will ever latch; the next resize/scale event re-syncs
-            // extent and scale and redraws.
-            let latching = self.committed_buffer_scale.max(1) as u32;
-            if e.width % latching != 0 || e.height % latching != 0 {
+            if e.width != epw || e.height != eph {
+                renderer.resize(epw, eph);
                 self.redraw = true;
                 return;
             }
+            if s != self.committed_buffer_scale {
+                surface.set_buffer_scale(s);
+                self.committed_buffer_scale = s;
+            }
         }
 
         if let Some(ref surface) = self.surface {
@@ -2965,14 +2974,21 @@ impl<A: Application> EngineState<A> {
             self.redraw = true;
         }
 
-        renderer.draw_frame_2d(Frame2D {
+        if !renderer.draw_frame_2d(Frame2D {
             verts: &verts,
             batches: &batches,
             overlay_verts: &overlay_verts,
             images: &image_quads,
             plate_features: &plate_features,
             clear_color,
-        });
+        }) {
+            // No present happened (swapchain out-of-date, or the created
+            // swapchain didn't match the requested extent). The frame
+            // callback requested above will never latch without a commit —
+            // clear it or the demand-driven loop stalls waiting forever.
+            self.frame_callback_pending = false;
+            self.redraw = true;
+        }
     }
 }
 
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 704c18b..db96263 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -976,10 +976,26 @@ impl VkRenderer {
                 self.swapchain_loader.destroy_swapchain(old_swapchain, None);
             }
             self.extent = extent;
-            // Keep the two in step so a rebuild queued for a non-resize reason
-            // (suboptimal/out-of-date) doesn't hand `pending_extent` a stale or
-            // unclamped size.
-            self.desired_extent = extent;
+            if extent.width == self.desired_extent.width && extent.height == self.desired_extent.height {
+                // Keep the two in step so a rebuild queued for a non-resize
+                // reason (suboptimal/out-of-date) doesn't hand
+                // `pending_extent` a stale or unclamped size.
+                self.desired_extent = extent;
+            } else {
+                // The surface capabilities overrode the requested size (seen
+                // on suspend/resume, when caps briefly lag the real surface
+                // state). Presenting this swapchain would commit a buffer the
+                // caller never approved — paired with the wrong buffer scale
+                // that reads as a self-resize and half/double-sizes the
+                // window. Keep the request, requeue the rebuild, and let
+                // draw_frame skip the present until caps agree.
+                log::warn!(
+                    "swapchain extent {}x{} != requested {}x{}; skipping present until they agree",
+                    extent.width, extent.height,
+                    self.desired_extent.width, self.desired_extent.height,
+                );
+                self.swapchain_dirty = true;
+            }
 
             let images = self
                 .swapchain_loader
@@ -1402,6 +1418,12 @@ impl VkRenderer {
         if self.swapchain_dirty {
             self.swapchain_dirty = false;
             self.recreate_swapchain();
+            if self.swapchain_dirty {
+                // The rebuild couldn't honor the requested extent (surface
+                // caps disagree, e.g. mid suspend/resume) — presenting it
+                // would commit a wrong-size buffer. Skip; the caller redraws.
+                return false;
+            }
         }
 
         unsafe {