Skip to content

Wayland: bound the frame-callback wait before delivering RedrawRequested - #4675

Closed
zh4ngx wants to merge 3 commits into
rust-windowing:masterfrom
zh4ngx:wayland-frame-callback-stall
Closed

Wayland: bound the frame-callback wait before delivering RedrawRequested#4675
zh4ngx wants to merge 3 commits into
rust-windowing:masterfrom
zh4ngx:wayland-frame-callback-stall

Conversation

@zh4ngx

@zh4ngx zh4ngx commented Aug 30, 2026

Copy link
Copy Markdown

Fixes #4668 (the Wayland zero-timeout spin / wedged event loop).

Root cause

The reporter's fingerprint — epoll_pwait with a zero timeout, paired 1:1 with
read(eventfd) = -1 EAGAIN, one core burned while no frames are dispatched —
reproduces deterministically on GNOME Wayland with winit 0.30.13 + eframe, and
instrumentation shows where the zero timeout comes from:

  1. eframe (like other redraw-scheduling apps) sets ControlFlow::Poll together
    with Window::request_redraw() when a repaint becomes due, and only re-arms
    its repaint schedule once RedrawRequested is delivered
    (eframe/src/native/run.rs, check_redraw_requests).
  2. The Wayland backend suppresses RedrawRequested while a frame callback is in
    flight (if window.frame_callback_state() == FrameCallbackState::Requested { return None; } in the redraw dispatch).
  3. Compositors legitimately delay wl_surface.frame callbacks — GNOME/mutter,
    for one, throttles them for occluded windows, and the reporter traced
    mutter withholding them for 10–300 s at a time. While the callback is
    pending, every iteration of the loop computes Some(Duration::ZERO) from
    ControlFlow::Poll and spins: ~34k epoll_pwait(timeout=0)/s under strace
    on my box, each iteration also paying timerfd_settime + two epoll_ctl

The same gate exists on master (winit-wayland/src/event_loop/mod.rs), so this
is not specific to the 0.30 line.

The fix

Two small parts in the Wayland backend:

  1. Bound the wait. WindowState records when the current frame callback
    was requested; once it has been in flight for FRAME_CALLBACK_STALL_TIMEOUT
    (250 ms) the pending RedrawRequested is delivered anyway instead of being
    suppressed forever. Presenting without a frame callback is protocol-legal;
    frame callbacks are advisory pacing, and a compositor that withholds them
    must not be able to wedge the client.
  2. Don't busy-poll while gated. In poll_events_with_timeout, when the
    computed timeout is zero only because of ControlFlow::Poll and every
    pending redraw is gated behind a non-stalled frame callback, wait for the
    remaining stall interval instead. Nothing except the compositor (or the
    stall deadline) can unblock that redraw, so a zero timeout cannot make
    progress; user events and socket traffic still wake the loop immediately
    through their registered sources. Plain ControlFlow::Poll with no gated
    redraw keeps its existing behavior.

The gate-folding semantics — all pending redraws gated → wait for the nearest
stall deadline; any un-gated pending redraw → deliver now — are covered by unit
tests (fold_frame_callback_gate in winit-wayland/src/state.rs).

Verification

Measured on NixOS, GNOME Wayland, kernel 7.2.0, with a 60-line eframe 0.35
(wgpu/Vulkan) app that calls ctx.request_repaint_after(100ms); CPU is
/proc/<pid>/stat utime+stime of the main thread, sampled over 5–15 s windows.
The same change backported to winit 0.30.13 (branch
wayland-frame-callback-stall-v0.30)
was used for the runs, since eframe pins winit 0.30:

configuration idle CPU (one core) app behavior
Wayland, stock winit 0.30.13 75–100% repaints ~10/s while callbacks flow; wedged (logic passes frozen) when they don't
X11 (XWayland), stock 0.5% healthy
Wayland, this fix 0.2% repaints at the 250 ms stall cadence when callbacks are withheld entirely; never wedges
X11 (XWayland), this fix 0.0% healthy

Also verified on a real application, not just the probe — a large
eframe 0.35 + wgpu 29 (Vulkan) terminal app where we originally hit this
(the main-thread 93–95% idle case), same debug build and measurement
method, patch applied via a path override onto its pinned winit 0.30.13:

configuration idle CPU (one core)
Wayland, stock winit 0.30.13 99.9%
Wayland, this fix 0.1–0.2%
X11 (XWayland), this fix 0.5% (unchanged within noise)

Multi-window probe (raw winit, ControlFlow::Poll)

A second probe with no eframe exercises the multi-window case under
ControlFlow::Poll — the exact configuration of #4668 — with two windows:
one whose frame callbacks never arrive (headless weston, a surface that
never commits a buffer: a stand-in for an occluded window whose callbacks
the compositor is withholding) and which continuously requests redraws, and
one on-demand window that never arms callbacks and asks for a redraw every
100 ms.
15 s measurement windows, main-thread utime+stime:

winit 0.30.13 main-thread CPU callback-starved window on-demand window
stock 99.9% wedged — one redraw ever 10 redraws/s, ~0 ms request→delivery
this fix 0.0% repaints at the 250 ms stall cadence, never wedges ~0 ms request→delivery whenever asked

Stock shows both reported variants of #4668 simultaneously in one process:
the zero-timeout spin burning a core and the starved window never getting
RedrawRequested ("no frames"). With the fix, an un-gated pending redraw is
never delayed behind a gated sibling — request_redraw() pings the loop's
awakener and the redraw is dispatched in the next pass (measured ~0 ms; the
all-gated→wait / any-un-gated→zero distinction in frame_callback_gate()
keeps that immediate even when another window is waiting out its stall
interval).

Behavioral note: while every pending redraw is gated and the compositor is
silent, a Poll loop now sleeps to the stall boundary (≤250 ms) instead of
spinning. Any Wayland event — input, configure, a frame callback for a
visible window — still wakes it immediately. Apps that need precise timers
should keep using WaitUntil/timer sources (as eframe does once re-armed);
a Poll loop whose only activity is a fully callback-starved window trades
wakeup granularity for an idle core.

Real-compositor rerun (GNOME 50.4 / mutter Wayland, live session)

The multi-window probe was re-run against a real logged-in GNOME Wayland
session (same probe source, only the winit path override differs; mutter
withholds frame callbacks for the never-committed surface exactly as the
headless-weston stand-in predicts — gated window stays gated for real):

winit 0.30.13, ControlFlow::Poll main-thread CPU (15 s) callback-starved window on-demand window
stock 99.9% wedged — one redraw ever 10 redraws/s, ~0 ms
this fix 0.1% 4/s at the 250 ms stall cadence, never wedges ~0 ms

Same numbers as the weston table above, now on mutter itself. The large
eframe/wgpu app from #4668 was also re-measured on this session with the
revised gate: idle Wayland 0.2% main thread (stock in the calm case —
visible window, callbacks flowing, no pending redraw — is also ~0.2%,
consistent with the mechanism: the spin needs a redraw gated behind a
withheld callback, which is what occlusion/minimization produces).

Resize and activation paths on real mutter with the fix: the initial
compositor configure (Resized 1600x1200) and a client
request_inner_size() round-trip (returns Some(new_size), applied
immediately) both behave identically to stock, and the example app's
xdg_activation flow — request token → ActivationTokenDone delivered →
create window with token — completes through the event loop.

Earlier single-window run under headless weston

The gate refinement (wait only while every pending redraw is gated; an
ungated pending redraw keeps its immediate dispatch) was additionally
exercised under headless weston: 0.4% idle with the app's logic/UI pass
counters advancing at the compositor's repaint clock — calm and healthy
rather than spinning or wedged.

strace -f -c over 12 s, stock vs fixed (Wayland):

stock:  404849 epoll_pwait, 404849 timerfd_settime, 809713 epoll_ctl,
        406810 read (404864 EAGAIN)          ← the #4668 signature
fixed:    325 epoll_pwait, ~2k syscalls total

Still not verified: interactive keyboard input and drag/maximize resize on
a focused window with the patch. Keyboard injection into the session works
(uinput), but no programmatic way to focus the probe window could deliver
focus on this mutter — self-issued xdg_activation tokens complete but
don't steal focus, and Shell Introspect/Screenshot are portal-locked — so
the injected keys go to whatever the human user has focused. The dispatch
machinery those events ride (wayland socket dispatch, configure handling,
frame callbacks) is exercised by the measurements above; a human-driven
check on a focused window is the remaining step. Happy to run more
scenarios on request.

A 250 ms stall timeout means a fully callback-starved window repaints at ~4 fps
instead of never; normal visible windows (callbacks at vsync) are unaffected
because the wait ends when the callback arrives.

The same change backported onto the v0.30.x line is on
wayland-frame-callback-stall-v0.30
(based on tag v0.30.13; that's the build the runtime numbers above were
measured with, since eframe and most of today's apps still pin 0.30). Happy to
open a backport PR against v0.30.x if wanted.

Compositors may delay wl_surface.frame callbacks for occluded windows for
seconds or indefinitely (GNOME/mutter does this). While a callback is in
flight, the Wayland backend suppresses RedrawRequested, so under
ControlFlow::Poll the event loop degenerates into a zero-timeout epoll spin
(rust-windowing#4668: one core burned, epoll_pwait(timeout=0) and read(eventfd)=EAGAIN at
a 1:1 ratio), and apps that only re-arm their repaint schedule from the
redraw handler wedge entirely.

Track when the current frame callback was requested, then:

1. deliver the pending RedrawRequested once the callback has been in
   flight for FRAME_CALLBACK_STALL_TIMEOUT (250ms), and
2. while a redraw is gated behind a non-stalled callback, wait for the
   compositor instead of honoring a zero timeout: nothing but the
   compositor or the stall deadline can unblock that redraw.

Verified on GNOME Wayland with an eframe 0.35 app against the same change
backported to winit 0.30.13: stock winit pins the main thread at 75-100%
with the reported syscall signature (~34k epoll_pwait(timeout=0) per second
under strace, each paired with a read(eventfd)=EAGAIN); with the change,
idle CPU is 0.2% of a core, the app keeps repainting at the stall cadence
when callbacks are withheld entirely, and the X11 backend is unaffected
(0.0%).
@zh4ngx
zh4ngx force-pushed the wayland-frame-callback-stall branch from b3235a6 to e0b23b3 Compare August 30, 2026 12:47
@zh4ngx

zh4ngx commented Aug 30, 2026

Copy link
Copy Markdown
Author

Additional evidence for the multi-window case, since "what if one window's redraw is gated and another's isn't?" is the obvious review question.

Setup: raw-winit two-window probe (no eframe), ControlFlow::Poll — the configuration of #4668 — under headless weston. Window A continuously requests redraws and arms frame callbacks that never arrive (buffer-less surface = stand-in for an occluded window whose callbacks the compositor withholds). Window B never arms callbacks and requests a redraw every 100 ms (the on-demand/worker-poll pattern). Main-thread CPU via /proc/<pid>/task/<tid>/stat, 15 s windows; delivery latency measured inside the app from request_redraw() to RedrawRequested:

winit 0.30.13 main CPU window A (starved) window B (on-demand)
stock 99.9% wedged — one redraw, ever 10/s, ~0 ms latency
this PR 0.0% 250 ms stall cadence, never wedges ~0 ms latency whenever asked

Takeaways:

  • Stock exhibits both reported variants of Wayland: event loop spins at 100% CPU polling with a zero timeout; no frames, no input #4668 in one process: the zero-timeout spin burning a core, and the starved window never receiving RedrawRequested ("no frames") — while its sibling is served fine.
  • With the fix, an un-gated pending redraw is not delayed behind a gated sibling: request_redraw() pings the loop awakener and the redraw is dispatched in the next pass (~0 ms measured, with the other window waiting out its stall interval the whole time). This is also why frame_callback_gate() returns "no wait" when any pending redraw is un-gated rather than the min over gated windows.

One behavioral note, measured and disclosed: while every pending redraw is gated and the compositor is completely silent, a Poll loop now sleeps to the stall boundary (≤250 ms) instead of spinning. Any Wayland event (input, configure, a callback for a visible window) still wakes it immediately. So a Poll-forever app whose only pending work is a fully starved window trades wakeup granularity for an idle core; apps needing precise timing should use WaitUntil/timer sources, as eframe does once re-armed.

zh4ngx added 2 commits August 30, 2026 06:52
Extract the per-window gate folding from WinitState::frame_callback_gate
into fold_frame_callback_gate and cover its semantics with unit tests:
all gated redraws wait for the nearest stall deadline, any un-gated
redraw opens the gate (deliver now), and no pending redraws means no
gate. The behavior of frame_callback_gate is unchanged.
@kchibisov

Copy link
Copy Markdown
Member

don't want to understand what you AI wrote given that makes no sense to me. I don't really care what you use if you understand what you're doing.

@kchibisov kchibisov closed this Aug 31, 2026
@zh4ngx

zh4ngx commented Aug 31, 2026

Copy link
Copy Markdown
Author

Sorry, agents gone haywire :D

@zh4ngx
zh4ngx deleted the wayland-frame-callback-stall branch August 31, 2026 05:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Wayland: event loop spins at 100% CPU polling with a zero timeout; no frames, no input

2 participants