fix(automation): defer widget click/toggle out of the socket read callback (crash)#3826
Conversation
…lback (crash) Follow-up to aethersdr#3819. invoke <target> click (and toggle) ran QAbstractButton::click() synchronously inside the QLocalSocket read callback. A QToolButton with a dropdown opens its popup menu on click (popupTimerDone -> QMenu::exec -> QMenu::popup), a NESTED event loop; running it inside the CFSocket read callback re-enters socket/window state and segfaults — observed EXC_BAD_ACCESS in QWindow::geometry() on a null window while the app was backgrounded. aethersdr#3819 deferred the menu-ACTION trigger path; this is its latent sibling on the widget click/toggle path. Defer both to a clean main-loop turn via QTimer::singleShot(0) so any nested popup/dialog loop runs on a normal stack. The response returns {deferred:true}; state setters (setValue/setText/setChecked/combo/submit) stay synchronous and keep their newValue round-trip. Verified: clicking the menu-bar overflow popup button (qt_menubar_ext_button) repeatedly on a backgrounded instance returns in <2 ms, the bridge stays responsive, and the app survives with no crash report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the single-file change against the existing code in AutomationServer.cpp. This looks good — clean, minimal, and correct.
What's right:
- The fix faithfully mirrors the already-merged #3819 menu-action deferral (
AutomationServer.cpp:1458-1462): sameQTimer::singleShot(0, qApp, …)+QPointerguard pattern, samedeferred:trueresponse contract.QTimer/QPointer/qAppare already in use on that path, so no new includes are needed. The "click/toggle was the latent sibling of the menu-action path" framing is accurate. - Behavior for the
toggleverb on a non-checkable button is preserved:useToggle = (action=="toggle" && b->isCheckable())falls through toclick(), matching the oldb->isCheckable() ? b->toggle() : b->click(). - The safety rails — disabled-control refusal (
:1515), TX-keying guard (:1526), and power-ceiling clamp — all run before the deferral, so deferring the click doesn't bypass any of them. Good. - The QPointer capture means a slice/dialog torn down between scheduling and the timer firing is handled safely (
if (!bg) return;).
One non-blocking note: the response contract now changes for every click/toggle, not just QToolButton-with-menu cases — a plain push-button that never opens a popup also returns {deferred:true} with no newValue. That's the right call for safety (the callback can't know in advance whether a slot will spin a nested loop, and it matches the menu path exactly), but it does mean any automation script that read newValue off a click reply must now re-read via get/dumpTree. The PR body documents this, so just flagging it for callers.
All six CI checks pass (build, macOS/Windows, CodeQL, a11y). Nice, surgical follow-up — thanks @jensenpat. 🎯
🤖 aethersdr-agent · cost: $3.7535 · model: claude-opus-4-8
… driving popups Headless automation runs the app backgrounded; a native QMenu popped as a side effect of a driven click/trigger can segfault in QWindow::geometry() on a null window (e.g. a QToolButton dropdown, or an AX.25/APRS dialog menu popped from a click handler). Activating the target window first gives Cocoa a valid window to anchor the popup to — verified to foreground a backgrounded instance. Because activateWindow() really does foreground the app, doing it on every driven click would repeatedly steal focus during a sweep. So it is gated behind AETHER_AUTOMATION_RAISE (default OFF): enable it when driving flows that pop native menus from a backgrounded instance; otherwise the deferred click/trigger path runs unchanged with no focus-steal. Applied on the deferred click/toggle and menu-action trigger paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Added a second commit: |
|
Single-file change, 74 additions to Root cause and fix are accurate. Three correctness points worth noting:
Second commit ( CI: All six checks green including Constitution: Principle XI (Fixes Are Demonstrated) and Principle VIII (Evidence Over Assertion) both honored — real stack trace diagnosis, live-verified fix. For future reference (not blocking):
Merge eligible — Tier 3 only ( |
NF0T
left a comment
There was a problem hiding this comment.
Root cause confirmed from stack trace; fix mirrors the QTimer::singleShot(0) + QPointer pattern already established by the merged #3819 menu-action path — the widget click/toggle path was its latent sibling. Safety rails (disabled-control refusal, TX-keying guard) verified to run before the deferral. AETHER_AUTOMATION_RAISE companion correctly opt-in and focus-steal-guarded. All six CI checks pass including check-macos. Live verification in the PR body covers what CI cannot reproduce automatically.
Merge when ready. Squash subject: fix(automation): defer widget click/toggle out of the socket read callback — Principle XI. (#3826)
Summary
Follow-up crash fix to #3819. A bridge
invoke <target> clickcould segfault the app.invoke <target> click(andtoggle) ranQAbstractButton::click()synchronously inside theQLocalSocketread callback. When the target is aQToolButtonwith a dropdown menu, clicking it opens that menu's popup loop (popupTimerDone→QMenu::exec→QMenu::popup) — a nested event loop — right inside the CFSocket read callback. That re-enters socket/window state and crashes:(The app was
Role: Backgroundat the time, which makes the popup's native window resolution especially fragile.)Root cause & fix
This is the latent sibling of the bug #3819 already fixed: that PR deferred the menu-action trigger path out of the socket callback; the widget click/toggle path was left synchronous and opens popups/dialogs the same way.
Fix: defer
click/toggleto a clean main-loop turn viaQTimer::singleShot(0, qApp, …)(QPointer-guarded), so any nested popup/dialog loop runs on a normal stack — never inside socket I/O. The response returns{deferred:true}; state setters (setValue/setText/setChecked/setCurrentText/setCurrentIndex/submit) stay synchronous and keep theirnewValueround-trip.Verification
Built on
main, launched a backgrounded automation instance, and clicked the menu-bar overflow popup button (qt_menubar_ext_button) repeatedly:invoke … click→{deferred:true}in <2 ms (was: nested popup → crash)pingimmediate)Note
The general principle now holds across both paths: a bridge verb must never run a nested event loop (modal dialog / popup menu) synchronously inside the socket read callback. If a state-setter's slot is ever found to open a modal, the same one-line deferral applies; an alternative is to dispatch all command handling off the socket callback entirely (kept out of scope here to preserve the synchronous
newValuemodel).💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat