GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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 {