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

commit5094b7084ec3862d215fafb40d012703c01bcd86
parent8c38e021b9
authorLucas Galante <[email protected]>
date2026-08-04 12:50
fix: off-viewport windows deadlocked in the FIFO present throttle

A surface the compositor never renders (off the camera viewport) gets no
frame callbacks. The frame-callback starvation fallback (cef624f) then
force-presented past the dead callback — but Mesa's FIFO present throttle
waits on the PREVIOUS present's frame event, so that forced present blocked
forever inside queue_present: event loop dead, process alive, window
unclosable (close events never processed), zombie windows accumulating.
The freeze reproduced in ~250ms — exactly one starvation interval — on any
off-viewport spawn whose app kept requesting redraws.

Two-part fix:
- Swapchains now prefer MAILBOX (universally available on Mesa Wayland):
  a present replaces the queued buffer instead of waiting on frame events,
  so presenting to an invisible surface can never block. The demand-driven
  loop's frame-callback gate still paces on-screen rendering.
- Under FIFO (drivers without MAILBOX) the starvation fallback stays
  closed — VkRenderer::forced_present_safe() gates it — accepting stale
  pixels over a driver-level deadlock.

Also adds CCE_PRESENT_DEBUG=1 acquire/present tracing, which is how the
blocking call was identified.

Verified live: off-viewport spawn with --select (the reliable repro) now
presents 5 frames and idles in epoll where it previously froze at the
second present; ccectl close-window works on the off-screen window; an
on-screen window renders current content immediately on reveal.

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

 src/backend/window_runner.rs | 14 +++++++++++-
 src/vk/renderer.rs           | 54 +++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 66 insertions(+), 2 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 3b3d563..4762d1e 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3355,7 +3355,7 @@ impl<A: Application> WindowHandler for EngineState<A> {
         self.first_configure_received = true;
         self.just_configured = true;
     }
-    
+
     fn request_close(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _window: &XdgWindow) {
         self.exit = true;
     }
@@ -4325,8 +4325,20 @@ pub fn run<A: Application>() {
         // with a perfectly live event loop (input processes, state changes,
         // nothing repaints). If a redraw has been waiting on a callback well
         // past any real vsync interval, stop waiting and draw.
+        //
+        // Gated on the renderer's present mode: forcing a present past a
+        // dead callback is only safe under MAILBOX (the present replaces the
+        // queued buffer). Under FIFO the driver's throttle waits on the
+        // previous present's frame event, so the forced present itself
+        // blocks forever inside the driver — the exact freeze this fallback
+        // exists to prevent. There the gate stays closed: pixels may stale
+        // until the next frame-done/configure, but the loop stays alive.
         if engine_state.redraw
             && engine_state.frame_callback_pending
+            && engine_state
+                .renderer
+                .as_ref()
+                .is_some_and(|r| r.forced_present_safe())
             && engine_state
                 .frame_callback_armed_at
                 .is_none_or(|t| t.elapsed().as_millis() > 250)
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 8bdfefd..55f9b48 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -231,6 +231,8 @@ pub struct VkRenderer {
     desired_extent: vk::Extent2D,
     corner_radius_px: f32,
     swapchain_dirty: bool,
+    present_mode: vk::PresentModeKHR,
+    present_debug_count: u64,
 
     // Declared last: everything above must be destroyed before the device/
     // instance the core tears down in its own Drop.
@@ -839,6 +841,8 @@ impl VkRenderer {
             desired_extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
             corner_radius_px,
             swapchain_dirty: false,
+            present_mode: vk::PresentModeKHR::FIFO,
+            present_debug_count: 0,
             core,
         };
         log::debug!("[timing] VkRenderer pipelines/stages: {:?}", t_rest.elapsed());
@@ -945,6 +949,25 @@ impl VkRenderer {
             .find(|&mode| caps.supported_composite_alpha.contains(mode))
             .unwrap_or(vk::CompositeAlphaFlagsKHR::OPAQUE);
 
+            // MAILBOX when the driver offers it (Mesa Wayland always does):
+            // FIFO's present throttle waits on the PREVIOUS present's frame
+            // callback, and a surface the compositor never renders (off the
+            // viewport) never gets one — the second-ever present then blocks
+            // forever inside queue_present with the whole event loop behind
+            // it. MAILBOX just replaces the queued buffer, so presenting to
+            // an invisible surface is always safe. The demand-driven loop's
+            // frame-callback gate keeps MAILBOX from free-running.
+            let modes = self
+                .core
+                .surface_loader
+                .get_physical_device_surface_present_modes(self.core.physical_device, self.surface)
+                .unwrap_or_default();
+            self.present_mode = if modes.contains(&vk::PresentModeKHR::MAILBOX) {
+                vk::PresentModeKHR::MAILBOX
+            } else {
+                vk::PresentModeKHR::FIFO
+            };
+
             let old_swapchain = self.swapchain;
             self.swapchain = self
                 .swapchain_loader
@@ -966,7 +989,7 @@ impl VkRenderer {
                         .image_sharing_mode(vk::SharingMode::EXCLUSIVE)
                         .pre_transform(caps.current_transform)
                         .composite_alpha(composite_alpha)
-                        .present_mode(vk::PresentModeKHR::FIFO)
+                        .present_mode(self.present_mode)
                         .clipped(true)
                         .old_swapchain(old_swapchain),
                     None,
@@ -1353,6 +1376,15 @@ impl VkRenderer {
         self.rt.as_ref().is_some_and(|rt| rt.accumulating())
     }
 
+    /// Whether presenting past an unacknowledged frame callback is safe.
+    /// True under MAILBOX (the present replaces the queued buffer). Under
+    /// FIFO the driver's present throttle waits on the previous present's
+    /// frame event, so a forced present to a surface the compositor isn't
+    /// rendering blocks forever — the caller must not force one.
+    pub fn forced_present_safe(&self) -> bool {
+        self.present_mode == vk::PresentModeKHR::MAILBOX
+    }
+
     /// The extent the next `draw_frame` will render at: the pending size when a
     /// swapchain rebuild is queued, otherwise the live one.
     pub fn pending_extent(&self) -> vk::Extent2D {
@@ -1443,6 +1475,9 @@ impl VkRenderer {
                 .wait_for_fences(&[in_flight], true, u64::MAX)
                 .expect("Fence wait failed");
 
+            if present_debug() {
+                eprintln!("[vk] frame {} acquire...", self.present_debug_count);
+            }
             let image_index = match self.swapchain_loader.acquire_next_image(
                 self.swapchain,
                 u64::MAX,
@@ -1911,6 +1946,9 @@ impl VkRenderer {
                 .wait_semaphores(&signal_semaphores)
                 .swapchains(&swapchains)
                 .image_indices(&image_indices);
+            if present_debug() {
+                eprintln!("[vk] frame {} present img {}...", self.present_debug_count, image_index);
+            }
             match self.swapchain_loader.queue_present(self.core.queue, &present) {
                 Ok(suboptimal) => {
                     if suboptimal {
@@ -1923,12 +1961,26 @@ impl VkRenderer {
                 Err(e) => log::error!("queue_present failed: {e:?}"),
             }
 
+            if present_debug() {
+                eprintln!("[vk] frame {} presented", self.present_debug_count);
+                self.present_debug_count += 1;
+            }
             self.frame_index = (self.frame_index + 1) % FRAMES_IN_FLIGHT;
         }
         true
     }
 }
 
+/// `CCE_PRESENT_DEBUG=1` traces every acquire/present to stderr — the
+/// diagnostic for present-pipeline stalls (a present that logs `acquire...`
+/// or `present img N...` with no matching completion line is blocked inside
+/// the driver; see the off-viewport freeze notes on the present-mode choice
+/// in `create_swapchain`).
+fn present_debug() -> bool {
+    static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
+}
+
 impl Drop for VkRenderer {
     fn drop(&mut self) {
         unsafe {