Skip to content

Fix worker object shutdown thread affinity#2248

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-qt-thread-warning
May 1, 2026
Merged

Fix worker object shutdown thread affinity#2248
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-qt-thread-warning

Conversation

@rfoust

@rfoust rfoust commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Delete worker-thread QObjects on their owning threads during shutdown instead of from the GUI thread.
  • Stop controller and spot-client resources on their worker threads before posting deleteLater() and quitting those threads.
  • Apply the same shutdown ordering to RadioConnection and PanadapterStream so their timers/sockets are destroyed with the correct thread affinity.

Bug addressed

When closing AetherSDR, Qt could log warnings such as:

QObject::killTimer: Timers cannot be stopped from another thread
QObject::~QObject: Timers cannot be stopped from another thread
QObject::moveToThread: Current thread is not the object thread

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=RelWithDebInfo
  • cmake --build /private/tmp/aethersdr-thread-warning-build
  • ctest --test-dir /private/tmp/aethersdr-thread-warning-build --output-on-failure

Copilot AI review requested due to automatic review settings May 1, 2026 13:58
@rfoust rfoust requested review from jensenpat and ten9876 as code owners May 1, 2026 13:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 RadioModel teardown to stop worker objects via blocking queued calls, then schedule deleteLater() before quitting worker threads.
  • Update MainWindow teardown to stop audio/controller resources on their worker threads and use deleteLater() 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.

Comment thread src/gui/MainWindow.cpp
Comment on lines +3832 to +3845
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;
}

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/gui/MainWindow.cpp Outdated
Comment on lines 3796 to 3806
}, Qt::BlockingQueuedConnection);
m_audio->deleteLater();
m_audio = nullptr;
}

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment thread src/models/RadioModel.cpp Outdated
Comment on lines 389 to 391
m_connection->deleteLater();
m_connection = nullptr;
}

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/gui/MainWindow.cpp Outdated
Comment on lines +4136 to +4147
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;

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
@aethersdr-agent

Copy link
Copy Markdown
Contributor

CI Failure Analysis

Hey @rfoust — thanks for the contribution! The CI failure here is not caused by your code.

What failed

Job Result
build ✅ passed
check-paths ✅ passed
Prepare ✅ passed
Agent ✅ passed
Upload results ✅ passed
Cleanup artifacts → "Delete artifacts" step ❌ failed
analyze (cpp) ⏳ still running

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 do

Nothing 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 comments

Copilot flagged some potential lifetime issues with deleteLater() + immediate nullptr assignment patterns in MainWindow.cpp and RadioModel.cpp. These are worth reviewing if your PR touches teardown/disconnect paths — queued invokeMethod() lambdas that capture member pointers can dereference nullptr if the member is nulled before the queued call executes. If those comments aren't relevant to your changes in this PR, feel free to ignore them (they may be on adjacent code from #2247).

Thanks for contributing — your changes look good from CI's perspective! 🎉

@rfoust rfoust force-pushed the codex/fix-qt-thread-warning branch from a0f4652 to 1acda67 Compare May 1, 2026 14:14
@rfoust

rfoust commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the Copilot teardown feedback in the latest push:

  • Worker-object lambdas introduced by this PR now capture local object pointers instead of dereferencing mutable member pointers after shutdown begins.
  • Member pointers are no longer nulled immediately after deleteLater(); they are cleared only after the owning worker thread has quit and drained.
  • Added explicit fallback deletion for already-stopped worker threads, including the audio engine case where deleteLater() would otherwise not run.

Re-tested with:

  • cmake --build /private/tmp/aethersdr-thread-warning-build
  • ctest --test-dir /private/tmp/aethersdr-thread-warning-build --output-on-failure

@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.

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->finishedobj->deleteLater() (idiomatic Qt), or
  • Keeping the explicit delete on the main thread after wait() 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 preceding BlockingQueuedConnection call — which is exactly what the old code did after moveToThread).

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:

  1. Don't null member pointers after deleteLater() — they serve no purpose (object is being destroyed) and create a nullptr race with queued lambdas.
  2. Verify deleteLater() + quit() ordering — either use the finisheddeleteLater() pattern, or keep synchronous delete after wait() since the blocking stop calls already ensure timers/sockets are inactive.

Happy to re-review after updates. Thanks for the contribution!

@ten9876 ten9876 merged commit e9ba17f into aethersdr:main May 1, 2026
5 checks passed
ten9876 added a commit that referenced this pull request May 2, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants