fix(ui): release RefCell borrows across GTK calls outside overlays/ (#643) - #663
Conversation
…643) Sweeps the non-overlays/ half of #643 under the corrected definition from the issue's follow-up comment: *any* RefCell borrow held across a GTK call, not just a chained borrow_mut(). A borrow held across a call that emits synchronously panics with BorrowMutError if a handler re-enters the cell, and a panic unwinding through a glib callback aborts the process. All four spellings are fixed here: 1. chained temporary — for x in cell.borrow_mut().drain(..) { gtk(x) } 2. named binding — let mut m = cell.borrow_mut(); ... gtk() ... 3. shared borrow() — held across GTK work with a borrow_mut() elsewhere 4. argument-position — w.set_title(&f(&mut cell.borrow_mut())) The semicolon rule, recorded at control-center's insert site: a cell.borrow_mut().insert(k, v) is SAFE as a closure tail expression (the displaced Option is moved out past the RefMut) and a HAZARD with a trailing semicolon (the Option becomes a statement temporary, dropped before the RefMut, i.e. inside the borrow). Note drop(...) does not fix the direct spelling — only a closure boundary or a let binding moves the drop past the borrow. Related: RefCell::take() is not borrow_mut().take(); take()'s own borrow ends before it returns. modal.rs and overlays/ are deliberately untouched — PR #644 owns those files and carries that half of #643. Sites: popup.rs close_catchers; reactive_list.rs (backs 8 panels); power_profile.rs; stats.rs build_top_apps_expander x3 and build_live_per_core_row; calendar.rs rebuild_upcoming_list x2, on_day_clicked, refresh_upcoming_list; tasks.rs rebuild_list x3 and the create popover's connect_show; connections.rs x3; control-center apply_plugins x2 and clear_rows x2. Test: close_catchers_tolerates_a_reentrant_teardown_from_destroy, in hytte-ui's existing system-tests GTK bucket. GtkWidget::destroy is emitted synchronously from dispose, so a connect_destroy handler re-entering close_catchers reproduces the hazard for real. Falsified against the pre-fix shape: "RefCell already borrowed" -> "panic in a function that cannot unwind" -> SIGABRT, aborting the whole test binary rather than failing one test. No Waker-based test applies to the other sites: none of them touch a Mutable inside the borrow, so every trigger is a GTK emission, and neither trollshell nor trollshell-control-center has a display harness. Refs #643 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adversarial reviewVerdict: the 22 conversions are correct. No What does not hold is part of the audit trail. Three of the claims the PR body records as settled facts are wrong on the source, and one of them — the "strongest-evidence site" — has already been propagated into #643's second comment as the thing that raises the cluster's evidence grade. Since the durable output of this cluster is the enumerated definition and the exclusion list, not the 22 All line numbers below are on the branch ( Finding 1 — the "strongest-evidence site" is not stronger than the rest; the
|
…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]>
…uster, install inserts, shared borrows (#643) (#673) 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]>
Refs #643
Sweeps the non-
overlays/half of #643, under the corrected definition from the issue's follow-up comment: anyRefCellborrow held across a GTK call, not just a chainedborrow_mut().Scope split —
modal.rs/overlays/deliberately excludedPR #644 is open and owns
trollshell/src/modal.rsand every file undertrollshell/src/overlays/. Nothing here touches those files, so the two PRs can land in either order without conflicting. The overlay half of #643 — including the fourinstall-shaped insert sites and thenotifications.rsnamed-binding cluster the follow-up comment added — stays with #644. That is why this isRefs #643, notCloses.The hazard, and the four spellings
A
RefCellborrow held live across a GTK call that can synchronously emit a signal. If any handler on that emission borrows the same cell, it panics — and a panic unwinding through a glib callback aborts the process. Reachability is not the bar: the whole #631/#644 cluster fixed sites where emission is unverified, and this matches that standard.All four spellings appear below:
for x in cell.borrow_mut().drain(..) { gtk_call(x) }; theRefMutlives for the whole loop.let mut m = cell.borrow_mut();held live across GTK calls further down.borrow()held across GTK work where aborrow_mut()exists on another path.w.set_title(&f(&mut cell.borrow_mut())); theRef/RefMutis a temporary of the whole statement, so it outlives the call.The semicolon rule
insert-shaped calls are safe or unsafe depending on a semicolon.Two corollaries this PR leans on:
drop(cell.borrow_mut().insert(k, v))does not fix the direct (non-closure) spelling —dropconsumes the value while the statement'sRefMuttemporary is still alive. Only a closure boundary (as in fix(overlays): release RefCell borrows before the GTK call that can re-enter them (#631) #644'sdrop(PANELS.with(...))) or binding the result withletmoves the drop past the borrow.RefCell::take()is notborrow_mut().take().take()'s own borrow ends before it returns, soif let Some(x) = cell.take() { gtk_call(&x) }is already correct, whileif let Some(x) = cell.borrow_mut().take() { … }keeps theRefMutalive for the whole then-block.Sites fixed (22)
crates/hytte-ui/src/popup.rs:286close_catchers— spelling (1).borrow_mut().drain(..)acrosswin.destroy(). →take(). fix(hytte-ui): destroy() the three remaining close()-teardown sites (#638) #639 converted this function'sclose()→destroy()without touching the borrow.trollshell/src/components/reactive_list.rs:88reactive_list— spelling (1), acrossremove_row(). →take(). Highest blast radius in the sweep: this generic helper backs 8 consumers (panels/{appearance,vpn,clipboard,displays,stats}.rs,panels/network/{wired,wifi,connection}.rs).trollshell/src/components/power_profile.rs:49— spelling (1), acrossexpander.remove(). →take().trollshell/src/panels/stats.rs:632build_top_apps_expander— spelling (1), acrossremove(). →take().:654— spelling (4).sample_display_name(s, &mut meta_cache.borrow_mut())inlined intorow.set_title(…), so theRefMutlives acrossset_title. → bind the title first.:661— spelling (4). TheRefMutfromresolve_app_meta(app_id, &mut meta_cache.borrow_mut())lives across theunwrap_or_elsethat builds the fallback icon. Weakest site of the group (aThemedIconconstructor on a fresh object has no handlers); fixed for uniformity of the statement shape rather than on its own evidence.:756build_live_per_core_row— spelling (2).let mut bars = bars_for_bind.borrow_mut();held live acrosscores_row.remove(),cores_row.append(),bar.set_fraction()andbar.set_tooltip_text(). →take()for the whole update, stored back at the end. Side benefit:bars.clear()no longer dropsGtkProgressBars inside the borrow.trollshell/src/widgets/calendar.rs:328rebuild_upcoming_list— spelling (1), acrossgroup.remove(). →take().:331— Form B,borrow_mut().take()withgroup.remove()in the then-block. →RefCell::take().:281on_day_clicked— spelling (3).rows_track.borrow()held acrossflash_row_highlight();rebuild_upcoming_listis theborrow_mut()counterparty. → clone the matched row out, then flash.:408refresh_upcoming_list— spelling (3), and the longest borrow in the sweep:state.events.borrow()passed as a liveRefintorebuild_upcoming_list, i.e. held across the entiregroup.remove()/group.add()rebuild.wire_events_binddoesstate.events.borrow_mut()on the other path. → clone the snapshot (oneVec<CalendarEvent>clone per refresh, negligible next to rebuilding the rows).trollshell/src/widgets/tasks.rs:139rebuild_list— spelling (1), acrossgroup.remove(). →take().:142,:145— Form B ×2 (placeholder_track,overflow_track). →RefCell::take().:415build_create_popover'sconnect_show— spelling (4).sync_list_picker(&picker, &lists_for_show.borrow())holds theRefacross that function's GTK work;wire_lists_bind(:79) is theborrow_mut()counterparty. → clone out first.trollshell/src/panels/connections.rs:67(owned) and:71(others) — spelling (2) ×2. NamedRefMutbindings held from the top of the bind closure past everygroup.remove(),group.add(),other.remove()andother.add_row(), through to the end of the row-building loop. →take()both, rebuild into ownedVecs, store back once at the end.:75(overflow_for_bind) — Form B. →RefCell::take().crates/trollshell-control-center/src/main.rs:427apply_plugins'ssame_setbranch — spelling (3), the strongest-evidence site in this PR.state.by_id.borrow()is held acrossupdate_plugin_row, which ends inprow.row.set_active(…). GObject property notification is synchronous, so the row's ownconnect_active_notifyhandler runs inside that call. It does not panic today only because that handler early-returns on thesyncingguard — exactly the "no known reentrant borrower" standard the fix(overlays): release RefCell borrows before the GTK call that can re-enter them (#631) #644 sites were held to.by_id.borrow_mut()counterparties live at:441and:452. → re-borrow per iteration and clone the handle out (PluginRowgains#[derive(Clone)]; both its fields are refcounted GObjects).:441— the semicolon rule, direct (non-closure) spelling.state.by_id.borrow_mut().insert(id.clone(), prow);drops the displacedOption<PluginRow>— two GTK widgets — inside the borrow. → bind withlet, thendrop. Weakest site here:clear_rowsruns immediately above, so the displaced value isNonetoday. Kept for the same reason fix(overlays): release RefCell borrows before the GTK call that can re-enter them (#631) #644 kept itsinstallcounterpart.:449clear_rows— spelling (1), acrossgroup.remove(). →take().:452clear_rows—state.by_id.borrow_mut().clear()runs eachPluginRow's drop glue (aGtkSwitchRow+ aGtkImage) inside the borrow. →drop(state.by_id.take()), whose borrow is over before the map drops.Beyond the issue's list
The issue named 12 sites across these eight files. Applying the corrected four-spelling definition turned up 10 more in the same files:
stats.rs:654/:661/:756,calendar.rs:281/:408,tasks.rs:415, and control-center:427/:441/:452. All are fixed above and called out individually.Examined and deliberately left alone
Write-backs of the form
*cell.borrow_mut() = new;/.push(x);are not hazards here and were not churned:reactive_list.rs:95,105,power_profile.rs:70,calendar.rs:342,390,tasks.rs:79,156,167,178,connections.rs:109,popup.rs:256, control-center:440,:468.pushreturns(); each assignment replaces a value the same function alreadytake()d, so the value dropped inside the borrow is an emptyVecor aNone, with no drop glue to run. The two write-backs this PR adds (connections.rs,stats.rs'sbars) carry a one-line comment saying exactly that, so the next sweep doesn't have to re-derive it.stats.rs:143—PENDING_SCROLL.with(|m| { m.borrow_mut().insert(monitor_key(monitor), section); });has the trailing semicolon, so the displaced value really does drop inside the borrow — butStatsSectionis a#[derive(Clone, Copy)]fieldless enum with no drop glue, so nothing runs there. The benign side of the semicolon rule; left as-is rather than wrapped.tasks.rs:361—let has_list = !lists_for_changed.borrow().is_empty();on its own line,set_sensitiveon the next. TheRefdies at the end of thelet. This is the counter-example to spelling (4): the same expression inlined into theset_sensitiveargument would be a hazard.tasks.rs:387-393— already the correct idiom, with an explicitdrop(lists);before the GTK calls.stats.rs:1025-1030— borrow confined to an explicit block that closes before theset_textcalls.:1039'sfor &v in window.borrow().iter()holds aRefacross a loop whose body is pure arithmetic.stats.rs:380,stats.rs:641,calendar.rs:302,514,tasks.rs:644-760, control-center:423— borrow confined to a statement or block containing no GTK call.popup.rs:401,408— test assertions.The issue's own ruled-out list is left untouched, and I agree with it: of the two that fall in my files,
components/focus.rs:146aborts a timer and control-center:114aborts aSourceId— no GTK call in either body.Test
One test added, and it is a real falsification rather than a shape assertion:
close_catchers_tolerates_a_reentrant_teardown_from_destroyincrates/hytte-ui/src/popup.rs.GtkWidget::destroyis emitted synchronously from dispose, so aconnect_destroyhandler runs insidewin.destroy(). The test hangs such a handler on a catcher and has it re-enterclose_catcherson the same cell — the exact shape a real re-entrant teardown takes.Falsified against the pre-fix
borrow_mut().drain(..)before restoring it, and the failure mode is the one #631/#643 describe rather than a clean assert:So this also settles, in-tree and reproducibly, the question the rest of the cluster leaves open: the emission is synchronous, and the resulting
BorrowMutErrordoes abort the process rather than unwind. Withtake()the inner call finds an empty vec and returns; all 106hytte-uiunit tests pass.It sits in
hytte-ui's existing#[cfg(all(test, feature = "system-tests"))] mod testsnext toclose_catchers_drains_and_destroys_idempotently, so it runs innix flake check'ssystem-testsbucket underxvfb-run, not in the default hermeticcargo test.No
Waker-based test applies to the other 21 sites. #644'sstd::task::Wakeharness works becausereset_drawer_open_statescallsMutable::set_neqwhile holding the borrow, andfutures-signalsinvokesWaker::wake()synchronously fromnotify— the reentrancy trigger is pure std. None of the sites here touch aMutableinside the borrow: every trigger is a GTK emission (remove,destroy,set_title,set_active). And neithertrollshellnortrollshell-control-centerdefines thesystem-testsfeature or has any display-test harness, so there is nowhere to hang the equivalent for them. "Not hermetically testable" here is a statement about the trigger, not a shrug —popup.rsis the one site whose crate already has the display harness its trigger needs, which is why it is the one that got a test.Gates
cargo clippy --workspace --all-targets --features system-tests -- -D warnings, withRUSTC_WRAPPERunset (sccache serves cached artifacts and skips lint emission on hits, so a wrapped run is a false green) — clean, no warnings.cargo test --workspace(hermetic) — all pass, includingtrollshell's 233 andhytte-services' 672.xvfb-run cargo test -p hytte-ui --features system-tests— 106 + 1 pass; falsified against the pre-fix shape first (SIGABRT above), then restored.nix fmt— 0 changed, and idempotent on a second run. (Usednix fmt, notcargo fmt, so the#[cfg(feature)]-gated blocks are covered.)nix build .#trollshell— pass (which also runs the workspace'sdoChecksuite).Line numbers above are from the pre-change tree, matching the issue; function names are given for each so they stay findable after the diff.