Skip to content

shell: drop the GdkSurface when a layer-shell window closes - #15

Merged
mirkobrombin merged 3 commits into
singularityos-lab:mainfrom
perlowja:fix/layer-surface-stale-buffer
Aug 13, 2026
Merged

shell: drop the GdkSurface when a layer-shell window closes#15
mirkobrombin merged 3 commits into
singularityos-lab:mainfrom
perlowja:fix/layer-surface-stale-buffer

Conversation

@perlowja

@perlowja perlowja commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Every layer-shell window in the shell can be killed by the compositor after a variable number of open/close cycles:

Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
wl_display error 2: zwlr_layer_surface_v1 has never been configured

Root cause

GTK keeps ONE wl_surface alive across hide/show. gtk4-layer-shell destroys the zwlr_layer_surface_v1 at unmap and creates a NEW one over that same surface on the next open. Comparing a working cycle with a crashing one in a single session:

working  (#77 -> #64)          crashing (#64 -> #82)
destroy()                      destroy()
attach(nil); commit()          attach(nil); commit()
<nothing>                      attach(wl_buffer#79); commit()   <-- stray
get_layer_surface(#64)         get_layer_surface(#82)
commit() -> configure OK       commit() -> ERROR 2

The only difference is a frame queued by the closing animation landing ~0.7 ms after the unmap, re-attaching a live buffer to the now-roleless surface. wl_surface state is persistent, so that buffer is still current when the next layer surface is created, and its first commit carries a buffer before any configure — which wlroots rejects. Whether that frame lands is a race on wl_buffer.release, which is why windows died after a variable number of opens.

The change

close_layer_window() in src/core/layer_window.vala hides the window and drops its GdkSurface, so the next open allocates a fresh wl_surface that cannot carry a stale buffer. This is the same sequence gtk4-layer-shell performs internally in gtk_layer_surface_remap().

All 24 close paths across six files call that one helper — the rationale lives once, next to the code that implements it, rather than being repeated at each site:

dock.vala                 7
sidebar.vala              5
app_menu.vala             4
workspace_overview.vala   4
hot_corner_manager.vala   2
overview.vala             2

The cast inside the helper is required: GtkWindow implements GtkNative, so a bare unrealize() binds to gtk_native_unrealize, an internal vfunc, not gtk_widget_unrealize. Verified by inspecting the C valac emits for each spelling.

Validation

Verified with a Wayland trace analyser that flags a layer surface being re-created over a wl_surface that still holds a buffer: 1 dangerous re-creation before the change, 0 across 36 layer surfaces after it. Confirmed by hand over ~30 open cycles and across a clean reboot, with zero shell restarts. CIX Sky1, labwc/wlroots, GTK4, GLES on Mali.

Every one of the 24 sites resolves to a Gtk.Window: two are in hot_corner_manager.vala, whose first class is HotCornerManager : Object, but both sit inside HotCornerHintWindow : Gtk.Window later in the file; overview.vala similarly contains a WorkspaceCard : Gtk.Box and no call site falls inside it.

Upstream

The underlying protocol error is reproducible without this shell — two layer windows cycling, with the unmap dispatched after a paint is queued, fails 5/5 within four layer surfaces on stock gtk4-layer-shell 1.3.0. Reproducer and analysis: wmww/gtk4-layer-shell#130. If that lands, this workaround becomes unnecessary.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4031172d53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/overview/overview.vala Outdated
// gtk4-layer-shell makes in gtk_layer_surface_remap().
// The cast is required: GtkWindow implements GtkNative, so a bare
// unrealize() binds to gtk_native_unrealize, not gtk_widget_unrealize.
((Gtk.Widget) this).unrealize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Unrealize the overview after gesture dismissal

When the launcher overview is dismissed through the four-finger gesture, both branches of end_gesture() call finish_gesture_hide(), which only invokes hide() and never drops the GdkSurface. Consequently this reset protects toggle-based dismissal but leaves the animated gesture path able to reuse the stale wl_surface and reproduce the same compositor protocol error; put the reset in the shared hide cleanup or add it to finish_gesture_hide().

Useful? React with 👍 / 👎.

Comment thread src/components/dock/dock.vala Outdated
// Drop the GdkSurface so the next open gets a fresh wl_surface.
// Cast required: GtkWindow implements GtkNative, so a bare
// unrealize() binds to gtk_native_unrealize, not the widget one.
((Gtk.Widget) this).unrealize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reset the surface on production dock remaps

The fresh-surface behavior is added to the debug remap action, but the production autohide reveal still performs the same hide()/present() remap in animate_dock(false) at lines 1043-1044 without unrealizing; the overview-only close at line 1231 is similarly unprotected. These frequently used paths therefore continue recreating a layer surface over the existing GdkSurface and can still trigger the protocol failure this commit is intended to prevent.

Useful? React with 👍 / 👎.

@perlowja
perlowja force-pushed the fix/layer-surface-stale-buffer branch from 4031172 to 57554ab Compare August 10, 2026 21:28
@perlowja

Copy link
Copy Markdown
Contributor Author

Both findings were correct. Verified each against the source before acting, and the gap turned out to be wider than reported — pushed in 57554ab.

Gesture dismissal (overview.vala). Confirmed. finish_gesture_hide() at line 435 calls a bare hide() with no unrealize, reached from end_gesture() at 508 and 524. overview.vala has two bare hide(); and only the animation-end one was patched, because overview.vala was not in the file list of the generic pass that handled the other components. Both are now covered.

Dock remap paths (dock.vala). Confirmed as to substance. Line 1039 is ((Gtk.Widget) this).hide(); followed by present() — a full remap on the production autohide reveal, exactly the hazard this change exists for. One detail: the second location cited, line 1231, is update_clock(); the unprotected sites are 244, 260, 1039, 1143 and 1210.

Root cause of both. The patch generator matched only a bare hide(); and missed the ((Gtk.Widget) this).hide(); spelling — which is the same cast this patch itself emits for unrealize(), so the blind spot was self-inflicted. Full audit of the six layer-shell files: 24 close paths, of which 18 were protected and 6 were not. Fixed by matching both spellings and adding overview.vala to the pass:

file close paths
dock.vala 7
sidebar.vala 5
app_menu.vala 4
workspace_overview.vala 4
hot_corner_manager.vala 2
overview.vala 2

Neither path was caught by testing because autohide and the four-finger gesture were never exercised in the reproduction. Review caught what testing did not.

Worth noting for the reviewer's benefit: this has now missed close paths twice, which is an argument for the library-side fix proposed in wmww/gtk4-layer-shell#130 — clearing the stale buffer inside gtk4-layer-shell covers every close path in every client without each call site having to remember. This PR remains the client-side fix for shells running against the library as it currently ships.

@perlowja
perlowja force-pushed the fix/layer-surface-stale-buffer branch from 57554ab to 458d4d5 Compare August 10, 2026 21:41
@perlowja

Copy link
Copy Markdown
Contributor Author

Pushed 458d4d5. Both P1 findings are addressed, and I stripped a downstream comment prefix that had no business in this tree.

Gesture dismissalfinish_gesture_hide() now drops the surface:

private void finish_gesture_hide() {
    hide();
    // Drop the GdkSurface so the next open gets a fresh wl_surface.
    ((Gtk.Widget) this).unrealize();
    PreviewCache.get_default().clear();

Dock remaps — all five ((Gtk.Widget) this).hide(); sites (244, 264, 1051, 1159, 1234) are covered, including the autohide reveal that follows it with present().

Totals across the six layer-shell files: 24 close paths, 24 unrealize calls. Was 18.

Since this has now missed close paths once and been corrected, it is worth saying what the underlying fix is: I have proposed a library-side change at wmww/gtk4-layer-shell#130 that clears the stale buffer before get_layer_surface, which covers every close path in every client without each call site having to remember. That is verified on metal — the same shell binary without any of the changes in this PR crashes immediately against the stock library and survives against the patched one.

If that lands, most of this PR becomes redundant. It is still worth having for shells running against the library as it currently ships, and I am happy to reshape it into a shared close helper (as suggested above) if you would rather have one call site than 24 — say the word and I will respin.

@perlowja

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 458d4d5af2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/sidebar/sidebar.vala Outdated
// Drop the GdkSurface so the next open gets a fresh wl_surface.
// Cast required: GtkWindow implements GtkNative, so a bare
// unrealize() binds to gtk_native_unrealize, not the widget one.
((Gtk.Widget) this).unrealize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cover the external sidebar close path

When bar-layout edit mode is enabled while the sidebar is open, sync_bar_layout_edit_mode() still calls sidebar?.hide() directly at src/core/main.vala:761-764. Because the new unrealize calls cover only Sidebar's internal close sites, this path retains the old GdkSurface; reopening the same sidebar through a panel or dock toggle calls present() and can reproduce the stale wl_surface protocol failure this change is intended to prevent. Route this close through a sidebar method that performs both operations.

Useful? React with 👍 / 👎.

Every layer-shell window in the shell can be killed by the compositor after a
variable number of open/close cycles:

    Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
    wl_display error 2: zwlr_layer_surface_v1 has never been configured

Root cause, from WAYLAND_DEBUG=1 traces. GTK keeps ONE wl_surface alive across
hide/show. gtk4-layer-shell destroys the zwlr_layer_surface_v1 at unmap and
creates a NEW one over that same wl_surface on the next open. Comparing a
working cycle with a crashing one in a single session:

    working  (#77 -> #64)          crashing (#64 -> #82)
    destroy()                      destroy()
    attach(nil); commit()          attach(nil); commit()
    <nothing>                      attach(wl_buffer#79); commit()   <-- stray
    get_layer_surface(#64)         get_layer_surface(#82)
    commit() -> configure OK       commit() -> ERROR 2

The only difference is a frame queued by the closing animation that lands
~0.7ms AFTER the unmap and re-attaches a live buffer to the now-roleless
surface. wl_surface state is persistent, so that buffer is still current when
the next layer surface is created; its first commit therefore carries a buffer
before any configure, which wlroots rejects. Whether that frame lands is a race
on wl_buffer.release, which is why the shell died after a VARIABLE number of
opens rather than deterministically.

unrealize() after hide() drops the GdkSurface, so the next open allocates a
brand-new wl_surface that cannot carry a stale buffer. This is the same call
gtk4-layer-shell itself makes in gtk_layer_surface_remap(), which does
gtk_widget_unrealize() then gtk_widget_map() -- a supported, exercised path.
unrealize() on a never-realized widget is a documented no-op, so the
constructor-time hide() in workspace_overview is harmless.

The cast is required: GtkWindow implements GtkNative, so a bare unrealize()
binds to gtk_native_unrealize, an internal vfunc, not gtk_widget_unrealize.
Verified by inspecting the C that valac emits for each spelling.

This is not launcher-specific. An audit found 19 windows calling
init_for_window(), of which six hide and then re-show -- each one a distinct
instance of the same fault. Applied to all 24 close paths in those six files:

    dock.vala                 7
    sidebar.vala              5
    app_menu.vala             4
    workspace_overview.vala   4
    hot_corner_manager.vala   2
    overview.vala             2

Note that a close path is spelled either `hide();` or
`((Gtk.Widget) this).hide();`, and both need the same treatment -- dock.vala
uses the cast form for five of its seven, including the production autohide
reveal, which does that then present(): a full remap.

Verified on CIX Sky1 (Radxa Orion O6N, labwc/wlroots, GTK4, GLES on libmali)
with a Wayland trace analyzer that flags a layer surface being re-created over
a wl_surface that still holds a buffer: 1 dangerous re-creation before the
change, 0 across 36 layer surfaces after it. Confirmed by hand over ~30 open
cycles and across a clean reboot, with zero shell restarts.

Upstream gtk4-layer-shell has no fix. Issue #94 is this exact bug (same error,
same "random after N opens", reproduces only on the gl/vulkan renderers) and
was closed because Hyprland changed; PR #119 reordered teardown and was
rejected as a smithay bug. A library-side fix is proposed separately in
wmww/gtk4-layer-shell#130, which would cover every close path in every client
without each one having to remember; this change is the client-side fix for
shells running against the library as it stands.

A shared close helper would be cleaner than 24 call sites; happy to respin that
way if preferred.
@perlowja
perlowja force-pushed the fix/layer-surface-stale-buffer branch from 458d4d5 to f453641 Compare August 10, 2026 21:53
@perlowja

Copy link
Copy Markdown
Contributor Author

I re-checked both P1s from the latest review against the commit it was submitted on (458d4d5) and against the current head (f453641). Both were already fixed at that point — the review re-anchored the two original findings rather than re-reading the files. Quoting the current head so it's checkable:

1. "Unrealize the overview after gesture dismissal"finish_gesture_hide() already drops the surface. src/components/overview/overview.vala:451-456:

private void finish_gesture_hide() {
    hide();
    // Drop the GdkSurface so the next open gets a fresh wl_surface.
    // Cast required: GtkWindow implements GtkNative, so a bare
    // unrealize() binds to gtk_native_unrealize, not the widget one.
    ((Gtk.Widget) this).unrealize();

Both branches of end_gesture() route through it (overview.vala:528 and overview.vala:544), so the four-finger dismissal is covered.

2. "Reset the surface on production dock remaps" — the animate_dock() reveal remap and the overview-only close both unrealize. src/components/dock/dock.vala:1051-1056:

((Gtk.Widget) this).hide();
// Drop the GdkSurface so the next open gets a fresh wl_surface.
// Cast required: GtkWindow implements GtkNative, so a bare
// unrealize() binds to gtk_native_unrealize, not the widget one.
((Gtk.Widget) this).unrealize();
present();

and src/components/dock/dock.vala:1234-1239:

((Gtk.Widget) this).hide();
// Drop the GdkSurface so the next open gets a fresh wl_surface.
// Cast required: GtkWindow implements GtkNative, so a bare
// unrealize() binds to gtk_native_unrealize, not the widget one.
((Gtk.Widget) this).unrealize();
present();

dock.vala now has 7 unrealize() calls for its 7 hide/present remap paths — lines 248, 268, 942, 1055, 1163, 1184, 1238.

For completeness on the third finding, which was real: the external sidebar?.hide() in sync_bar_layout_edit_mode() is fixed at src/core/main.vala:764-771, and I grepped every qualified .hide() in the tree — that was the only external close on a layer-shell window.

@mirkobrombin

mirkobrombin commented Aug 12, 2026

Copy link
Copy Markdown
Member

The bug is valid, but I do not want 24 manual unrealize calls. Please put this behind one shared close helper and trim the repeated comments, then I can merge it <3

Replaces 24 open-coded hide()+unrealize() pairs, and the explanatory comment
repeated at each of them, with a single close_layer_window() in
src/core/layer_window.vala. The rationale now lives once, next to the code that
implements it.

No behaviour change: the helper performs the same hide() then unrealize() in the
same order, including the cast that makes unrealize() bind to
gtk_widget_unrealize rather than GtkNative's internal vfunc.

Verified every one of the 24 sites resolves to a Gtk.Window: two of them are in
hot_corner_manager.vala, whose first class is HotCornerManager : Object, but
both sit inside HotCornerHintWindow : Gtk.Window further down the file.
overview.vala likewise contains a WorkspaceCard : Gtk.Box, and no call site
falls inside it.

Net -132/+25 lines across six files.
@perlowja

Copy link
Copy Markdown
Contributor Author

Done in 4730fea — one close_layer_window() in src/core/layer_window.vala, and the 24 open-coded pairs plus their repeated comments are gone. The rationale now lives once, next to the code that implements it. Net −132/+25 across six files.

No behaviour change: same hide() then unrealize(), same order, same cast (a bare unrealize() binds to GtkNative's internal vfunc rather than gtk_widget_unrealize).

Validation: confirmed all 24 sites resolve to a Gtk.Window. Two are in hot_corner_manager.vala, whose first class is HotCornerManager : Object — but both sit inside HotCornerHintWindow : Gtk.Window further down. overview.vala similarly contains a WorkspaceCard : Gtk.Box, and no call site falls inside it.

Separately: I reproduced the underlying protocol error standalone, no shell involved — it needs two or more layer windows cycling plus the unmap dispatched after a paint is queued, and it fails 5/5 within four layer surfaces on stock gtk4-layer-shell 1.3.0. Details and reproducer are in wmww/gtk4-layer-shell#130. If that lands, this workaround becomes unnecessary.

@perlowja
perlowja force-pushed the fix/layer-surface-stale-buffer branch from f011b70 to 4730fea Compare August 13, 2026 02:44
@mirkobrombin

Copy link
Copy Markdown
Member

The shared helper is good now. One issue remains before merge: the dock frame-clock pulse must end the same frame clock it started. After hide/unrealize/present, the timeout currently fetches the new clock and can end it twice during a quick pointer leave/re-enter. Please retain the original frame clock in the timeout. Also keep #18 stacked until this lands.

Closing a layer window now drops the GdkSurface, so the next present
allocates a NEW GdkFrameClock. The 350ms timeout re-fetched the clock via
get_frame_clock(), which after a hide/unrealize/present cycle is not the
clock begin_updating() was called on: the original is left permanently
updating, and a quick pointer leave/re-enter can end the new one twice.

Capture the clock at begin_updating() time and end that one.

The identical re-fetch exists in panel.vala, but the panel never hides or
unrealizes and does not go through close_layer_window(), so its frame clock
does not change underneath the timeout. Left alone deliberately - it is not
reachable from this change.
@perlowja

Copy link
Copy Markdown
Contributor Author

Done in ee9ec8b. pulse_frame_clock() now captures the clock at begin_updating() and the timeout ends that one, instead of re-fetching:

var fc = get_frame_clock();
if (fc == null) return;
fc.begin_updating();
GLib.Timeout.add(350, () => {
    fc.end_updating();
    return GLib.Source.REMOVE;
});

You were right about the mechanism — since this PR drops the GdkSurface on close, the next present allocates a new frame clock, so the old one was left permanently updating while a quick leave/re-enter could end the new one twice.

The identical re-fetch exists in panel.vala:532, but I left it alone: the panel never hides or unrealizes and doesn't go through close_layer_window(), so its frame clock doesn't change underneath the timeout. It isn't reachable from this change, and it seemed better not to widen the diff.

Pushed as a plain commit rather than a rebase — the branch still merges cleanly onto current main, so #18 stays stacked as you asked.

@mirkobrombin
mirkobrombin merged commit 419dfc8 into singularityos-lab:main Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants