Skip to content

fix(overlays): finish the RefCell-across-GTK sweep — notifications cluster, install inserts, shared borrows (#643) - #673

Merged
vibechoom merged 1 commit into
mainfrom
fix/643-overlay-refcell-sweep
Jul 31, 2026
Merged

fix(overlays): finish the RefCell-across-GTK sweep — notifications cluster, install inserts, shared borrows (#643)#673
vibechoom merged 1 commit into
mainfrom
fix/643-overlay-refcell-sweep

Conversation

@vibechoom

Copy link
Copy Markdown
Contributor

The overlay half of the RefCell-borrow-across-a-GTK-call sweep, sequenced
behind #644 and #663 (which owned these files) and covering the four groups
#643's scope-rewrite comment left open, plus what the closing re-grep turned up.

22 sites, all four spellings. The definition applied is the corrected one
from #643: any borrow held across a GTK call, not just borrow_mut().

1. The notifications.rs named-binding cluster — highest priority

This is the one site in the whole cluster where the tree documents the
emission as synchronous rather than leaving it unverified. The card-loop comment
and replace_card's comment on vbox.remove(&old.widget) both state that
unmapping a card fires its connect_unmapresume_expiry, and #593's entire
keep-the-card-mounted design is built on that being immediate. It doesn't panic
today only because that handler reaches for the expiry bookkeeping rather than
for card_map.

site spelling was
trollshell/src/overlays/notifications.rs:269 (apply_emission) (2) named binding let mut map = view.card_map.borrow_mut(); live across vbox.remove, replace_card/mount_card, the overflow remove/append, and set_visible
trollshell/src/overlays/notifications.rs:270 (apply_emission) (2) named binding let mut suppressed = view.suppressed_during_dnd.borrow_mut(); same span
trollshell/src/overlays/notifications.rs:335 (apply_emission) (2) named binding let mut slot = view.overflow_card.borrow_mut(); live across remove() + append()
trollshell/src/overlays/notifications.rs:350 (apply_emission) (4) argument-position set_visible(!map.is_empty() || view.overflow_card.borrow().is_some())
trollshell/src/overlays/notifications.rs:223 (route_emission) (3) shared borrow() TOAST_WINDOWS.borrow() held across the whole of apply_emission

Fix: take-and-restore for the three cells (the panels/stats.rs
build_live_per_core_row idiom from #663), compute the visibility bool from the
locals before handing anything back, and Rc<ToastView> in TOAST_WINDOWS so
route_emission can clone a handle out — mirroring how overlays::osd already
stores Rc<OsdView>.

Trade-off stated in the code, not hidden: while apply_emission runs, the
cells hold empty Defaults, so a re-entrant reader would see no cards rather
than the live set. Same trade #663 made for stats.rs's per-core bars and
connections.rs's row vecs, and it beats the alternative, which is not a wrong
answer but a BorrowMutError through a glib callback — a process abort. Nothing
re-enters via the driver itself: route_emission is pumped from a
spawn_localed for_each, which cannot re-poll itself synchronously.

2. The four install insert-drop sites

The shape #644 annotated at modal.rs, surviving unannotated in four more
places. All are the semicolon-rule hazard: insert returns the displaced value,
and as a bare statement that value is a temporary of the same statement as
the borrow_mut() RefMut — statement temporaries drop in reverse creation
order, so it runs its drop glue while the cell is still borrowed. Converted to
tail-expression insert + an outer drop(...).

  • trollshell/src/overlays/sidebar.rs:267 — displaced SidebarPanel
  • trollshell/src/overlays/frame.rs:178 — displaced FrameView
  • trollshell/src/overlays/osd.rs:182 — displaced Rc<OsdView>
  • trollshell/src/overlays/consent.rs:77 — displaced Monitor

Two honesty notes.

Reachability. All four need install to run twice for one key without an
intervening close_all, which main.rs prevents today. Weakest group in the
sweep; converted because not holding the borrow costs nothing.

Mechanism. The #663 review's correction is applied here: this is a refcount
decrement, not a widget teardown
. GTK holds its own reference to a mapped
toplevel, so dropping the Rust gtk::Window handle does not dispose the window
— that needs destroy(), which close_all calls. What actually runs inside the
borrow is JoinHandle/Mutable/SourceId drop glue plus GObject unrefs. The
in-code comments say that rather than repeating the teardown framing.

The consent site is the weakest of the four and I'd rather say so than let it
read as evidence:
the displaced value is a Monitor, whose drop is a
GdkMonitor unref, which emits nothing. There is no plausible re-entrant path
even in principle. Converted purely so the shape is uniform and nobody has to
re-derive which of the four were "the real ones".

3. Spelling-(3) shared borrow() sites

modal.rs needed a type change to fix at all: every entry point wants a whole
&ModalPanel to drive a run of GTK calls, so PANELS now stores
Rc<ModalPanel> and two helpers — live_panel(key) (modal.rs:524) /
live_panels() (modal.rs:518) — hand back owned handles with the borrow
already released. close_all and install are the borrow_mut()
counterparties.

  • trollshell/src/modal.rs:445 — the PANELS value type
  • trollshell/src/modal.rs:1001 (wire_retract_finish) — Ref across window.set_visible(false) and open_state.set(false), which wakes every drawer-open subscriber (futures-signals calls Waker::wake() synchronously from notify)
  • trollshell/src/modal.rs:1153 (switch_active) — across set_stack_page / on_page_show
  • trollshell/src/modal.rs:1170 (dismiss_all) — across set_reveal_child
  • trollshell/src/modal.rs:1182 (retract_by_key) — if let scrutinee Ref across set_reveal_child
  • trollshell/src/modal.rs:1201 (open_by_key) — across show_panel
  • trollshell/src/modal.rs:1237 (open_plugin_by_key) — across show_panel_active + set_active_panel
  • trollshell/src/modal.rs:1303 (toggle_inner) — across all three arms; the busiest entry point in the module (every bar chip lands here)
  • trollshell/src/modal.rs:1422 (wire_recenter_on_map) — across apply_stats_scroll + main_margin_for_center
  • trollshell/src/overlays/osd.rs:432 (route_show) — OSDS.borrow() across show(); OSDS was already Rc<OsdView>, so this is a refcount bump
  • trollshell/src/overlays/sidebar.rs:190 (is_settled_for_key) — PANELS.borrow() across revealer.is_child_revealed()

That last one is a property getter, which cannot emit. I converted it anyway
rather than leaving the getter exemption as folklore — #663's review flagged
exactly that gap ("if getters are categorically safe, that belongs in the
definition explicitly"). Three lines; cheaper to settle than to argue.

4. widgets/tasks.rs

  • trollshell/src/widgets/tasks.rs:695 (refresh_summary) — spelling (3). match *self.mode.borrow() { … } borrows the place expression for the whole match, so the Ref was live across every arm's set_visible/set_text, with a second nested self.selected.borrow() in the Pick arm. Three borrow_mut() counterparties on mode (set_mode, set_value, reset) and three on selected; set_mode is reached from a connect_toggled handler. Both payloads are Copy, so the fix is two register copies.
  • trollshell/src/widgets/tasks.rs:393 (do_create) — the audit-trail correction from fix(ui): release RefCell borrows across GTK calls outside overlays/ (#643) #663's review. drop(lists) came after DropDown::selected(), so the exclusion was only ever correct on getter-versus-setter grounds, which the definition never states. Rather than codify that exemption, the two statements are swapped.

Also found by the closing re-grep (not in #643's list)

Same mechanical idiom; leaving them would have meant declaring these files clean
while three known named-RefMut-across-remove/append sites survived.

  • trollshell/src/widgets/workspaces.rs:100 (update_workspaces) — (2), RefMut across container.remove / apply_workspace_visuals / append / insert_after
  • trollshell/src/widgets/tray.rs:64 (update_tray) — (2), same span plus create_item_button
  • trollshell/src/widgets/window_list.rs:50 (update_windows) — (2), same span
  • trollshell/src/widgets/tray.rs:131 (create_item_button) — (3), item_cell.borrow() across apply_item_button_visuals; update_tray's reuse arm holds the borrow_mut() counterparty

Ruled out, with reasons (so the next pass isn't misled)

Checked and deliberately not converted:

  • notifications.rs:149 (install) — the semicolon rule's worked counter-example. TOAST_WINDOWS.with(|map| map.borrow_mut().insert(k, v)) is safe because the insert is the closure's tail expression: the returned Option moves out past the RefMut. Annotated in place with a "do not add a semicolon here" note rather than needlessly wrapped.
  • modal.rs:1215/:1253 (open_on_focused, open_plugin_on_focused), sidebar.rs:142 (toggle_on_focused) — key lookups only (contains_key, keys().next().cloned()), no GTK under the borrow.
  • modal.rs:468 (recompute_gates), modal.rs:506 (drawer_open_state), sidebar.rs:651, osd.rs:162 — no GTK call under the borrow. (osd.rs:162's view.drawer_open is a Cell<bool>, not a Mutable, so it does not even wake.)
  • sidebar.rs:166 (current_visible_width_for_key) — reads open_state.get(), a Mutable, not GTK.
  • prompt.rs:65, osd.rs:210, sidebar.rs:448/:637 — abort a task or remove a SourceId; the exclusion fix(overlays): the same RefCell-held-across-a-GTK-close pattern exists in 7 more places (#627/#630 blast radius) #631/fix: the RefCell-across-a-GTK-call pattern also lives outside overlays/ — reactive_list.rs, calendar/tasks/connections rows, control-center, hytte-ui popup (#631 blast radius) #643 already made.
  • modal.rs:1425, and let-else scrutinees generally — a let-else temporary drops at the end of its own let statement, not across a following call.
  • widgets/tasks.rs:682, and if-condition borrows generally — condition temporaries drop before the block runs.
  • widgets/screencast.rs:51stop_all is niri IPC, no GTK.
  • widgets/calendar.rs:531group_events_by_day is pure, and the Ref drops at the end of that let, before any paint_cell.
  • widgets/tray.rs:144/:178, widgets/workspaces.rs:63, widgets/tasks.rs:401 — already drop(...) the borrow before the first GTK call (:401 after this PR's reorder).
  • widgets/mpris.rs:216/:220, widgets/tasks.rs:79/:162/:173/:184, calendar.rs:309/:355/:403, consent.rs:255, prompt.rs:230, sidebar.rs:462 — plain assignments; the displaced value is either non-GTK data or a slot the same function just take()d to None.

Testing — what I could and could not verify

No new tests, and the reason is structural rather than a judgement call:
trollshell is a binary crate with no system-tests/xvfb bucket at all.
Its
own modal.rs test module already says so ("the trollshell binary has no
system-tests/xvfb harness"). Every site in this PR needs a live GTK widget
tree — a ModalPanel, a ToastView, a DuePicker — to reach, so the
hytte-ui #[gtk::test] pattern
(close_catchers_tolerates_a_reentrant_teardown_from_destroy) has nowhere to
run here. Standing one up would mean adding a system-tests feature to the
binary crate and wiring it into flake.nix's checks; that is a separate
change and probably its own issue.

The Waker-based hermetic harness does not apply either, and I checked per
site rather than assuming.
It needs the reentrancy trigger to be a Mutable
wake reachable without GTK. Two sites here do have a Mutable wake inside the
formerly-live borrow — wire_retract_finish's open_state.set(false) and
show_panel_active's open_state.set(true) — which is more than #663 found,
but reaching either requires a populated PANELS, i.e. a real ModalPanel,
i.e. GTK windows. So the trigger is only reachable through GTK and the harness
can't be built hermetically. #644's
reset_drawer_open_states_does_not_reenter_the_borrow remains the one site in
the cluster where it did apply.

What is verified: the existing hermetic suites still pass over the changed
code, including sidebar.rs's is_settled_for_key("nonexistent") /
current_visible_width_for_key("nonexistent") guards and modal.rs's
close_all / reset_drawer_open_states tests, which now run against
HashMap<String, Rc<ModalPanel>>.

Gates run locally: cargo clippy --workspace --all-targets --features system-tests -- -D warnings (with RUSTC_WRAPPER unset), cargo test --workspace, xvfb-run cargo test --features system-tests -p hytte-ui,
nix fmt.

Live-verify wanted for the two behavioural conversions, since CI can't reach
them: the drawer (open / swap page / retract / deep-link, and a monitor
hot-plug) and the toast stack (several toasts at once so the "+N more" overflow
card appears and then goes away, plus a replaces_id update while hovering a
toast — that last one is the #593 hover-hold path apply_emission rewrites
around).

Not repeating a claim that was corrected

crates/trollshell-control-center/src/main.rs is not cited here as evidence
for anything. #643's correction comment established that its
connect_active_notify routes everything through spawn_on_runtime → tokio → a
oneshot awaited inside glib::spawn_future_local, i.e. fully deferred; it is
exactly as speculative as every other site, and it is already fixed by #663.

Likewise the hytte-ui SIGABRT result is cited for what it proves and no more:
destroy emits synchronously from dispose and a BorrowMutError through a glib
callback really does abort the process. It is a synthetic probe of the
mechanism — nothing in the tree installs a connect_destroy on a catcher —
not evidence that any particular site was live.

Closes #643. Refs #663, #644, #631, #630, #627, #593.

…uster, install inserts, shared borrows (#643)

The overlay half of #643, sequenced behind #644/#663 which owned these
files. 22 sites, all four spellings of the corrected definition (*any*
borrow held across a GTK call, not just `borrow_mut()`).

notifications.rs `apply_emission` is the headline: two named `RefMut`s
plus the overflow slot held live across `vbox.remove`, `replace_card`/
`mount_card`, the overflow `remove`/`append` and the closing
`set_visible`, whose argument was itself an `overflow_card.borrow()`
temporary. It is the one site in the cluster whose emission the tree
*documents* as synchronous (`connect_unmap` → `resume_expiry`, the
mechanism #593's design rests on); it survives today only because that
handler touches the expiry bookkeeping rather than `card_map`. Converted
with take-and-restore, the `panels/stats.rs` idiom from #663.

`TOAST_WINDOWS` and `PANELS` (modal) now store `Rc<_>` so `route_emission`
and modal's nine entry points can clone a handle out instead of holding a
shared borrow across `show_panel` / `set_stack_page` / `set_reveal_child`
/ `on_page_show` — mirroring `overlays::osd`'s existing `Rc<OsdView>`.
`modal::live_panel`/`live_panels` are the snapshot helpers.

The four `install` insert-drop sites (sidebar/frame/osd/consent) get the
tail-expression + `drop(...)` shape #644 annotated at `modal::install`.
Their comments state the mechanism precisely per the #663 review's
correction: a refcount decrement, not a widget teardown. consent's is the
weakest of the four — a `GdkMonitor` unref emits nothing — and says so.

Also `tasks.rs::refresh_summary` (a `match *self.mode.borrow()` holding
across every arm's `set_visible`/`set_text`, which #663 missed inside a
range its body certified clean), `tasks.rs::do_create`'s getter-ordering
folklore, and four sites the closing re-grep turned up in
`widgets/{workspaces,tray,window_list}.rs`.

No new tests: `trollshell` is a binary crate with no `system-tests`/xvfb
bucket, and every site needs a live GTK widget tree to reach. The
`Waker`-based harness doesn't apply — the two sites here with a `Mutable`
wake inside the borrow still need a populated `PANELS`, i.e. GTK windows.

Closes #643. Refs #663, #644, #631, #630, #627, #593.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vibechoom
vibechoom merged commit 7a7031d into main Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant