Skip to content

fix(win): prevent process lingering in Task Manager after close#2802

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/win-process-lingers-after-close
May 17, 2026
Merged

fix(win): prevent process lingering in Task Manager after close#2802
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/win-process-lingers-after-close

Conversation

@chibondking
Copy link
Copy Markdown
Collaborator

On Windows 11, the AetherSDR process could remain visible in Task Manager indefinitely after the main window was closed. Three independent root causes were identified.

──────────────────────────────────────────────────────────────────────────── Root cause 1 — AetherialAudioStrip blocks quitOnLastWindowClosed ──────────────────────────────────────────────────────────────────────────── m_aetherialStrip is created with parent=MainWindow but then given the Qt::Window flag so it appears as an independent taskbar entry. Qt 6 treats any widget carrying Qt::Window as a top-level window for the purpose of QApplication::quitOnLastWindowClosed. The attribute WA_QuitOnClose defaults to true on every widget.

Consequence: if the strip is visible when the user closes the main window, QApplication sees a second visible top-level window with WA_QuitOnClose set and refuses to emit lastWindowClosed. app.exec() never returns, ~MainWindow() never runs, and the process hangs forever.

Fix: set WA_QuitOnClose = false on m_aetherialStrip immediately after the Qt::Window flag is applied. The strip is a secondary, inspector-style window and must not control application lifetime.

──────────────────────────────────────────────────────────────────────────── Root cause 2 — floated applet-panel window lacks WA_QuitOnClose = false ──────────────────────────────────────────────────────────────────────────── m_appletPanelFloatWindow is created with parent = nullptr (a genuine top-level window). WA_QuitOnClose was never set to false on it, so if a close-event ordering quirk (e.g. the float window's native WM_DESTROY arrives before MainWindow's hide completes) caused it to be the apparent "last window", Qt could fail to call quit(). FloatingContainerWindow already sets this attribute; the applet-panel float window was the only top-level secondary window that did not.

Fix: add setAttribute(Qt::WA_QuitOnClose, false) alongside the existing WA_DeleteOnClose = false in floatAppletPanel(). This matches the pattern used by all other secondary/floating windows in the codebase.

──────────────────────────────────────────────────────────────────────────── Root cause 3 — MqttClient destroys mosquitto while loop thread still runs ──────────────────────────────────────────────────────────────────────────── MqttClient::disconnect() contains a #ifndef Q_OS_WIN guard that skips mosquitto_loop_stop() on Windows. The guard was presumably added to avoid a deadlock in the force=false (graceful) stop path, where the loop thread tries to deliver a callback to the Qt main thread while the main thread is already blocked waiting for the stop to complete.

The destructor calls disconnect() (which on Windows leaves the background network thread running) and then immediately calls mosquitto_destroy(). The libmosquitto documentation explicitly states that mosquitto_loop_stop() must be called before mosquitto_destroy(). Violating this contract on Windows can leave the thread in an undefined state — holding a WINSOCK handle or a Windows timer — that prevents ExitProcess() from completing cleanly (or at all).

Fix: add mosquitto_loop_stop(m_mosq, true) (force=true, safe on any thread) between the disconnect() call and mosquitto_destroy() in the destructor. The force=true variant does not attempt a graceful handshake with the loop thread and therefore cannot deadlock; it is the same path used by connectToBroker() when reinitialising the client. The #ifndef Q_OS_WIN guard in disconnect() is left in place — that code path handles user-initiated disconnect during a session and has different threading constraints.

──────────────────────────────────────────────────────────────────────────── Files changed
──────────────────────────────────────────────────────────────────────────── src/core/MqttClient.cpp
— ~MqttClient(): add mosquitto_loop_stop(m_mosq, true) before destroy

src/gui/MainWindow.cpp
— toggleAetherialStrip(): set WA_QuitOnClose=false on m_aetherialStrip
— floatAppletPanel(): set WA_QuitOnClose=false on m_appletPanelFloatWindow

──────────────────────────────────────────────────────────────────────────── Testing notes
──────────────────────────────────────────────────────────────────────────── To reproduce the original bug:

  1. Open AetherSDR on Windows 11.
  2. Open the Aetherial Audio Strip (egg-nub button) — ensures root cause 1. OR pop out the applet panel (View → Pop Out Applet Panel) — root cause 2. OR connect to an MQTT broker in the MQTT applet — root cause 3.
  3. Close the main window.
  4. Observe Task Manager: process should exit within ~1 second.

Before this fix: process remains in Task Manager indefinitely.
After this fix: process exits promptly on close.

On Windows 11, the AetherSDR process could remain visible in Task Manager
indefinitely after the main window was closed. Three independent root causes
were identified.

────────────────────────────────────────────────────────────────────────────
Root cause 1 — AetherialAudioStrip blocks quitOnLastWindowClosed
────────────────────────────────────────────────────────────────────────────
`m_aetherialStrip` is created with `parent=MainWindow` but then given the
`Qt::Window` flag so it appears as an independent taskbar entry. Qt 6 treats
any widget carrying `Qt::Window` as a top-level window for the purpose of
`QApplication::quitOnLastWindowClosed`. The attribute `WA_QuitOnClose`
defaults to `true` on every widget.

Consequence: if the strip is visible when the user closes the main window,
`QApplication` sees a second visible top-level window with `WA_QuitOnClose`
set and refuses to emit `lastWindowClosed`. `app.exec()` never returns,
`~MainWindow()` never runs, and the process hangs forever.

Fix: set `WA_QuitOnClose = false` on `m_aetherialStrip` immediately after
the `Qt::Window` flag is applied. The strip is a secondary, inspector-style
window and must not control application lifetime.

────────────────────────────────────────────────────────────────────────────
Root cause 2 — floated applet-panel window lacks WA_QuitOnClose = false
────────────────────────────────────────────────────────────────────────────
`m_appletPanelFloatWindow` is created with `parent = nullptr` (a genuine
top-level window). `WA_QuitOnClose` was never set to `false` on it, so if
a close-event ordering quirk (e.g. the float window's native WM_DESTROY
arrives before MainWindow's hide completes) caused it to be the apparent
"last window", Qt could fail to call `quit()`. `FloatingContainerWindow`
already sets this attribute; the applet-panel float window was the only
top-level secondary window that did not.

Fix: add `setAttribute(Qt::WA_QuitOnClose, false)` alongside the existing
`WA_DeleteOnClose = false` in `floatAppletPanel()`. This matches the
pattern used by all other secondary/floating windows in the codebase.

────────────────────────────────────────────────────────────────────────────
Root cause 3 — MqttClient destroys mosquitto while loop thread still runs
────────────────────────────────────────────────────────────────────────────
`MqttClient::disconnect()` contains a `#ifndef Q_OS_WIN` guard that skips
`mosquitto_loop_stop()` on Windows. The guard was presumably added to avoid
a deadlock in the `force=false` (graceful) stop path, where the loop thread
tries to deliver a callback to the Qt main thread while the main thread is
already blocked waiting for the stop to complete.

The destructor calls `disconnect()` (which on Windows leaves the background
network thread running) and then immediately calls `mosquitto_destroy()`.
The libmosquitto documentation explicitly states that `mosquitto_loop_stop()`
must be called before `mosquitto_destroy()`. Violating this contract on
Windows can leave the thread in an undefined state — holding a WINSOCK
handle or a Windows timer — that prevents `ExitProcess()` from completing
cleanly (or at all).

Fix: add `mosquitto_loop_stop(m_mosq, true)` (force=true, safe on any
thread) between the `disconnect()` call and `mosquitto_destroy()` in the
destructor. The `force=true` variant does not attempt a graceful handshake
with the loop thread and therefore cannot deadlock; it is the same path
used by `connectToBroker()` when reinitialising the client. The `#ifndef
Q_OS_WIN` guard in `disconnect()` is left in place — that code path handles
user-initiated disconnect during a session and has different threading
constraints.

────────────────────────────────────────────────────────────────────────────
Files changed
────────────────────────────────────────────────────────────────────────────
src/core/MqttClient.cpp
  — ~MqttClient(): add mosquitto_loop_stop(m_mosq, true) before destroy

src/gui/MainWindow.cpp
  — toggleAetherialStrip(): set WA_QuitOnClose=false on m_aetherialStrip
  — floatAppletPanel(): set WA_QuitOnClose=false on m_appletPanelFloatWindow

────────────────────────────────────────────────────────────────────────────
Testing notes
────────────────────────────────────────────────────────────────────────────
To reproduce the original bug:
  1. Open AetherSDR on Windows 11.
  2. Open the Aetherial Audio Strip (egg-nub button) — ensures root cause 1.
     OR pop out the applet panel (View → Pop Out Applet Panel) — root cause 2.
     OR connect to an MQTT broker in the MQTT applet — root cause 3.
  3. Close the main window.
  4. Observe Task Manager: process should exit within ~1 second.

Before this fix: process remains in Task Manager indefinitely.
After this fix: process exits promptly on close.
@chibondking chibondking changed the title fix(win): prevent process lingering in Task Manager after close (#XXXX) fix(win): prevent process lingering in Task Manager after close May 17, 2026
@chibondking
Copy link
Copy Markdown
Collaborator Author

@AetherClaude thoughts on this PR?

@aethersdr-agent
Copy link
Copy Markdown
Contributor

Reviewed the diff against the current tree. All three fixes are sound and minimal — happy to see this land.

Root cause 1 — m_aetherialStrip (MainWindow.cpp:11915–11918): Confirmed. The strip is constructed with parent=this, then promoted to a top-level via setWindowFlag(Qt::Window, true). Per Qt 6 semantics, that makes it eligible to gate quitOnLastWindowClosed, and WA_QuitOnClose defaults to true. Adding setAttribute(Qt::WA_QuitOnClose, false) immediately after the flag is set is the correct, minimal fix and is consistent with how FloatingContainerWindow already handles itself (FloatingContainerWindow.cpp:35).

Root cause 2 — m_appletPanelFloatWindow (MainWindow.cpp:14853): Confirmed. This widget is constructed with parent=nullptr, making it a genuine top-level window, and WA_QuitOnClose was indeed never cleared on it. The new line sits right next to the existing WA_DeleteOnClose call, mirroring the FloatingContainerWindow pattern exactly. Good consistency.

Root cause 3 — ~MqttClient (MqttClient.cpp:40–49): Confirmed and the most subtle of the three. On Windows, disconnect() deliberately skips mosquitto_loop_stop() (line 139's #ifndef Q_OS_WIN), so when the destructor immediately calls mosquitto_destroy() the background network thread is still live — a documented contract violation. The fix uses mosquitto_loop_stop(m_mosq, true) (force=true), which is the same call already used in connectToBroker() at line 67 when reinitialising the client, so the pattern is established and known-safe. It's also idempotent: on non-Windows where the loop was already stopped by disconnect(), the call returns MOSQ_ERR_NOT_RUNNING — harmless. Leaving the #ifndef Q_OS_WIN guard in the user-initiated disconnect() path is the right call; that path has a graceful-handshake deadlock risk that the destructor's force=true path doesn't.

One small forward-looking note (not a blocker for this PR): the disconnect() guard is now the only place where the loop thread isn't explicitly stopped on Windows. If a future bug surfaces around mid-session reconnect on Windows, that's the first place to look — but it's correct as-is for the documented threading constraint and shouldn't hold up this fix.

LGTM. A maintainer can authorize the orchestrator to land this fix by adding the aetherclaude-eligible label.

73, Jeremy KK7GWY & Claude (AI dev partner)

Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

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

Thanks @chibondking — careful diagnosis and the fix is minimal and well-targeted. The PR looks good to merge.

A few notes:

MainWindow.cpp (both hunks): Correct and consistent with the existing pattern. FloatingContainerWindow.cpp:35 already does the same thing, and m_aetherialStrip + m_appletPanelFloatWindow were the two remaining secondary top-levels missing it. The Qt::WA_QuitOnClose reasoning is right — once Qt::Window is applied (or parent=nullptr is used) the widget counts toward quitOnLastWindowClosed.

MqttClient.cpp: The change is reasonable defense-in-depth and makes the destructor symmetric with the reinit path in connectToBroker() (lines 66–70 already do loop_stop(force=true) + destroy).

One small caveat on the diagnosis in root cause 3: on Windows we never call mosquitto_loop_start()connectToBroker() uses the Q_OS_WIN branch at line 118–120 to drive mosquitto_loop() via m_pollTimer instead, precisely because libmosquitto's threaded loop isn't reliably available on the Windows build. So in practice there is no background libmosquitto thread for mosquitto_destroy() to race with on Windows. The poll timer is a QObject member that gets stopped in disconnect() (line 134) and destroyed with the client. That doesn't make this fix wrong — mosquitto_loop_stop() on a client that never started a thread is documented as harmless (returns MOSQ_ERR_NOT_SUPPORTED), and matching the connectToBroker() cleanup pattern is good hygiene. But if Windows-side process lingering came back after this lands, root cause 3 probably isn't where to look next; the more likely culprit would be something else holding app.exec() open (e.g. another stray top-level, a QThread not joined, or a QSocketNotifier on a WSA handle). Worth keeping in mind, not a blocker.

LGTM.

@ten9876 ten9876 merged commit a9b3d72 into aethersdr:main May 17, 2026
5 checks passed
@ten9876
Copy link
Copy Markdown
Collaborator

ten9876 commented May 17, 2026

Claude here — merged. Thanks CJ! Three independent root causes with three minimal one-line fixes, each tracing to an existing precedent in the codebase (FloatingContainerWindow + connectToBroker), and a textbook-quality root-cause analysis in the PR body. The Windows-process-hanging-in-Task-Manager symptom has been a recurring user complaint and this targets all three structural causes at once.

73, Jeremy KK7GWY & Claude (AI dev partner)

@ten9876 ten9876 mentioned this pull request May 17, 2026
ten9876 added a commit that referenced this pull request May 17, 2026
Hotfix recommended for TCI digital-mode operators, Windows users with
MQTT or floating-panel windows, and Linux Ubuntu 22.04 builders.

- TCI TX audio level regression vs v26.5.1 (#2806/#2807) — restores
  WSJT-X / JTDX output to full level by reverting only the device +
  protocol identity strings from #2597 to a form that bypasses
  WSJT-X's TCITransceiver SunSDR2DX/non-ExpertSDR3 gain trap.
- Windows process lingering in Task Manager after close (#2802,
  chibondking) — three independent root causes addressed: Channel
  Strip + applet-panel float window WA_QuitOnClose, MqttClient
  destructor loop_stop.
- AppImage CI build failure on gcc 11 strict mode (#2799) — qualifies
  bitstream.h:192 member access with `this->` for two-phase name
  lookup. Restores the v26.5.2 x86_64 AppImage artifact.
- SSA (Sweden) band plan (#2805, NF0T) — Swedish national overlay
  with PTSFS 2025:1 constraints, Swedish labels, 89 spots.

Bumps version 26.5.2 → 26.5.2.1 in CMakeLists.txt, README.md,
CLAUDE.md; regenerates WhatsNewData.cpp from CHANGELOG.md.
CalVer scheme expanded to YY.M.patch[.hotfix] to document the
4th-component hotfix convention used historically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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.

2 participants