fix(overlays): destroy unrealized layer windows on teardown, not close() (#632) - #637
Merged
Merged
Conversation
All five per-monitor overlay `close_all` functions called
`gtk::Window::close()` to tear down layer-shell surfaces. Per GTK 4.22's own
GIR docs, `gtk_window_close` is a *request* — "similar to what happens when
a window manager close button is clicked" — routed through `close-request`;
it is not the call that drops GTK's internal toplevel reference.
`gtk_window_destroy` is: "Drops the internal reference GTK holds on toplevel
windows."
`hytte-ui`'s layer-shell builder deliberately never shows these windows
(`crates/hytte-ui/src/layer_window.rs:98`), and `modal.rs`'s `EAGER_PAGES` is
empty, so nothing forces a realize. A drawer/overlay the user never opened
on a given monitor is therefore unrealized when a hot-plug's `close_all`
runs, and `close()` on an unrealized window doesn't tear it down — the
window and its whole child widget tree survive.
Worse, `trollshell/src/plugins/region.rs:392` aborts the plugin-panel
reconcile subscription only on `connect_destroy`, so every hot-plug that
never opened a given drawer leaks another live subscriber re-reconciling an
orphaned widget tree.
Switch all five `close_all` sites to `destroy()`: it works on unrealized
windows, it's the documented ref-dropping call, and — unlike `close()` — it
cannot be vetoed by a future `close-request` handler.
Sites: modal.rs, overlays/{sidebar,frame,osd,notifications}.rs.
overlays/prompt.rs is deliberately untouched (its window is always shown
before close() runs, and its `abort_subscription` has no GTK in it at all).
Closes #632
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This was referenced Jul 30, 2026
Merged
Closed
vibechoom
added a commit
that referenced
this pull request
Jul 31, 2026
…urst falsified (#672) Closes #660 Bring docs/live-verify.md current across the ten PRs that merged after the last refresh (#634): #637, #639, #644, #645, #662, #663, #664, #666, #668, plus #642 (coverage bump only, per the issue's own conclusion — its default station-id fix needs no live-verify caveat since write_default_config only touches an absent places.toml). Corrected the #630 entry: it told a verifier to expect modal::close_all tearing down with window.close(); #637 changed that call to destroy() after #630 had already merged, so the entry named a call the code no longer makes (exactly the issue's finding). Also added a new #645 entry to the Network panel section for the networkd startup-refresh retry, since that path used to latch "no link manager has answered yet" forever on a transient first-refresh failure (curable only by a shell restart) and now self-heals — swept the rest of the doc for the same class of falsified expectation (inert-until-restart) and found nothing else affected; the wifi-side #609/#634 entries describe a different code path and are unaffected. New entries added: - #637/#639 — the destroy()-not-close() sweep across the five trollshell overlays and the three remaining hytte-ui sites (bar.rs, popup.rs), including #637's WeakRef/toplevels()-count kanshi hot-plug gesture. - #644/#663 — the RefCell-borrow-across-GTK-call sweep (31 sites total across overlays/modal.rs and the rest of the UI, incl. an 8-panel-wide reactive_list.rs helper and the control-center). Both documented with the same "nothing changes" honest pass condition #630 already used. - #664 — mpris Auto chip fix + bare-string artist widening. Needs two live MPRIS players and a shell restart for the new CSS class, both called out. - #666 — new top-level "Claude bridge" section for the keyless loopback shim, nobody has run this service live yet. Carries the PR's flagged riskiest assumption (hive-claude's SessionNotFound classification) with its journal tell. - #668 — new top-level "D-Bus name ownership" section. Documents that recovery going from ~250ms to up to 5 minutes after a squatter exits is the expected cost of the fix, not a regression to file. Left alone: #642 (no doc change needed beyond the coverage line, matching the issue's own recommendation after checking places.toml's write-once behavior); #637's abort_subscription/prompt.rs exclusions and #644/#663's "deliberately not touched" sites (no live-visible behavior, nothing to verify). Gates: markdown-only diff (git diff --stat: 1 file, docs/live-verify.md). nix fmt run twice (1 file reformatted first pass — a prose reflow — 0 changed second pass). Clippy/tests deliberately skipped: nix/package.nix's crane filter excludes markdown, so the packaged derivation is unaffected and a nix build would burn a full workspace recompile for zero signal (the same crane-filter trap #662 already documented). nix build was not run, per instructions. Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #632
The bug
All five per-monitor overlay
close_allfunctions tore down their layer-shell windows withgtk::Window::close():trollshell/src/modal.rstrollshell/src/overlays/sidebar.rstrollshell/src/overlays/frame.rstrollshell/src/overlays/osd.rstrollshell/src/overlays/notifications.rsGTK 4.22's own GIR docs draw a sharp line between the two calls:
gtk_window_close— "Requests that the window is closed. This is similar to what happens when a window manager close button is clicked." A request, routed throughclose-request.gtk_window_destroy— "Drops the internal reference GTK holds on toplevel windows."close()is not the call that releases GTK's internal toplevel reference —destroy()is.hytte-ui's layer-shell builder deliberately never shows these windows (crates/hytte-ui/src/layer_window.rs:98: "Construct thegtk::Window, wire up layer-shell, but don't show"), andmodal.rs'sEAGER_PAGESis empty, so nothing forces a realize. A drawer/overlay the user never opened on a given monitor is therefore unrealized when a hot-plug'sclose_allruns, andclose()on an unrealized window doesn't tear it down — the window and its whole child widget tree survive.Worse:
trollshell/src/plugins/region.rs:392aborts the plugin-panel reconcile subscription only onconnect_destroy. Ifdestroy()never fires, every hot-plug/kanshi switch that never opened a given drawer on that monitor leaks another live subscriber re-reconciling an orphaned widget tree.The fix
Switched all five
close_allsites fromclose()todestroy(). It works on unrealized windows, it's the documented ref-dropping call, and — unlikeclose()— it can't be vetoed by a futureclose-requesthandler on these shell-owned, non-cancellable surfaces.Left the surrounding structure alone:
modal::close_all's drain-into-Vec-before-closing ordering (fix(modal): drop the PANELS borrow before closing drawer windows (#627) #630/aaba27a) and the post-loopreset_drawer_open_states()sequencing are untouched — only thepanel.window.close()call inside the loop changed todestroy(). Updated the doc comment's mentions ofgtk_window_close/close()togtk_window_destroy/destroy()where it specifically discusses which GTK call is used, since the comment's own reasoning (avoid a reentrant borrow panic) still applies todestroy().sidebar.rs'spanel.open_state.set(false)immediately before the window teardown (fix(osd): a hot-plug while a drawer is open latches DRAWER_OPEN true, silencing the OSD on that output for the rest of the session #618) is untouched — still runs first, thendestroy().trollshell/src/overlays/prompt.rswas not touched, per the issue and task scope:abort_subscriptionhas the sameif let Some(x) = …borrow_mut().take()shape as the fixed sites, but its body callshandle.abort()on a tokioAbortHandle— no GTK, nothing to destroy.close_prompt()does callw.close(), but that window is only ever inPROMPT_WINDOW's slot aftershow_prompthas already calledwindow.set_visible(true)+window.present()— i.e. it's realized by the timeclose()runs, and it's out of the issue's five named files, so it wasn't touched even though the same underlying GTK distinction technically applies to it.Other
connect_destroydependencies foundSwept the whole tree for
connect_destroy:trollshell/src/plugins/region.rs:392— the one already named in the issue (plugin-panel reconcile teardown on a sidebar/drawer child). Now fires correctly once the parent drawer window isdestroy()d instead ofclose()d.trollshell/src/plugins/region.rs:212— same shape, one layer down:build_region's render-subscription teardown on the mount-regiongtk::Boxitself. This container is used both by the sidebar slots (now fixed transitively, sincesidebar::close_allnowdestroy()s the parent window whose children cascade) and by the bar'sbar_left/center/right_slotregions, which live insideBar's own window (crates/hytte-ui/src/bar.rs) — not one of this issue's five files.crates/hytte-reactive/src/bind.rs:55— the genericbind()apply-loop's WeakRef self-free. Same family: relies on the widget'sdestroysignal firing when its ancestor window goes away.Also found, outside
connect_destroybut the same underlying defect:crates/hytte-ui/src/bar.rs:195and:208, andcrates/hytte-ui/src/popup.rs:287, also callwindow.close()for teardown. TheBarwindow is always shown (mapped) before a rebuild ever tears it down, so it's less likely to hit the "unrealized, soclose()no-ops entirely" failure mode this issue centers on — but the same "close()isn't the ref-dropping call" concern applies in principle. Left untouched: none of these are trollshell's five named files, they're a different crate (hytte-ui), and the issue explicitly scopes to the fivetrollshell/srcsites — flagging here rather than expanding scope.Test feasibility
No hermetic test was added.
trollshell/src/modal.rs's own existing test-module doc comment already states the crate has nosystem-tests/xvfb harness (unlikehytte-bus/hytte-reactive/hytte-services/hytte-ui, which do), and the existingclose_all_resets_drawer_open_statetest relies onPANELSbeing empty on a test thread specifically so the drain touches no GTK at all. Asserting thatdestroy()actually drops GTK's internal toplevel reference needs a live display server plus a bootedApp(to construct a real layer window), which this crate's test harness doesn't have — a test that doesn't exercise real GTK window realization wouldn't be honest coverage of this change, so I didn't write one. Confirmed viacargo test -p trollshell(233 passed, 0 failed) that nothing else regressed.Live-verify note
The honest manual check for this fix: on a two-monitor Niri session, hold a
glib::WeakRefto (or otherwise track) a drawer/sidebar/frame/OSD/toast window that's never been opened on the secondary output, force several kanshi profile switches (hot-plug/hot-unplug cycles), and confirm:WeakRefno longer upgrades after the correspondingclose_all(or, short of instrumenting aWeakRef, thatgtk::Window::toplevels()'s count doesn't grow per cycle for surfaces that were never opened).region.rs:392subscriber is left double-rendering into a dead tree.Gates run
unset RUSTC_WRAPPER && cargo clippy -p trollshell --all-targets -- -D warnings— clean.cargo test -p trollshell— 233 passed, 0 failed.nix fmttwice — 0 files changed both times.