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

commit8c38e021b9fab5ea820970d2709ccea6b4326ce0
parent9cfadad3ef
authorLucas Galante <[email protected]>
date2026-08-04 10:34
fix: dropped resize request wedged the pending extent, freezing all presents

VkRenderer::resize only recorded the request when it differed from the LIVE
extent. With a rebuild already queued (swapchain_dirty), a request returning
to the live size was silently dropped: desired_extent stayed wedged at the
intermediate size, the render loop's pending_extent gate never matched again,
and every subsequent frame skipped its present — silently, at loop cadence,
with the process otherwise healthy.

Seen in the wild as the status-bar clock frozen after suspend/resume: the
scale bounce (2→1→2 before any draw) resized the clock segment 305→297→305
logical; the return to 305 was the dropped request. The clock rebuilt its
layout every minute for 45 minutes while the compositor never received
another buffer.

resize() now records desired_extent unconditionally, so a queued rebuild is
always retargeted to the latest request. Also add a rate-limited warn when
the extent gate skips ~5s of consecutive renders — this wedge had zero log
footprint, and any future variant should at least say so.

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

 src/backend/window_runner.rs | 16 ++++++++++++++++
 src/vk/renderer.rs           |  9 ++++++++-
 2 files changed, 24 insertions(+), 1 deletion(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 008ac0b..3b3d563 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2738,6 +2738,10 @@ pub struct EngineState<A: Application> {
     /// When the pending frame callback was armed — the starvation fallback's
     /// clock (see the render gate in `run`).
     pub frame_callback_armed_at: Option<std::time::Instant>,
+    /// Consecutive renders skipped by the extent gate (pending swapchain size
+    /// != the size the current logical size and scale call for). Normally 0 or
+    /// 1; a persistent count means no frame is presenting and deserves a warn.
+    pub extent_gate_skips: u32,
     pub first_configure_received: bool,
     pub ctrl_pressed: bool,
     pub shift_pressed: bool,
@@ -3130,9 +3134,20 @@ impl<A: Application> EngineState<A> {
             let e = renderer.pending_extent();
             if e.width != epw || e.height != eph {
                 renderer.resize(epw, eph);
+                self.extent_gate_skips += 1;
+                // ~5s of continuous skipping at the 16ms loop cadence: nothing
+                // is presenting and nothing else will say so — this is the
+                // only witness to a wedged pending extent.
+                if self.extent_gate_skips % 300 == 0 {
+                    log::warn!(
+                        "[window_runner] extent gate: pending {}x{} != expected {}x{} for {} consecutive renders; no frame is presenting",
+                        e.width, e.height, epw, eph, self.extent_gate_skips,
+                    );
+                }
                 self.redraw = true;
                 return;
             }
+            self.extent_gate_skips = 0;
             if s != self.committed_buffer_scale {
                 surface.set_buffer_scale(s);
                 self.committed_buffer_scale = s;
@@ -4097,6 +4112,7 @@ pub fn run<A: Application>() {
         redraw: false,
         frame_callback_pending: false,
         frame_callback_armed_at: None,
+        extent_gate_skips: 0,
         first_configure_received: false,
         ctrl_pressed: false,
         shift_pressed: false,
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index edcd8a0..8bdfefd 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1363,8 +1363,15 @@ impl VkRenderer {
     /// lazily on the next `draw_frame`.
     pub fn resize(&mut self, width: u32, height: u32) {
         let extent = vk::Extent2D { width: width.max(1), height: height.max(1) };
+        // Record the request unconditionally, not just when it differs from the
+        // live extent: with a rebuild already queued (`swapchain_dirty`), a
+        // request that returns to the live size must overwrite the queued one.
+        // Otherwise `desired_extent` stays wedged at the intermediate size, the
+        // caller's `pending_extent` gate never matches, and no frame presents
+        // again — a resume scale bounce (2→1→2 before any draw) froze the
+        // status-bar clock exactly this way.
+        self.desired_extent = extent;
         if extent.width != self.extent.width || extent.height != self.extent.height {
-            self.desired_extent = extent;
             self.swapchain_dirty = true;
         }
     }