GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: frame-callback starvation froze apps that went off-screen
The demand-driven loop renders only when no frame callback is pending,
and render() arms a new one every frame — but the compositor only sends
frame-done for surfaces it actually renders, so a callback armed while
the window sat outside the viewport never fires. Result: a permanently
frozen app with a live event loop (input processes, state changes,
nothing repaints — reproduced deterministically by panning the window
off-screen, dirtying it, and panning back). A redraw waiting on a
callback for >250ms now stops waiting and draws.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/backend/window_runner.rs | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index e89c018..031c81f 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2688,6 +2688,9 @@ pub struct EngineState<A: Application> {
pub exit: bool,
pub redraw: bool,
pub frame_callback_pending: bool,
+ /// 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>,
pub first_configure_received: bool,
pub ctrl_pressed: bool,
pub shift_pressed: bool,
@@ -3092,6 +3095,7 @@ impl<A: Application> EngineState<A> {
if let Some(ref surface) = self.surface {
let _callback = surface.frame(&self.qh, ());
self.frame_callback_pending = true;
+ self.frame_callback_armed_at = Some(std::time::Instant::now());
}
// Direct renderer staging (3D scenes, RT panes, app-shaped text).
@@ -4045,6 +4049,7 @@ pub fn run<A: Application>() {
exit: false,
redraw: false,
frame_callback_pending: false,
+ frame_callback_armed_at: None,
first_configure_received: false,
ctrl_pressed: false,
shift_pressed: false,
@@ -4250,6 +4255,22 @@ pub fn run<A: Application>() {
last_title = current_title;
}
+ // Frame-callback starvation fallback: the compositor only sends
+ // frame-done for surfaces it actually renders, so a callback armed
+ // while the window sat off-viewport (or the scene went static) may
+ // never fire — and the vsync gate below then freezes the app forever
+ // 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.
+ if engine_state.redraw
+ && engine_state.frame_callback_pending
+ && engine_state
+ .frame_callback_armed_at
+ .is_none_or(|t| t.elapsed().as_millis() > 250)
+ {
+ engine_state.frame_callback_pending = false;
+ }
+
if engine_state.redraw && !engine_state.frame_callback_pending {
engine_state.redraw = false;
if engine_state.first_configure_received {