Skip to content

fix(gui): guard slice DAX-recall singleShot against dangling slice (UAF crash)#3733

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/slice-dax-recall-uaf
Jun 23, 2026
Merged

fix(gui): guard slice DAX-recall singleShot against dangling slice (UAF crash)#3733
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/slice-dax-recall-uaf

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

What

Fixes a use-after-free crash (EXC_BAD_ACCESS / SIGSEGV) when a slice is
removed within ~300 ms of being added. Closes #3732.

MainWindow::onSliceAdded() arms a 300 ms QTimer::singleShot to restore a
slice's saved DAX channel (#1221), capturing the slice by raw pointer:

QTimer::singleShot(300, this, [this, s, savedDax]() {   // s = raw SliceModel*
    if (s && !profileLoadRadioStateWritesHeld())
        s->setDaxChannel(savedDax);
});

The if (s && …) guard is ineffective — a raw pointer does not go null when
the pointee is destroyed — and the timer's context object is the long-lived
MainWindow, so it is not auto-cancelled when the slice goes away. Removing
the slice inside the 300 ms window fires the timer on freed memory:

QTimer → setDaxChannel() → sendCommand() → emit commandReady()
       → QtCore doActivate()   ← SIGSEGV on the dangling sender

Why it matters

This is reachable on ordinary paths, not just automation: rapid slice
add/remove, profile recall that recreates slices, and band-stack restore can
all destroy a slice within 300 ms of creating it. The timer arms whenever the
slice index maps to a saved DaxChannel_Slice{A,B,…} > 0, so users who have
ever assigned a DAX channel are the most exposed.

The fix

Capture a QPointer<SliceModel> so the existing guard actually nulls out on
destruction — the same dangling-object pattern already used elsewhere in this
TU (#381). One file, +9/-3.

-                QTimer::singleShot(300, this, [this, s, savedDax]() {
-                    if (s && !profileLoadRadioStateWritesHeld()) {
-                        s->setDaxChannel(savedDax);
-                    }
-                });
+                QPointer<SliceModel> sp(s);
+                QTimer::singleShot(300, this, [this, sp, savedDax]() {
+                    if (sp && !profileLoadRadioStateWritesHeld()) {
+                        sp->setDaxChannel(savedDax);
+                    }
+                });

Testing

Reproduced live on a FLEX-8400M, then verified the fix:

Discovery

Found while exercising the slice open/close/reopen lifecycle through the agent
automation bridge — a concrete example of agent-driven testing surfacing a real
pre-existing crash that manual clicking rarely hits because it requires
sub-300 ms timing.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…AF crash)

onSliceAdded() schedules a 300 ms QTimer::singleShot to restore a slice's
saved DAX channel (aethersdr#1221). The lambda captured the SliceModel by raw pointer
and guarded with `if (s && ...)` — but a raw pointer does not become null when
the QObject is destroyed, so the guard was ineffective. The timer's context
object is MainWindow (long-lived), so the timer is not auto-cancelled when the
slice goes away.

Removing a slice within 300 ms of adding it (rapid add/remove, e.g. profile
churn or fast lifecycle automation) therefore fired the timer on a freed
SliceModel: setDaxChannel() -> sendCommand() -> emit commandReady() segfaulted
in doActivate() on the dangling sender. Confirmed live via the automation
bridge — a reopen at T=0 removed at T+172ms crashed when the timer fired at
~T+300ms (EXC_BAD_ACCESS).

Capture a QPointer<SliceModel> instead, matching the existing dangling-widget
guards in this TU (aethersdr#381). Regression-tested: 6x add/remove at 120 ms dwell plus
a full open/tune/handoff/close/reopen lifecycle now run with no crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jensenpat jensenpat marked this pull request as ready for review June 22, 2026 04:27
@jensenpat jensenpat requested a review from a team as a code owner June 22, 2026 04:27

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against main. The diagnosis and fix are both correct.

Verified:

  • The pre-change code matches the described bug exactly (MainWindow_Wiring.cpp:261-265): the singleShot captures s as a raw SliceModel*, the timer's context object is the long-lived MainWindow, and the if (s && …) guard cannot detect a freed pointee. Removing the slice inside the 300 ms window does fire setDaxChannel() on dangling memory.
  • s is a SliceModel* QObject, so QPointer<SliceModel> is valid; capturing the QPointer by value and re-checking if (sp && …) makes the existing guard actually do its job. Behavior is unchanged when the slice survives.
  • The QPointer-guard-against-dangling-object pattern is already established in this same TU (SpectrumWidget at lines 1000/1605, QTimer at 1679), so this is consistent with house style.
  • The profileLoadRadioStateWritesHeld() skip and surrounding logic are preserved.

Scope is tight (+9/−3, one file), no convention concerns, and the nearby singleShot at line 2444 captures only a panId value so it's unaffected.

Nice catch — this is exactly the right minimal fix, and the agent-driven sub-300 ms reproduction is a good example of surfacing a timing-dependent UAF that manual testing rarely hits. Thanks @jensenpat. (Non-blocking: the same DaxChannel_Slice%1 deferred-restore shape is the kind of thing worth a grep for other long-lived-context singleShots capturing slice/model raw pointers, but nothing else jumped out in this file.)


🤖 aethersdr-agent · cost: $2.7748 · model: claude-opus-4-8

@K5PTB

K5PTB commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

@jensenpat this also fixes #3725. I opened it yesterday when I encountered the bug under similar circumstances, but hadn't yet circled back to fix it.

@jensenpat

Copy link
Copy Markdown
Collaborator Author

@jensenpat this also fixes #3725. I opened it yesterday when I encountered the bug under similar circumstances, but hadn't yet circled back to fix it.

Thanks Paul for the eyes on this, I'll add to the PR and it should auto close.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — textbook UAF fix, verified. The raw SliceModel* capture in the 300 ms DAX-recall singleShot (long-lived MainWindow context, so not auto-cancelled) is correctly switched to QPointer<SliceModel> so the existing if (sp && …) guard actually nulls on destruction. Confirmed: SliceModel is a QObject (QPointer valid); the pattern is already house style in this TU (lines 1012/1621/1695); behavior unchanged when the slice survives. Real crash on ordinary paths (rapid add/remove, profile recall, band-stack restore), reproduced + fixed on hardware, CI green. Also fixes #3725 (per @K5PTB). Thanks @jensenpat.

@ten9876 ten9876 merged commit 3508226 into aethersdr:main Jun 23, 2026
6 checks 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

3 participants