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 f011b70517a208e615020ff1bc56e1656f68bbad Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 12 Aug 2026 22:44:38 -0400 Subject: [PATCH 3/3] shell: dismiss folder overlays when the launcher closes Opening an app folder creates an AppFolderOverlay, which is its own toplevel layer-shell window on the OVERLAY layer. Hiding the launcher therefore does not take it down. Nothing closed it at close time. The only thing that dismisses folder overlays is launcher_grid.depopulate(), and that is deferred to an idle timer of IDLE_DEPOPULATE_MS -- 45 seconds -- whose purpose is reclaiming grid widgets, icon textures and preview buffers. So the overlay stayed on screen for 45s after the launcher closed, and if the launcher was reopened inside that window the timer was cancelled (overview.vala, reopen branch), leaving the overlay up indefinitely. Split the cheap part out: close_folder_overlays() dismisses the overlays and nothing else, and both close paths -- the animated close and the gesture dismiss -- call it immediately. depopulate() still does the expensive teardown on its idle timer and now delegates the overlay half to the same helper, so there is no behaviour change for the memory-reclaim path. The overlay feature itself is untouched; only the point at which it goes away changes. --- src/components/overview/app_launcher_grid.vala | 15 ++++++++++++++- src/components/overview/overview.vala | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/components/overview/app_launcher_grid.vala b/src/components/overview/app_launcher_grid.vala index 5125236..d9c2820 100644 --- a/src/components/overview/app_launcher_grid.vala +++ b/src/components/overview/app_launcher_grid.vala @@ -377,9 +377,22 @@ namespace Singularity { return GLib.Source.CONTINUE; } - public void depopulate() { + /** + * Dismiss any open folder overlay without tearing down the grid. + * + * A folder overlay is its own toplevel layer-shell window, so hiding + * the launcher that spawned it leaves it on screen. Closing the + * overlays is cheap; dropping the grid contents is not, which is why + * depopulate() stays on its idle timer and this can be called + * immediately on close. + */ + public void close_folder_overlays() { _folder_overlays.foreach((id, ov) => ov.close_overlay()); _folder_overlays.remove_all(); + } + + public void depopulate() { + close_folder_overlays(); Widget? c = grid.get_first_child(); while (c != null) { Widget nc = c.get_next_sibling(); grid.remove(c); c = nc; } } diff --git a/src/components/overview/overview.vala b/src/components/overview/overview.vala index eb113cc..5d68694 100644 --- a/src/components/overview/overview.vala +++ b/src/components/overview/overview.vala @@ -334,6 +334,12 @@ namespace Singularity { _anim_out_timer = 0; main_box.remove_css_class("animating-out"); close_layer_window (this); + // A folder overlay is a separate toplevel layer window, so + // it does not go away with the launcher. Dismiss it now -- + // waiting for the idle depopulate below left it on screen + // for IDLE_DEPOPULATE_MS, or indefinitely if the overview + // was reopened before that timer fired and cancelled it. + launcher_grid.close_folder_overlays(); PreviewCache.get_default().clear(); hidden(); // The overview just freed its grid widgets, icon textures @@ -434,6 +440,9 @@ namespace Singularity { private void finish_gesture_hide() { close_layer_window (this); + // Same reason as the animated close path: the folder overlay is its + // own toplevel and does not go away with the launcher. + launcher_grid.close_folder_overlays(); PreviewCache.get_default().clear(); hidden(); Singularity.trim_heap();