Skip to content

Wayland: emit Occluded for the xdg-shell suspended state - #4709

Open
crmne wants to merge 1 commit into
rust-windowing:masterfrom
crmne:wayland-suspended-occluded-master
Open

crmne wants to merge 1 commit into
rust-windowing:masterfrom
crmne:wayland-suspended-occluded-master

Conversation

@crmne

@crmne crmne commented Sep 23, 2026 •

Copy link
Copy Markdown

On Wayland, a compositor stops sending frame callbacks to a window it has suspended (xdg-shell v6 suspended, sent for example when the window is on a hidden workspace or the outputs are off). A client that presents with vsync then blocks until the window is shown again, which freezes its event loop.

Winit used to report this state as WindowEvent::Occluded, but #3441 removed it: every configure also requested a redraw so the acknowledgement would be committed, and drawing on the configure that suspends the window blocked. This follows the design in #3442:

  • Occluded(true) and Occluded(false) are emitted when the suspended state is added or removed.
  • New WindowExtWayland::notify_presentation_paused(). An app calls it after it has stopped presenting on Occluded(true). Winit then commits the surface itself for the pending configure (sctk has already acked it) and for every later configure while the window stays suspended, instead of asking for a redraw. The configure that ends the suspension asks for a redraw as before, and the next Occluded(true) needs a new call.
  • Winit leaves the commit to the app if the integer buffer scale changed after the call, because the new scale would apply to the old buffer.
  • An app that never calls the method behaves as before: every configure, including the suspending one, asks for a redraw.
  • The Occluded docs now cover Wayland and this contract, including apps that keep presenting at a low rate while occluded (swap interval 0, no pre_present_notify).

Testing: a softbuffer probe hid its window three times per run, on headless Jay (with hidden windows kept in transactions, stock 50 ms timeout) and on Hyprland 0.56.2 (headless output):

Probe behaviour Jay timeouts Notes
Stops presenting, calls the method 0 commit right after the ack
Stops presenting, doesn't call it 1 per hide the stall the method removes
Keeps presenting on every redraw 0 same redraw/paint counts as stock
Calls it and presents every 400 ms 0

Hyprland showed the expected Occluded events and commits. Sway doesn't send suspended. Compositors older than xdg_wm_base v6 never send it either, so nothing changes there.

  • Tested on all platforms changed (Wayland: Jay and Hyprland, headless)
  • Added an entry to the changelog module if knowledge of this change could be valuable to users
  • Updated documentation to reflect any user-facing changes, including notes of platform-specific behavior
  • Created or updated an example program if it would help users understand this functionality

@kchibisov kchibisov left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you look at the reason why the original patch got reverted you'll see that your patch may not work on some compositors.

@crmne

crmne commented Sep 23, 2026

Copy link
Copy Markdown
Author

Thanks. I read #3441, #3442 and ff73119. If I understand the concern: the configure that adds suspended is only acknowledged by the first commit after the window is shown again. So a compositor that waits for that commit before it sends the configure removing suspended, or before it resumes frame callbacks, would never unsuspend the window. I only tested on Hyprland, which doesn't wait. Is that the failure you mean, and do you know which compositors behave that way?

If so, I can rework this along the lines you proposed in #3442: the application tells winit it has stopped presenting (for example, a window method called after handling Occluded(true)), and winit then acks and commits the pending configure on its behalf. Would that shape work for you?

@mahkoh

mahkoh commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

I only tested on Hyprland, which doesn't wait.

Hyprland doesn't implement transactional configure sequences. It cannot be used to test anything related to configure sequences since it doesn't operate under the same constraints as other compositors.

So a compositor that waits for that commit before it sends the configure removing suspended, or before it resumes frame callbacks, would never unsuspend the window.

Most compositors use a short timeout, say 200 milliseconds. So this would show up as a stutter instead of permanently blocking subsequent configuration sequences.

@crmne

crmne commented Sep 23, 2026

Copy link
Copy Markdown
Author

Thanks, both. I tested this on compositors with transactional configure sequences and on ones that send suspended, all headless:

  • Jay (master, run): sends suspended and uses transactions, but hidden windows are exempt from them. No configure or transaction timeouts with this patch.
  • sway 1.12 (run): uses transactions (200 ms) but never sends suspended, so the patch changes nothing there.
  • cosmic-comp (source): only sends suspended for inactive stack tabs, which its TilingBlocker doesn't wait on.
  • KWin, mutter, Hyprland (source): send suspended but don't wait on clients across windows.

To model the case you describe, I changed Jay to also wait on hidden windows (200 ms timeout). There the patch caused one timeout-long stall, about 200 ms, each time a window was hidden, and no permanent block. Stock winit avoids that stall only because the app keeps drawing once and then freezes on vsync for as long as the window is hidden.

A single empty commit from winit on the configure that leaves the window suspended removed every stall in that setup. sctk already acks each configure, so the commit is the only missing piece. A commit from winit is only safe while the app isn't presenting, though, so I'd follow the shape from #3442: a window method the app calls after handling Occluded(true) to say it has stopped presenting. Winit then commits for the pending and any later suspended configures, and asks for a redraw when the window is unsuspended, as now. I'll push that unless you'd prefer another shape.

A compositor sends no frame callbacks to a surface it has suspended, so a
client presenting with vsync blocks until the window is shown again. Winit
stopped reporting this state as `WindowEvent::Occluded` in rust-windowing#3441: every
configure also requested a redraw so the acknowledgement would be
committed, and drawing on the configure that suspends the window blocked.

Emit `Occluded` again when the `suspended` state is added or removed, and
add `WindowExtWayland::notify_presentation_paused`, as proposed in rust-windowing#3442.
An application calls it after it has stopped presenting on
`Occluded(true)`. Winit then commits the surface itself for the pending
configure (sctk has already acknowledged it) and for every later configure
while the window stays suspended, instead of asking for a redraw. The
configure that ends the suspension asks for a redraw as before, and the
next `Occluded(true)` needs a new call.

Winit leaves the commit to the application when the integer buffer scale
changed since the call, because the new scale would apply to the old
buffer, whose size need not be a multiple of it.

An application that never calls the method behaves as before: every
configure, including the suspending one, asks for a redraw.

Refs rust-windowing#3442
@crmne
crmne force-pushed the wayland-suspended-occluded-master branch from 9d2015f to a68ac90 Compare September 25, 2026 08:59
@crmne

crmne commented Sep 25, 2026

Copy link
Copy Markdown
Author

I pushed the shape from my last comment.

  • WindowExtWayland::notify_presentation_paused(): the app calls it once it has stopped presenting after Occluded(true). Winit then commits the surface right away (sctk has already acked the configure), and again after every later configure while the window stays suspended, without asking for a redraw. The configure that removes suspended emits Occluded(false) and asks for a redraw as before. The next suspension needs a new call.
  • An app that never calls it behaves as it does today: every configure, including the suspending one, asks for a redraw. The only difference it sees is the Occluded events.
  • If the integer buffer scale changed after the call, winit doesn't commit, since set_buffer_scale would then apply to the old buffer.
  • The app may keep presenting at a low rate while occluded, as long as the present can't block (swap interval 0 or mailbox) and it doesn't call pre_present_notify for those frames. The docs say so.

Testing used a small softbuffer probe that hides its window three times per run. On headless Jay, I kept hidden windows in transactions as before, with the stock 50 ms timeout:

  • Calls the method: no transaction or configure timeouts. The commit goes out right after the ack, in the same dispatch.
  • Stops presenting without calling it: one timeout per hide.
  • Keeps presenting on every redraw: no timeouts, and the same redraw and paint counts as stock v0.30.x.
  • Calls it and keeps presenting every 400 ms: no timeouts.

On Hyprland 0.56.2 (headless output, window moved to a special workspace and back), the events and commits were as expected. The 0.30 backport (#4710) gave the same results.

@mahkoh, a side note: Jay's headless backend never clears backend_idle, so every window there is suspended from the moment it maps. I patched that locally for these tests.

The name is open. I can also move it to the core Window trait if you'd rather have it there.

crmne added a commit to crmne/zapfast that referenced this pull request Sep 25, 2026
Hyprland stops frame callbacks to a window covered by a fullscreen or
maximized one without marking it suspended, so a vsync swap blocked the
event loop until the compositor called ZapFast unresponsive (#190).

Pin the shared forks: crmne/egui apps-0.36 presents without a blocking
swap on Wayland, paces frames by frame callbacks, and runs only the app
logic when a redraw is 250 ms overdue (emilk/egui#8631); crmne/winit
apps-0.30 reports suspended as Occluded and commits on the app's behalf
(rust-windowing/winit#4709). vsync.rs's compositor check goes: vsync is
requested everywhere and eframe handles Wayland.

Fixes #190
crmne added a commit to crmne/chatwithwork-local-agent that referenced this pull request Sep 25, 2026
Hyprland sends no frame callbacks to a window covered by a fullscreen or
maximized one, on a hidden workspace, or with the display off, so a
vsync swap blocked the event loop the tray shares and the compositor
called the app unresponsive.

Pin the forks ZapFast, Spotifast, RekordFlash and TonePush share:
crmne/egui apps-0.36 paces Wayland frames by frame callbacks
(emilk/egui#8631) and crmne/winit apps-0.30 reports suspended as
Occluded (rust-windowing/winit#4709). egui_kittest comes from the same
revision so the tests share one egui.
return false;
}

self.window.wl_surface().commit();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

winit should never commit on behalf of the user when there's a possibility of user rendering, which there's, so such approach doesn't work.

What works, perhaps, is once you get Suspended, do RequestRedraw and after that request submit Occluded, so the event is kind of delayed with the hope that user redrawn because they got asked to do so, so delivering Occluded would be once we know that user kind-of committed the occluded state.

crmne added a commit to crmne/chatwithwork-local-agent that referenced this pull request Sep 25, 2026
Hyprland sends no frame callbacks to a window covered by a fullscreen or
maximized one, on a hidden workspace, or with the display off, so a
vsync swap blocked the event loop the tray shares and the compositor
called the app unresponsive.

Pin the forks ZapFast, Spotifast, RekordFlash and TonePush share:
crmne/egui apps-0.36 paces Wayland frames by frame callbacks
(emilk/egui#8631) and crmne/winit apps-0.30 reports suspended as
Occluded (rust-windowing/winit#4709). egui_kittest comes from the same
revision so the tests share one egui.
crmne added a commit to crmne/chatwithwork-local-agent that referenced this pull request Sep 25, 2026
Hyprland sends no frame callbacks to a window covered by a fullscreen or
maximized one, on a hidden workspace, or with the display off, so a
vsync swap blocked the event loop the tray shares and the compositor
called the app unresponsive.

Pin the forks ZapFast, Spotifast, RekordFlash and TonePush share:
crmne/egui apps-0.36 paces Wayland frames by frame callbacks
(emilk/egui#8631) and crmne/winit apps-0.30 reports suspended as
Occluded (rust-windowing/winit#4709). egui_kittest comes from the same
revision so the tests share one egui.
@crmne

crmne commented Sep 26, 2026

Copy link
Copy Markdown
Author

Thanks, understood about not committing on the user's behalf. I tested the redraw-then-Occluded flow for the case I care about, the Hyprland "not responding" dialog, and neither design fixes it. With Mesa EGL and FIFO, an app that doesn't call pre_present_notify blocks in the in-flight present before winit even reads the suspending configure. An app that does call it never blocks, because winit holds RedrawRequested until the frame callback arrives. That hold also stops the post-configure redraw while the window is hidden, so with your flow those apps wouldn't get Occluded(true) until the window was shown again.

So the dialog is the app's job (pre_present_notify, or a non-FIFO present mode), not winit's. If Occluded for suspended is still useful to you, I can cut this down to emitting it immediately, with no commits from winit. Otherwise I'm happy to close it.

This branch has not been deployed

No deployments
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.

3 participants