Fix worker object shutdown thread affinity#2248
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts shutdown/teardown ordering for several Qt worker-thread QObjects so they are stopped and destroyed on their owning threads, addressing Qt thread-affinity warnings during application exit.
Changes:
- Update
RadioModelteardown to stop worker objects via blocking queued calls, then scheduledeleteLater()before quitting worker threads. - Update
MainWindowteardown to stop audio/controller resources on their worker threads and usedeleteLater()instead of deleting from the GUI thread. - Apply similar shutdown ordering to spot-client worker objects (DX cluster / RBN / WSJT-X / etc.) before quitting their worker thread.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/models/RadioModel.cpp |
Changes RadioConnection/PanadapterStream shutdown to respect QObject thread affinity by stopping on worker thread and using deleteLater(). |
src/gui/MainWindow.cpp |
Changes shutdown of audio engine, external controllers, and spot clients to stop on worker threads and schedule deletion via deleteLater(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (m_serialPort) { | ||
| m_serialPort->deleteLater(); | ||
| m_serialPort = nullptr; | ||
| } | ||
| if (m_flexControl) { | ||
| m_flexControl->deleteLater(); | ||
| m_flexControl = nullptr; | ||
| } | ||
| #endif | ||
| #ifdef HAVE_MIDI | ||
| if (m_midiControl) { | ||
| m_midiControl->deleteLater(); | ||
| m_midiControl = nullptr; | ||
| } |
There was a problem hiding this comment.
Nulling controller pointers right after deleteLater() can break existing queued invokeMethod() lambdas that reference the member pointer inside the lambda body (e.g., m_serialPort is used inside a queued lambda in the TX state handler). If such a queued lambda executes after these assignments, it can dereference nullptr and crash. Prefer capturing the target object pointer in those lambdas / using method-pointer invokeMethod overloads, or avoid setting these members to nullptr until after the worker thread is fully stopped and no queued work can run.
| }, Qt::BlockingQueuedConnection); | ||
| m_audio->deleteLater(); | ||
| m_audio = nullptr; | ||
| } |
There was a problem hiding this comment.
If m_audioThread is not running here (e.g., thread failed to start or was stopped earlier), m_audio will no longer be destroyed because the previous explicit delete was removed and deleteLater() is only called in the isRunning() branch. Consider adding a safe fallback path to destroy m_audio when the thread isn’t running (or otherwise guarantee the thread is running before relying on deleteLater).
| m_connection->deleteLater(); | ||
| m_connection = nullptr; | ||
| } |
There was a problem hiding this comment.
m_connection is set to nullptr immediately after scheduling deleteLater(). Elsewhere in this file there are queued invokeMethod() lambdas that dereference the member m_connection inside the lambda body (e.g., the reconnect timer path uses invokeMethod(m_connection, [this]{ m_connection->connectToRadio(...); })). If any such queued lambda runs after m_connection is nulled during teardown, it can dereference nullptr. Prefer using the method-pointer overload or capturing a local conn pointer in those lambdas so they don’t depend on the mutable member, or defer nulling until after teardown is complete.
| m_dxCluster = nullptr; | ||
| m_rbnClient->deleteLater(); | ||
| m_rbnClient = nullptr; | ||
| m_wsjtxClient->deleteLater(); | ||
| m_wsjtxClient = nullptr; | ||
| m_spotCollectorClient->deleteLater(); | ||
| m_spotCollectorClient = nullptr; | ||
| m_potaClient->deleteLater(); | ||
| m_potaClient = nullptr; | ||
| #ifdef HAVE_WEBSOCKETS | ||
| m_freedvClient->deleteLater(); | ||
| m_freedvClient = nullptr; |
There was a problem hiding this comment.
Setting spot-client pointers to nullptr immediately after posting deleteLater() is unsafe because there are other queued invokeMethod() calls in this file that dereference the member pointers inside lambdas (e.g., the disconnect handler invokes m_dxCluster->disconnect() inside a queued lambda). If one of those queued lambdas runs after these members are nulled (or during shutdown ordering), it can crash. Prefer capturing the object pointer in the lambda (or using the method-pointer overload of invokeMethod) so the queued work doesn’t depend on mutable member pointers, or postpone nulling until no further queued work can run.
| m_dxCluster = nullptr; | |
| m_rbnClient->deleteLater(); | |
| m_rbnClient = nullptr; | |
| m_wsjtxClient->deleteLater(); | |
| m_wsjtxClient = nullptr; | |
| m_spotCollectorClient->deleteLater(); | |
| m_spotCollectorClient = nullptr; | |
| m_potaClient->deleteLater(); | |
| m_potaClient = nullptr; | |
| #ifdef HAVE_WEBSOCKETS | |
| m_freedvClient->deleteLater(); | |
| m_freedvClient = nullptr; | |
| m_rbnClient->deleteLater(); | |
| m_wsjtxClient->deleteLater(); | |
| m_spotCollectorClient->deleteLater(); | |
| m_potaClient->deleteLater(); | |
| #ifdef HAVE_WEBSOCKETS | |
| m_freedvClient->deleteLater(); |
CI Failure AnalysisHey @rfoust — thanks for the contribution! The CI failure here is not caused by your code. What failed
The actual compilation and tests passed fine. The failure is in the "Cleanup artifacts" post-build job, which deletes temporary workflow artifacts after uploading results. This is a CI infrastructure issue — likely a transient GitHub Actions API error or a permissions/token expiry problem in the cleanup workflow step. What to doNothing on your end. A maintainer can re-run the failed job, or this will resolve on the next push. Your code compiled and tested successfully. Separate note: Copilot review commentsCopilot flagged some potential lifetime issues with Thanks for contributing — your changes look good from CI's perspective! 🎉 |
a0f4652 to
1acda67
Compare
|
Addressed the Copilot teardown feedback in the latest push:
Re-tested with:
|
There was a problem hiding this comment.
Thanks for tackling this, @rfoust — the Qt thread-affinity warnings during shutdown are genuinely annoying and this is the right general approach (stop-on-worker → deleteLater() → quit thread). A few things to address before merging:
1. deleteLater() ordering vs quit() — potential for object leak
In several places the pattern is:
obj->deleteLater();
thread->quit();
thread->wait(3000);deleteLater() posts a QDeferredDeleteEvent to the object's thread event loop. If quit() drains the loop before that event is processed, the deferred delete never fires and the object leaks. The safer Qt idiom is to connect the thread's finished signal to the object's deleteLater() slot, or call quit() first and let the event loop process the deferred delete during wind-down. In practice Qt does process pending deferred deletes during QThread::quit() wind-down in most cases, but the order shown here isn't guaranteed by the Qt docs.
Consider either:
- Connecting
thread->finished→obj->deleteLater()(idiomatic Qt), or - Keeping the explicit
deleteon the main thread afterwait()returns (the thread is dead at that point, so cross-thread destruction is safe for objects whose timers/sockets have already been stopped via the precedingBlockingQueuedConnectioncall — which is exactly what the old code did aftermoveToThread).
2. Copilot's nullptr-after-deleteLater concern is valid
Copilot flagged this correctly in multiple places. For example in RadioModel.cpp, the reconnect-timer lambda at line ~368 does:
QMetaObject::invokeMethod(m_connection, [this] {
m_connection->connectToRadio(m_lastInfo);
});The PR nulls m_connection immediately after deleteLater(). If a queued invocation from the reconnect timer (or any other async path) fires between deleteLater() and actual destruction, it will dereference nullptr. The old code didn't null the pointer until after delete, which was fine since delete was synchronous.
The BlockingQueuedConnection call to disconnectFromRadio should prevent new reconnect attempts, and signals are disconnected at the top of the destructor — so in practice the race window is very small. But it's still a correctness issue. Safest fix: don't null the member pointers at all — the object is about to be destroyed anyway, so the member values are irrelevant after the destructor body completes.
The same applies to the MainWindow destructor nulling of m_serialPort, m_flexControl, m_midiControl, m_hidEncoder, and the closeEvent nulling of m_dxCluster etc.
3. Copilot's fallback-path concern for m_audio is valid
The old code always did delete m_audio unconditionally. The new code only calls deleteLater() inside the isRunning() branch. The else branch correctly adds delete m_audio, which addresses this — good. Just confirming Copilot's concern was handled.
4. The ext-controller block now stops MIDI/HID on the worker thread — nice addition
The old code only did BlockingQueuedConnection for m_serialPort->close(). Adding the same treatment for m_midiControl and m_hidEncoder is a good improvement that goes slightly beyond the stated scope but makes sense here.
5. Minor: duplicate quit()/wait() guard
In both RadioModel.cpp blocks there's a pattern like:
if (m_connThread && m_connThread->isRunning()) {
// ...
m_connThread->quit();
m_connThread->wait(3000);
}
// then immediately:
if (m_connThread && m_connThread->isRunning()) {
m_connThread->quit();
m_connThread->wait(3000);
}The second isRunning() check is only reachable when the first branch wasn't taken (thread not running), so the second block is dead code. It doesn't hurt, but it reads as if it's guarding against something — consider removing it or adding a comment explaining the intent.
Summary
The core idea is correct and welcome. The main actionable items are:
- Don't null member pointers after
deleteLater()— they serve no purpose (object is being destroyed) and create a nullptr race with queued lambdas. - Verify
deleteLater()+quit()ordering — either use thefinished→deleteLater()pattern, or keep synchronousdeleteafterwait()since the blocking stop calls already ensure timers/sockets are inactive.
Happy to re-review after updates. Thanks for the contribution!
Reliability sweep release. Headline is jensenpat's DAX2 coexistence policy refactor (#2271): platform-aware decision table, lazy dax_tx stream creation, anti-stomp on incoming foreign DAX TX status, LAN VITA UDP rebind on Flex's "Port/IP pair already in use" error, dead- orphan DAX RX detection, and 24+ test assertions. Substantial reliability fixes around disconnect teardown (#2247 — sequenced stream remove with response wait, dropped self-disconnect, dying-gasp byte), worker-thread shutdown (#2248 — no more killTimer warnings), and panadapter zoom (#2246 — no spectrum flash). Closes 12 issues via two paired triage-cleanup sweep PRs (#2270 + #2272) covering small UI bugs, contrast, indicator routing, click-to- tune behaviour, and visible regressions from recent feature work. Also includes the #2273 follow-up to #2271: setDax() and DAX TX stream creation are now both gated on platforms that can actually feed DAX audio, so Linux non-PipeWire builds keep digital TX on the physical mic input rather than going silent. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
deleteLater()and quitting those threads.RadioConnectionandPanadapterStreamso their timers/sockets are destroyed with the correct thread affinity.Bug addressed
When closing AetherSDR, Qt could log warnings such as:
These warnings came from worker objects that own timers or socket resources being moved or destroyed from the main GUI thread after their worker thread had been stopped. That teardown order violates Qt object affinity rules and can make shutdown noisy or unreliable.
Correction
The shutdown path now stops each worker object through a blocking queued call while its worker thread is still running, then schedules the object with
deleteLater()so Qt destroys it on the owning thread before the thread quits.Testing
cmake -S /Users/rfoust/.codex/worktrees/4343/AetherSDR-thread-warning -B /private/tmp/aethersdr-thread-warning-build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfocmake --build /private/tmp/aethersdr-thread-warning-buildctest --test-dir /private/tmp/aethersdr-thread-warning-build --output-on-failure