From f45364123457543c9a6c571c2232a25552500d79 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 10 Aug 2026 17:28:05 -0400 Subject: [PATCH 1/3] shell: drop the GdkSurface when a layer-shell window closes 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() 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. --- src/components/dock/dock.vala | 28 +++++++++++++++++++ src/components/overview/app_menu.vala | 16 +++++++++++ src/components/overview/overview.vala | 20 +++++++++++++ .../overview/workspace_overview.vala | 16 +++++++++++ src/components/sidebar/sidebar.vala | 20 +++++++++++++ src/core/hot_corner_manager.vala | 8 ++++++ src/core/main.vala | 11 +++++++- 7 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/components/dock/dock.vala b/src/components/dock/dock.vala index 39f2fec..85ed934 100644 --- a/src/components/dock/dock.vala +++ b/src/components/dock/dock.vala @@ -242,6 +242,10 @@ namespace Singularity { if (key == "panel-fusion") { update_fusion(); ((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(); update_visibility_mode(); pulse_frame_clock(); } @@ -258,6 +262,10 @@ namespace Singularity { _enabled = _settings.get_boolean("dock-enabled"); if (!_enabled) { ((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(); set_exclusive_zone(this, 0); app_system.shell_dock_height = 0; _set_reveal_barrier_active(false); @@ -928,6 +936,10 @@ namespace Singularity { break; case "remap": 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(); queue_draw(); break; @@ -1037,6 +1049,10 @@ namespace Singularity { // transform is presented reliably without moving the surface. set_body_class("dock-reveal-offset", true); ((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(); start_content_slide(); } @@ -1141,6 +1157,10 @@ namespace Singularity { } if (!_enabled) { ((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(); set_exclusive_zone(this, 0); app_system.shell_dock_height = 0; _set_reveal_barrier_active(false); @@ -1158,6 +1178,10 @@ namespace Singularity { queue_resize(); } else if (visibility_mode == "overview-only") { 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(); set_exclusive_zone(this, 0); } update_autohide_state(); @@ -1208,6 +1232,10 @@ namespace Singularity { // committed, so closing a focused fullscreen window left the // dock buried. Remap to force a fresh buffer and present. ((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(); update_visibility_mode(); pulse_frame_clock(); diff --git a/src/components/overview/app_menu.vala b/src/components/overview/app_menu.vala index 73a1d47..c140c4a 100644 --- a/src/components/overview/app_menu.vala +++ b/src/components/overview/app_menu.vala @@ -159,6 +159,10 @@ namespace Singularity { ((Gtk.Widget)this).add_controller(key_controller); 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(); } private void apply_monitor_sizing() { @@ -270,6 +274,10 @@ namespace Singularity { menu_animation.done.connect(() => { if (_is_open) return; 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(); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); }); @@ -334,6 +342,10 @@ namespace Singularity { if (stay_open) search_entry.grab_focus(); else { 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(); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); } @@ -354,6 +366,10 @@ namespace Singularity { if (stay_open) search_entry.grab_focus(); else { 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(); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); } diff --git a/src/components/overview/overview.vala b/src/components/overview/overview.vala index ef3c3be..2c53872 100644 --- a/src/components/overview/overview.vala +++ b/src/components/overview/overview.vala @@ -334,6 +334,22 @@ namespace Singularity { _anim_out_timer = 0; main_box.remove_css_class("animating-out"); hide(); + // Drop the GdkSurface, do not merely unmap it. + // GTK reuses one wl_surface across hide/show, and + // gtk4-layer-shell builds a fresh layer surface over + // it on the next open. A frame queued by the closing + // animation can land after the unmap and re-attach a + // live buffer; the next open then commits that stale + // buffer before any configure, and the compositor + // kills the client -- wl_display error 2, + // layer_surface has never been configured. + // unrealize() forces a fresh wl_surface next time. + // Same call gtk4-layer-shell uses in its own remap. + // 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 valac emits for each spelling. + ((Gtk.Widget) this).unrealize(); PreviewCache.get_default().clear(); hidden(); // The overview just freed its grid widgets, icon textures @@ -434,6 +450,10 @@ namespace Singularity { 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(); PreviewCache.get_default().clear(); hidden(); Singularity.trim_heap(); diff --git a/src/components/overview/workspace_overview.vala b/src/components/overview/workspace_overview.vala index aea62a9..fe6e478 100644 --- a/src/components/overview/workspace_overview.vala +++ b/src/components/overview/workspace_overview.vala @@ -136,6 +136,10 @@ namespace Singularity { update_wallpaper(); 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(); } private void update_wallpaper() { @@ -421,6 +425,10 @@ namespace Singularity { opacity = 0; anim_box.remove_css_class("animating-out"); 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(); // Free all window preview textures - they'll be re-captured on next open clear_overview_content(); hidden(); @@ -499,6 +507,10 @@ namespace Singularity { opacity = target; if (!stay_open) { 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(); clear_overview_content(); hidden(); } @@ -518,6 +530,10 @@ namespace Singularity { if (_gesture_animation == animation) _gesture_animation = null; if (!stay_open) { 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(); clear_overview_content(); hidden(); } diff --git a/src/components/sidebar/sidebar.vala b/src/components/sidebar/sidebar.vala index ed0bddd..5e7a343 100644 --- a/src/components/sidebar/sidebar.vala +++ b/src/components/sidebar/sidebar.vala @@ -76,6 +76,10 @@ namespace Singularity { }); system_view.hide_sidebar.connect(() => { 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(); }); system_view.open_settings_page.connect((page) => { open_page(page); @@ -91,6 +95,10 @@ namespace Singularity { update_vertical_anchor(); 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(); // Close on Escape key var key_controller = new Gtk.EventControllerKey(); @@ -99,6 +107,10 @@ namespace Singularity { if (!Singularity.DebugManager.get_default().sidebar_pinned) { desktop_settings.set_boolean("bar-layout-edit-mode", false); 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(); } return true; } @@ -134,6 +146,10 @@ namespace Singularity { opacity = 1; GtkLayerShell.set_margin(this, GtkLayerShell.Edge.RIGHT, 40); 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(); return; } // Null before skip so old done handler sees slide_animation != old_anim @@ -156,6 +172,10 @@ namespace Singularity { _is_closing = false; _can_close_on_focus_loss = false; 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(); } }); anim.play(); diff --git a/src/core/hot_corner_manager.vala b/src/core/hot_corner_manager.vala index e00b3be..b23a04c 100644 --- a/src/core/hot_corner_manager.vala +++ b/src/core/hot_corner_manager.vala @@ -225,6 +225,10 @@ namespace Singularity { set_child(hint); 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(); } public void show_hint(string action) { @@ -236,6 +240,10 @@ namespace Singularity { public void hide_hint() { hint.remove_css_class("visible"); 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(); } private static string icon_for_action(string? action) { diff --git a/src/core/main.vala b/src/core/main.vala index d8f4f4a..dd78544 100644 --- a/src/core/main.vala +++ b/src/core/main.vala @@ -760,7 +760,16 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh private void sync_bar_layout_edit_mode() { if (settings.get_boolean("bar-layout-edit-mode")) { - sidebar?.hide(); + if (sidebar != null) { + sidebar.hide(); + // Drop the GdkSurface here too. This is the one close path that + // lives OUTSIDE Sidebar, so the in-class resets do not cover it, + // and re-opening via a panel or dock toggle calls present() on + // the old surface -- the same stale-buffer fault. + // Cast required: GtkWindow implements GtkNative, so a bare + // unrealize() binds to gtk_native_unrealize, not the widget one. + ((Gtk.Widget) sidebar).unrealize(); + } settings_window?.hide(); if (bar_layout_edit_overlay == null) { bar_layout_edit_overlay = new Singularity.BarLayoutEditOverlay(this); From 4730fea0c32ed06086a1d505be95f09044fcf4ef Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 12 Aug 2026 21:39:03 -0400 Subject: [PATCH 2/3] shell: collapse the layer-window close paths into one helper 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. --- meson.build | 1 + src/components/dock/dock.vala | 42 ++++--------------- src/components/overview/app_menu.vala | 24 ++--------- src/components/overview/overview.vala | 24 +---------- .../overview/workspace_overview.vala | 24 ++--------- src/components/sidebar/sidebar.vala | 30 +++---------- src/core/hot_corner_manager.vala | 12 +----- src/core/layer_window.vala | 37 ++++++++++++++++ 8 files changed, 62 insertions(+), 132 deletions(-) create mode 100644 src/core/layer_window.vala diff --git a/meson.build b/meson.build index 7f14591..5000842 100644 --- a/meson.build +++ b/meson.build @@ -324,6 +324,7 @@ singularity_core_sources = files( 'src/components/sidebar/pages/performance_page.vala', 'src/core/display_manager.vala', 'src/core/hot_corner_manager.vala', + 'src/core/layer_window.vala', 'src/core/monitor_osd.vala', 'src/core/system_components.vala', 'src/core/debug_manager.vala', diff --git a/src/components/dock/dock.vala b/src/components/dock/dock.vala index 85ed934..669cde6 100644 --- a/src/components/dock/dock.vala +++ b/src/components/dock/dock.vala @@ -241,11 +241,7 @@ namespace Singularity { } if (key == "panel-fusion") { update_fusion(); - ((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(); + close_layer_window (this); update_visibility_mode(); pulse_frame_clock(); } @@ -261,11 +257,7 @@ namespace Singularity { if (key == "dock-enabled") { _enabled = _settings.get_boolean("dock-enabled"); if (!_enabled) { - ((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(); + close_layer_window (this); set_exclusive_zone(this, 0); app_system.shell_dock_height = 0; _set_reveal_barrier_active(false); @@ -935,11 +927,7 @@ namespace Singularity { queue_draw(); break; case "remap": - 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(); + close_layer_window (this); present(); queue_draw(); break; @@ -1048,11 +1036,7 @@ namespace Singularity { // is live right after the remap (and we pulse it), so the content // transform is presented reliably without moving the surface. set_body_class("dock-reveal-offset", true); - ((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(); + close_layer_window (this); present(); start_content_slide(); } @@ -1156,11 +1140,7 @@ namespace Singularity { return; } if (!_enabled) { - ((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(); + close_layer_window (this); set_exclusive_zone(this, 0); app_system.shell_dock_height = 0; _set_reveal_barrier_active(false); @@ -1177,11 +1157,7 @@ namespace Singularity { // Re-trigger size_allocate to restore the correct exclusive zone queue_resize(); } else if (visibility_mode == "overview-only") { - 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(); + close_layer_window (this); set_exclusive_zone(this, 0); } update_autohide_state(); @@ -1231,11 +1207,7 @@ namespace Singularity { // window covering it) is not composited until a frame is // committed, so closing a focused fullscreen window left the // dock buried. Remap to force a fresh buffer and present. - ((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(); + close_layer_window (this); present(); update_visibility_mode(); pulse_frame_clock(); diff --git a/src/components/overview/app_menu.vala b/src/components/overview/app_menu.vala index c140c4a..94196e5 100644 --- a/src/components/overview/app_menu.vala +++ b/src/components/overview/app_menu.vala @@ -158,11 +158,7 @@ namespace Singularity { }); ((Gtk.Widget)this).add_controller(key_controller); - 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(); + close_layer_window (this); } private void apply_monitor_sizing() { @@ -273,11 +269,7 @@ namespace Singularity { menu_animation.tick.connect(() => { opacity = menu_animation.value; }); menu_animation.done.connect(() => { if (_is_open) return; - 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(); + close_layer_window (this); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); }); @@ -341,11 +333,7 @@ namespace Singularity { opacity = target; if (stay_open) search_entry.grab_focus(); else { - 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(); + close_layer_window (this); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); } @@ -365,11 +353,7 @@ namespace Singularity { if (menu_animation == animation) menu_animation = null; if (stay_open) search_entry.grab_focus(); else { - 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(); + close_layer_window (this); if (widgets_grid != null) widgets_grid.depopulate(); hidden(); } diff --git a/src/components/overview/overview.vala b/src/components/overview/overview.vala index 2c53872..eb113cc 100644 --- a/src/components/overview/overview.vala +++ b/src/components/overview/overview.vala @@ -333,23 +333,7 @@ namespace Singularity { _anim_out_timer = GLib.Timeout.add(180, () => { _anim_out_timer = 0; main_box.remove_css_class("animating-out"); - hide(); - // Drop the GdkSurface, do not merely unmap it. - // GTK reuses one wl_surface across hide/show, and - // gtk4-layer-shell builds a fresh layer surface over - // it on the next open. A frame queued by the closing - // animation can land after the unmap and re-attach a - // live buffer; the next open then commits that stale - // buffer before any configure, and the compositor - // kills the client -- wl_display error 2, - // layer_surface has never been configured. - // unrealize() forces a fresh wl_surface next time. - // Same call gtk4-layer-shell uses in its own remap. - // 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 valac emits for each spelling. - ((Gtk.Widget) this).unrealize(); + close_layer_window (this); PreviewCache.get_default().clear(); hidden(); // The overview just freed its grid widgets, icon textures @@ -449,11 +433,7 @@ namespace Singularity { } 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(); + close_layer_window (this); PreviewCache.get_default().clear(); hidden(); Singularity.trim_heap(); diff --git a/src/components/overview/workspace_overview.vala b/src/components/overview/workspace_overview.vala index fe6e478..3f83515 100644 --- a/src/components/overview/workspace_overview.vala +++ b/src/components/overview/workspace_overview.vala @@ -135,11 +135,7 @@ namespace Singularity { wp_manager.wallpaper_changed.connect(update_wallpaper); update_wallpaper(); - 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(); + close_layer_window (this); } private void update_wallpaper() { @@ -424,11 +420,7 @@ namespace Singularity { _anim_out_timer = 0; opacity = 0; anim_box.remove_css_class("animating-out"); - 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(); + close_layer_window (this); // Free all window preview textures - they'll be re-captured on next open clear_overview_content(); hidden(); @@ -506,11 +498,7 @@ namespace Singularity { || Math.fabs(opacity - target) < 0.001) { opacity = target; if (!stay_open) { - 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(); + close_layer_window (this); clear_overview_content(); hidden(); } @@ -529,11 +517,7 @@ namespace Singularity { opacity = target; if (_gesture_animation == animation) _gesture_animation = null; if (!stay_open) { - 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(); + close_layer_window (this); clear_overview_content(); hidden(); } diff --git a/src/components/sidebar/sidebar.vala b/src/components/sidebar/sidebar.vala index 5e7a343..66f5c94 100644 --- a/src/components/sidebar/sidebar.vala +++ b/src/components/sidebar/sidebar.vala @@ -75,11 +75,7 @@ namespace Singularity { toggle_settings(); }); system_view.hide_sidebar.connect(() => { - 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(); + close_layer_window (this); }); system_view.open_settings_page.connect((page) => { open_page(page); @@ -94,11 +90,7 @@ namespace Singularity { main_stack.notify["visible-child-name"].connect(() => update_vertical_anchor()); update_vertical_anchor(); - 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(); + close_layer_window (this); // Close on Escape key var key_controller = new Gtk.EventControllerKey(); @@ -106,11 +98,7 @@ namespace Singularity { if (keyval == Gdk.Key.Escape) { if (!Singularity.DebugManager.get_default().sidebar_pinned) { desktop_settings.set_boolean("bar-layout-edit-mode", false); - 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(); + close_layer_window (this); } return true; } @@ -145,11 +133,7 @@ namespace Singularity { _can_close_on_focus_loss = false; opacity = 1; GtkLayerShell.set_margin(this, GtkLayerShell.Edge.RIGHT, 40); - 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(); + close_layer_window (this); return; } // Null before skip so old done handler sees slide_animation != old_anim @@ -171,11 +155,7 @@ namespace Singularity { slide_animation = null; _is_closing = false; _can_close_on_focus_loss = false; - 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(); + close_layer_window (this); } }); anim.play(); diff --git a/src/core/hot_corner_manager.vala b/src/core/hot_corner_manager.vala index b23a04c..44f182c 100644 --- a/src/core/hot_corner_manager.vala +++ b/src/core/hot_corner_manager.vala @@ -224,11 +224,7 @@ namespace Singularity { hint.add_overlay(badge); set_child(hint); - 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(); + close_layer_window (this); } public void show_hint(string action) { @@ -239,11 +235,7 @@ namespace Singularity { public void hide_hint() { hint.remove_css_class("visible"); - 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(); + close_layer_window (this); } private static string icon_for_action(string? action) { diff --git a/src/core/layer_window.vala b/src/core/layer_window.vala new file mode 100644 index 0000000..cf2ae73 --- /dev/null +++ b/src/core/layer_window.vala @@ -0,0 +1,37 @@ +using Gtk; + +namespace Singularity { + + /** + * Hide a layer-shell window and drop its GdkSurface. + * + * GTK keeps one wl_surface alive across hide/show, while 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. wl_surface state is persistent, so + * a frame that lands after the unmap leaves a buffer attached, and creating + * a layer surface over a surface that still holds a buffer is a client + * error the compositor answers by killing the connection: + * + * Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display + * zwlr_layer_surface_v1 has never been configured + * + * Whether the stray frame lands is a race on wl_buffer.release, which is + * why a window would survive a variable number of open/close cycles before + * dying. Dropping the GdkSurface means 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(), so it is a supported, exercised path. + * unrealize() on a never-realized widget is a documented no-op, which makes + * this safe to call from a constructor-time hide. + * + * The cast matters: GtkWindow implements GtkNative, so a bare unrealize() + * binds to gtk_native_unrealize -- an internal vfunc -- rather than + * gtk_widget_unrealize. Verified by inspecting the C that valac emits for + * each spelling. + */ + public void close_layer_window (Gtk.Window window) { + ((Gtk.Widget) window).hide (); + ((Gtk.Widget) window).unrealize (); + } +} From ee9ec8b659391e8ce046809db65a5adb1b1ac94c Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Thu, 13 Aug 2026 11:32:44 -0400 Subject: [PATCH 3/3] shell: end the frame clock the dock pulse actually began 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. --- src/components/dock/dock.vala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/dock/dock.vala b/src/components/dock/dock.vala index 669cde6..4364965 100644 --- a/src/components/dock/dock.vala +++ b/src/components/dock/dock.vala @@ -975,9 +975,13 @@ namespace Singularity { var fc = get_frame_clock(); if (fc == null) return; fc.begin_updating(); + // End the clock we began, not whatever get_frame_clock() returns + // 350ms later: closing a layer window now drops the GdkSurface, so + // the next present allocates a NEW frame clock. Re-fetching here + // would leave the original permanently updating and double-end the + // new one on a quick pointer leave/re-enter. GLib.Timeout.add(350, () => { - var f = get_frame_clock(); - if (f != null) f.end_updating(); + fc.end_updating(); return GLib.Source.REMOVE; }); }