fix(gui): stop Radio Setup nav relayout from crashing macOS accessibility queries. Principle XI. - #5838
Conversation
|
Following up on the triage note on #5837, which asked me to confirm the flush is reached on real state changes and not only on the no-op path. I ran that check on the PR head ( Setup: patched build against Qt 6.8.3, macOS 27.0 (26A428), FLEX-8600 (GPS locked), run under Steady state (the crash path): over a 60 s idle window, Real state changes:
Each of those real changes went through the new flush path with an accessibility poller running and the tree focused, and nothing crashed. Not covered:
|
There was a problem hiding this comment.
1. Issue fit
Yes, for the surface the issue names. #5837's root-cause chain is: updateRadioCapabilityVisibility() fires on every GPS/oscillator/capability/connection message → setHidden() → doDelayedItemsLayout() → a layout is left pending almost permanently → an AXFocusedUIElement query runs it re-entrantly from inside QAccessibleTableCell::rect() → TableModelChanged frees the running cell → null viewport(). The diff attacks link 2/3: no-op when unchanged, and flush synchronously when it does change so nothing is left pending for the accessibility path to execute. I read all 13 setHidden sites in RadioSetupDialog.cpp at head — every one now routes through setNavigationItemHidden(), with none missed (grep -n setHidden src/gui/RadioSetupDialog.cpp shows only the one inside the helper at :287).
I could not reproduce the crash or the fix: headless, no build, no macOS accessibility client. Everything below is reasoned from source, not observed running. The issue's own evidence — six identical .ips reports, an lldb breakpoint on deleteAccessibleInterface, and a 25 s AXFocusedUIElement poll-while-clicking stress run after the patch — is the strongest evidence here and I cannot independently confirm any of it.
Test coverage: none added, and I am not asking for it. There is no deterministic socket-free seam for "a pending QTreeView layout is executed re-entrantly from an AppKit accessibility query" — that needs a real QTreeView, a real focus, and a real AXFocusedUIElement request on macOS. Per AGENTS.md's test-layer boundary this lands in the GUI/platform layer that has no registered lane. The reporter's stress run is the right evidence for this class. Noting it as a documented coverage boundary, not a nit.
2. Scope
| File / group | What it changes | Claimed by title/body? | Verdict |
|---|---|---|---|
src/gui/RadioSetupDialog.cpp — new static setNavigationItemHidden() (+ 15-line comment) |
Adds the guard-and-flush helper | Yes | In scope |
src/gui/RadioSetupDialog.cpp — 9 call-site conversions (ctor ×3, connect lambdas ×3, search lambda ×2, updateRadioCapabilityVisibility ×2) |
Routes existing setHidden() through the helper |
Yes | In scope |
| Reindentation at :1138 and :1147 | Whitespace to fit the new call | Implied | In scope |
Everything in the diff is explained by the issue. One file, no new public surface, no settings key, no capability field, no default changed, no CHANGELOG edit, no removed guard, no deleted comment naming a symptom. I diffed the two reformatted boolean expressions token-by-token against the base — !isCapabilityPageAvailable(item) || (!needle.isEmpty() && !haystack.contains(needle, Qt::CaseInsensitive)) is byte-identical in meaning; only leading whitespace moved.
No socket test added, modified, or removed — nothing in this diff touches tests/.
3. Blockers
None.
4. Nits (non-blocking)
- The same defect is live in
NetworkDiagnosticsDialog, and there on a 1 Hz timer.NetworkDiagnosticsDialog::refresh()is wired tom_refreshTimerstarted atm_refreshTimer.start(1000)(NetworkDiagnosticsDialog.cpp:1002-1003) and callsm_digitalVoiceWaveformNavigationItem->setHidden(!waveformSeen)unconditionally at:2561. On any session without digital-voice waveform telemetry — the common case — that issetHidden(true)on an already-hidden row of an identical navigationQTreeWidget, once per second, for as long as the dialog is open. If the PR's mechanism is right, that dialog has a layout pending essentially 100% of the time, which is a stronger version of the Radio Setup condition, not a weaker one.:977/:983(search filter) andThemeEditorDialog.cpp:987are the same pattern on keystroke cadence. I am not asking to fix them here — #5837 is about Radio Setup and unbundling is correct — but the PR reads as "fixed" while a sibling surface still carries it. Worth a follow-up issue so the fix does not have to be rediscovered from a second crash report. See inline. - Flushing per-row turns one coalesced layout into N synchronous ones. Before this change, N rows flipping in one handler scheduled N delayed layouts that Qt coalesced into exactly one at the next event-loop turn. After, each flip runs a full
doItemsLayout()inline. The search-filter lambda is the sharp case: the tree has 19 pages + 4 categories, and the first typed character hides most of them, so onetextChangedcan now run ~20 full layouts, each emittingQAccessible::TableModelChangedand tearing down/rebuilding every cell interface. That is more accessibility-interface churn than before, on the exact mechanism that is the crash — safe now because it is on our own stack, but it is not free. A dirty-flag + single flush at the end of each handler keeps the fix and drops the churn back to one. See inline. - The body says
setHidden()"schedules a delayed layout even when the value is unchanged." I could not check this: no Qt sources on this machine to readQTreeView::setRowHidden. My recollection is that the unhide branch early-returns when the row is not inhiddenIndexes, so the unconditional scheduling would hold only for hidden→hidden. That does not affect the fix (the hidden rows are exactly the capability-gated ones cycling on status messages), but the claim is stated more broadly than I could verify.
5. What I tried to break
- Looked for the call site left behind.
grep -n setHiddenon the head checkout returns 13 hits in the file; 12 aresetNavigationItemHiddencalls and the 13th is inside the helper. None missed. Then widened the grep repo-wide, which is where theNetworkDiagnosticsDialogsibling above came from. - Tried to desync the guard from the setter.
item->isHidden()reads the row's own hidden flag viaisRowHidden(row, parent), the same statesetHidden()writes — not effective visibility — so a collapsed or hidden parent cannot make the guard skip a real state change. The null-item and no-tree-yet paths both degrade to a harmless no-op. - Attacked the reindented booleans at :1138 and :1147. Compared against base token-by-token;
||/&&precedence and operand order are unchanged. This was the most likely place for a silent logic change to hide, and it is clean. - Checked reentrancy of the new synchronous flush. Traced every caller: constructor, three
connectlambdas on model signals, thetextChangedsearch lambda, andupdateRadioCapabilityVisibility(). None is reachable from inside an accessibility query, sodoItemsLayout()never runs on the stack the crash needs. Also confirmedcategory->setExpanded(true)at :916 early-returns when already expanded, so it does not re-dirty the tree behind the flush. - Checked the construction-order path. Lines 799/814/829 run while the tree is still being populated —
doItemsLayout()there lays out a partial tree that lateraddPage()calls re-lay out. Harmless, just three extra layouts at dialog construction. - CI: all 5 checks green on
01a0e45(build, Static checks, check-macos, check-windows, Sanitizer option configures). Green here means it compiles on three platforms; no check exercises this code path, so it is not evidence about the fix.
6. Recommendation
Approve with nits. Small, single-file, every call site converted, no logic drift in the reformatted expressions, and the root-cause analysis in #5837 is unusually well-evidenced (symbolicated lldb trace, six matching crash reports, a stress run that reproduces before and not after). The two nits are a follow-up issue for the NetworkDiagnosticsDialog sibling and an optional batch-the-flush refactor — neither should hold the merge, since the crash is on a priority: high macOS path and this removes the window for it. Concrete next step: a maintainer merges after the signing fix below, and opens a follow-up for the NetworkDiagnosticsDialog:2561 sibling.
Nice find, and thank you for the trace — the deleteAccessibleInterface breakpoint is what makes this reviewable at all.
One more thing: commit signing
main requires verified signatures, and the 1 commit on this branch is unsigned — routine setup, not a code problem.
Quickest setup (SSH key signing, no GPG needed):
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true(If you have no SSH key: ssh-keygen -t ed25519 first.) Then on GitHub: Settings → SSH and GPG keys → New SSH key → set the key type dropdown to Signing Key → paste the .pub.
Re-sign the commit already on this branch:
git rebase main --exec "git commit --amend --no-edit -n -S"
git push --force-with-leaseFor GPG or troubleshooting: https://docs.github.com/authentication/managing-commit-signature-verification
🤖 aethersdr-agent · cost: $6.3337 · model: claude-opus-5
01a0e45 to
55cba3a
Compare
|
Thanks for the review. Pushed an update ( Batching (the flush-per-row nit): taken.
"Even when unchanged" wording: you were right to flag it. I could not read Qt's source either, and I don't know which direction each of those calls went. The PR description and the code comment now say One thing I noticed while re-testing: with the poller running, some Update: the commit is now OpenPGP-signed and GitHub shows it as Verified (head |
…lity queries. Principle XI. QTreeWidgetItem::setHidden() can schedule a delayed QTreeView layout, and updateRadioCapabilityVisibility() runs on every GPS / oscillator / capability / connection status message, re-hiding rows that are already hidden. The Radio Setup navigation tree therefore nearly always had a layout pending on a connected radio. When macOS asked for the focused element, QAccessibleTableCell::rect() -> QTreeView::visualRect() ran that pending doItemsLayout() from inside the cell's own method. The layout emits QAccessible::TableModelChanged, which frees every accessible cell including the running one, and the next view->viewport() dereferenced null (Qt 6.8.3, SIGSEGV at 0x8). Route every setHidden() in the dialog through setNavigationItemHidden(), which does nothing when the state is unchanged and reports whether it changed anything. Each handler that can flip rows then calls settleNavigationLayout() once, so a layout is settled on our own call stack once per handler rather than once per row. Fixes aethersdr#5837. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
55cba3a to
358cc87
Compare
NF0T
left a comment
There was a problem hiding this comment.
Thanks @w5jwp. This is a well-diagnosed fix, and you changed it in the direction the review asked and then measured the result. I reviewed head 358cc877 (signed, Verified) and tried to break it.
What's right, and how I checked it
The mechanism holds against Qt's own source, not just the crash trace. Qt 6.8.3, qtreeview.cpp:
QTreeView::setRowHidden()ends with an unconditionald->doDelayedItemsLayout(). TheisPersistent()early-out only guards thehiddenIndexes.remove(), not the scheduling. SosetHidden()posts a layout in both directions, hidden→hidden and visible→visible.QTreeView::doItemsLayout()→QAbstractItemView::doItemsLayout()→interruptDelayedItemsLayout()stops the timer and clearsdelayedPendingLayout. SosettleNavigationLayout()really does leave nothing pending.QTreeWidgetItem::isHidden()reads the view's ownhiddenIndexesentry for that row, not effective visibility. A hidden parent cannot make theisHidden() == hiddenguard skip a real change.QTreeView::expand()returns early when the index is already expanded. This closes thesetExpanded(true)question from the triage note.
I also measured it with a standalone probe (a QTreeWidget subclass counting doItemsLayout() calls, using your two helpers verbatim; Qt 6.11.2, Windows, offscreen; 4 categories × 5 rows):
| Case | Layouts |
|---|---|
| Nothing touched (control) | 0 |
Pre-PR: setHidden(true) on an already-hidden row |
1 posted |
Pre-PR: setHidden(false) on an already-visible row |
1 posted |
Pre-PR: 20× same-state setHidden |
1 (coalesced) |
| PR: 20× guarded same-state + settle | 0 |
| PR: 10 rows flipped in one handler | 1 synchronous, 0 more after the event loop (no leftover pending layout) |
| Pre-PR: same 10-row flip | 0 synchronous, 1 posted |
setExpanded(true) on an expanded category, and on a hidden+expanded one |
0 |
For the re-entrancy step, I took a QAccessibleTableCell id, dirtied the tree, then re-resolved the id and called state()/rect(), as a native client would. With the pre-PR pattern the pending layout ran inside the cell's state() (1 layout observed after the query). With the guard it did not (0). On Qt 6.11.2 the cell interface was not freed, so I could not reproduce the crash itself here (no AppKit, and a newer Qt). The macOS crash and the before/after lldb results rest on your evidence, which I have not independently confirmed.
Coverage: all 10 setHidden sites in the file now go through the helper. grep at head finds no other setHidden. updateRadioCapabilityVisibility() has no early return before its settle, and nothing else in the GPS-tick path mutates the navigation tree. The reformatted boolean expressions are equivalent to base. The merge is clean against current main (8f06ac87).
Scope
| File / group | Claimed by title/body? | Verdict |
|---|---|---|
RadioSetupDialog.cpp: two new file-static helpers plus mechanism comment |
Yes | In scope |
RadioSetupDialog.cpp: 10 call-site conversions, 5 settle calls |
Yes | In scope |
One file, one commit, no new public surface, no settings key, no default changed, no CHANGELOG edit, no deleted guard. This is a fix, not a preference change: the same rows show and hide under the same conditions. NetworkDiagnosticsDialog and ThemeEditorDialog were kept out and filed as #5839.
CI and process
- All 5 checks are green on the current head, and
ci.ymlruns onpull_request, so it tests the merge result. Nothing exercises this path (the crash needs an AppKit focus query), so green here means "compiles on three platforms", not "fixed". I don't count that against the PR. Fixes #5837is present, the commit is signed, and the PR title cites Principle XI. I read XI: it locates the demonstration in the squash-merge process, so the citation is right regardless of the PR-body demonstration.- No regression test, and I'm not asking for one. The crash needs a real
QTreeView, real focus and an AppKit request. See the note below on a seam for #5839. - The formal review from
aethersdr-agentwas written against01a0e453. Both of its non-blocking nits (flush-per-row, and the sibling dialogs) are addressed in the current head. - Code owners: only
src/gui/RadioSetupDialog.cppis touched, which is Tier 3 (@aethersdr/reviewers).
Verdict: approve, no blockers
Forward guidance (not blocking, and not moved goalposts)
- For #5839: lifting the helpers into a header makes a small socket-free test possible: a
QTreeWidgetsubclass that countsdoItemsLayout(), asserting 0 layouts on repeated same-state calls and exactly 1 synchronous layout per changed handler. My probe is about 60 lines of this shape, and I'm happy to attach it. It would pin the guard without needing macOS. - A correction to the #5839 triage note, which also matters for its design: it says the early return is on the unhide branch and that un-hiding a non-hidden row doesn't schedule a layout. In 6.8.3 both directions schedule (see above, and the probe row for
setHidden(false)on a visible row). The guard is therefore needed on both directions, not just hide. - Your wording is right. "can schedule" is what the source supports. Keep it.
Questions, where you have context I lack
- On the
-25212(kAXErrorNoValue) results: when the search filter hides the selected row, does focus land somewhere sensible afterward? The tree keeps focus but has no current cell. That was true before this PR too, so I'm asking only whether it's worth a note in #5839. - You couldn't flip Filters/SmartLink/GPS on your radio. If someone with a GPSDO or a different family can, that would close the one "Not covered" item, but I would not hold the merge for it.
What I tried to break
- The claim that unchanged
setHidden()posts a layout: measured, both directions. - The claim that a per-handler
doItemsLayout()leaves nothing pending: measured, plus Qt source forinterruptDelayedItemsLayout(). - A hidden parent making the
isHidden()guard skip a real flip: readQTreeWidgetItem::isHidden(), which is own-row state. - A site left behind, and other tree mutations in the GPS-tick path:
grepof the file at head. - The reformatted boolean expressions: compared against base.
- Re-entrancy with the old pattern versus the guard: reproduced (layout runs inside
state()), crash not reproduced (Qt 6.11.2 on Windows). - "No behavior change", driven against the app: I built the merge base (
d3743a9b) and this head (358cc877) on Windows (MinGW, Qt 6.11.2,ENABLE_ASR=OFF; the two binaries differ only inRadioSetupDialog.cpp). I ran both offscreen against the demo simulator (DEMO-0001, isolated settings,AETHER_AUTOMATION_NO_TX=1) and drove the same script through the bridge over a socket. The script opens Radio Setup, searchesaudio,gps,calib, a no-match string andfilter, clears the search, disconnects, searches and clears again, reconnects, and searchesgpsand clears again. I grabbedradioSetupNavigationat each of the 13 states. All 13 screenshots are byte-identical between base and head. The comparison does discriminate: the states differ from each other (connected vs disconnected shows Filters and GPS rows appearing and disappearing, so those flips go throughupdateRadioCapabilityVisibility()), and clearing the search returns to the initial image on both builds. - Not done: the macOS crash and the Qt 6.8.3 behaviour are not verified (nothing here reproduces an AppKit focus query). I did not count layouts inside the real app, only in the standalone probe. The demo backend only exercises one capability set.
Suggested squash subject: fix(gui): stop Radio Setup nav relayout from crashing macOS accessibility queries. Principle XI. (#5838)
Nice find, and thanks for measuring rather than asserting.
73,
Ryan NF0T
👨🏼💻 Co-authored by Claude Sonnet 5
Summary
Fixes #5837
On macOS, selecting a page in Radio Setup crashes the app (
SIGSEGVat0x8inQAbstractScrollArea::viewport(), reached fromQAccessibleTableCell::rect()←state()←QCocoaAccessible::shouldBeIgnored) when an accessibility client asks for the app's focused element. Full root-cause trace is in the issue.Short version:
RadioSetupDialog::updateRadioCapabilityVisibility()runs on every GPS / oscillator / capability / connection status message and calledQTreeWidgetItem::setHidden()on navigation rows even when nothing changed.setHidden()→QTreeView::setRowHidden()can schedule a delayedQTreeViewlayout, so on a connected FLEX (GPS status arrives about once a second) the navigation tree almost always had a layout pending. When macOS then queries the focused cell,QAccessibleTableCell::rect()→QTreeView::visualRect()runs that pendingdoItemsLayout()from inside the cell's own method; the layout emitsQAccessible::TableModelChanged, which frees every accessible cell including the running one, and the nextview->viewport()dereferences null (Qt 6.8.3).The change, all in
RadioSetupDialog.cpp:setNavigationItemHidden()does nothing when the row's hidden state is unchanged (the steady-state case that was leaving the layout pending) and returns whether it changed anything.settleNavigationLayout()runsdoItemsLayout()once per handler, and only if a row changed, on our own call stack, so no accessibility query can find a layout pending. It is called at the end of the handlers that can flip rows:updateRadioCapabilityVisibility(), the search filter, and the threeconnectionStateChanged/apdStateChangedlambdas.All ten
setHidden()calls in the dialog (Calibration, Droop Correction, APD, Filters, SmartLink, GPS, and the search filter) go through the helper. No behavior change is intended: the same rows are shown and hidden in the same conditions.This works around a Qt bug rather than fixing it; the defect itself (a layout run re-entrantly from inside
QAccessibleTableCell::rect()) should be reported upstream. Other item views that can have a layout pending when a focus query arrives are not fixed here; the same pattern appears inNetworkDiagnosticsDialogandThemeEditorDialog(follow-up: #5839).Approach notes:
currentItemChangedhandler did not fix the crash, because the next GPS status message dirtied the tree again. It is not in this PR.Constitution principle honored
Principle XI — Fixes Are Demonstrated. The crash was reproduced on demand under
lldbon unpatchedmain, the same repro was run on the patched build, and the failure mode of the rejected first attempt is documented above.Test plan
cmake --build build --target AetherSDR, Qt 6.8.3 arm64, macOS 27.0). Only theAetherSDRtarget was built locally; CI ran the rest.No regression test is added. The crash needs a real
QTreeView, real focus and a realAXFocusedUIElementrequest on macOS, which the socket-free test layer cannot express.Verification detail (real clicks,
lldbbreakpoints onQTreeWidgetItem::setHidden,QTreeView::doItemsLayoutandRadioModel::gpsStatusChanged, tree focused, a separate process pollingAXFocusedUIElementevery 20 ms):main(d3743a9): crashes on every attempt (Network click → Audio click), stack as above, confirmed with symbolicated Qt frames.gpsStatusChangedemissions, 0setHiddencalls and 0doItemsLayoutcalls.setHiddenand one layout.-25212(kAXErrorNoValue, "no focused element"), starting when the search filter hid the currently selected row. I read that as expected, since a filtered-out row has no focused cell, and not as a hang or crash, but I only printed the first dozen codes.Not covered:
updateRadioCapabilityVisibility()'s own call sites (for example a GPSDO appearing): on this radio the Filters, SmartLink and GPS rows never changed state. Disconnect and reconnect exercised the helper through theconnectionStateChangedlambda instead, and I did not record which row that toggled.Checklist
docs/COMMIT-SIGNING.md) — OpenPGP-signed; GitHub shows the commit as VerifiedAppSettingscalls — none touched (Principle V)MeterSmoother— n/a, no meter UI touchedCHANGELOG.mdnot touched🤖 Generated with Claude Code