fix(tci/dax): DAX RX lifecycle hotfix — stopDax ownership guard, profile-switch recreate, debounced teardown (#3363, #3364, #3476)#3496
Conversation
There was a problem hiding this comment.
Thanks @jensenpat — this is an unusually well-root-caused PR, and the write-up made it straightforward to verify. I checked each fix against the existing code and the claims hold up:
- The
stopDax()ownership guard mirrors the existing pattern inonDaxChannelChanged()(MainWindow.cpp ~18394) exactly, including the#ifdef HAVE_RADEhandling (m_radeDaxStreamIdis declared under that guard in MainWindow.h). Channels 1–4 is consistent with every registration site (registerDaxStreamcallers all gate on 1–4), so nothing can be leaked by switching fromdaxStreamIds()to the per-channel loop. - Erase-vs-zero in the stream-removed handler is safe for in-flight creates as described: pending entries hold value 0 and the handler early-returns on
streamId == 0, so they can never match. Theerase(it); break;pattern is fine. - The grace-timer lifecycle looks right:
stop()stops the timer before its existing immediatereleaseDaxForTci(), the timeout lambda re-checks for live audio clients before releasing, and clearingm_tciDaxSliceson radio disconnect closes the deferred-release-after-radio-bounce hole.scheduleDaxRelease()falling back to immediate release when the timer is null is good defensive coding.
One real gap worth addressing (or explicitly deferring to #3305): borrowed-stream ownership after the bridge stops.
When TCI has borrowed a bridge-created stream (m_tciDaxBorrowedChannels contains the channel) and the user toggles the DAX bridge off, the new stopDax() guard correctly keeps the stream alive for TCI — that's the #3363 fix. But the channel stays marked borrowed in TciServer, so when TCI later releases (client quits, grace expires), releaseDaxForTci() skips it (TciServer.cpp:1946) — it neither sends stream remove nor unregisters it from PanadapterStream. The owner that would have cleaned it up (the bridge) is already gone, so the stream is orphaned on the radio until disconnect. Pre-PR this couldn't happen because stopDax() removed everything (the bug); post-PR an orphan is strictly better than silenced RX, but it's a new leak.
Suggested fix: when stopDax() keeps a stream solely because TCI owns the channel, transfer ownership — e.g. a small TciServer::adoptDaxChannel(int ch) that removes the channel from m_tciDaxBorrowedChannels, so the eventual releaseDaxForTci() removes the stream like one it created. If you'd rather not grow the API in a hotfix, a comment noting the orphan case + a pointer to #3305 would be acceptable.
Minor (non-blocking):
- The main-thread watchdog will log a huge spurious "stalled ~N ms" warning after system suspend/resume or laptop lid-close (the gap is wall-clock). Log-only so it's harmless, but consider capping/annotating gaps above a few seconds so field logs don't get misread as multi-hour stalls.
- The
qCWarningelevation means every normal client quit (gracefulaudio_stop+ disconnect) now logs warnings in routine operation. The rationale (teardowns should never be silent) is sound for the diagnostic phase — just flagging that once the remaining Tune/ATU stall is fixed, theaudio_stop/disconnect lines are candidates to drop back toqCInfo, keepingreleaseDaxForTci()itself at warning.
No scope concerns — all four files are within the stated fix/diagnostic scope, no AppSettings/QSettings or RAII issues, and error handling at the boundaries (null m_daxReleaseTimer, QPointer model, disconnected-radio guard in releaseDaxForTci) is in place. Nice work, and thanks for the live-rig validation detail.
🤖 aethersdr-agent · cost: $6.0343 · model: claude-fable-5
|
Review comments: Stream ownership is deferred until #3305 is ready. Not in scope. This has had 8 hours of non-stop FT8 testing with profile, ATU and tune changes, and no blackout of rx/tx or pan data. Tested on both Windows and macOS. Ready to go. |
|
Heads-up — One architectural adaptation needed (the only one across all open PRs): this PR adds const bool tciUsing = m_tciServer && m_tciServer->ownsDaxChannel(ch);
const bool tciUsing = tciServer() && tciServer()->ownsDaxChannel(ch);Your DAX-lifecycle logic is otherwise untouched — Context for the rebase — MainWindow.cpp went from ~19.5k lines to ~8.1k across PRs #3508–#3545; methods moved into sibling TUs ( |
NF0T
left a comment
There was a problem hiding this comment.
Claimed; read full diff, all five commit messages, PR body, bot review, and field validation notes.
The three DAX-RX lifecycle fixes are sound — the ownership guard, the erase-not-zero key fix, and the debounce timer all compose correctly and the hardware-validated evidence (stream 0x04000009 removal timestamp, WSJT-X console throw, monotonic frames_sent across profile switches) meets Principle VIII. Not blocking on the logic.
Blocking: two mechanical adaptations required for the #3351 MainWindow decomposition.
PR #3545 ("RadioSession owns TciServer + CatPorts") merged 2026-06-13 04:29, after this PR was opened. Both items are one-line fixes; the underlying intent is unchanged.
1 — Wrong target file.
The MainWindow.cpp hunk targets stopDax() at the old monolith line ~18312. That method now lives in MainWindow_DigitalModes.cpp:1070. The ownership-guard block belongs in MainWindow_DigitalModes.cpp — not back in MainWindow.cpp.
2 — Stale member reference.
The patch adds:
const bool tciUsing = m_tciServer && m_tciServer->ownsDaxChannel(ch);m_tciServer no longer exists as a MainWindow member. The replacement accessor is defined in MainWindow.h:503–504:
TciServer* tciServer() const { return m_session->tciServer(); }The correct line — identical to the pattern already used in onDaxChannelChanged() at MainWindow_DigitalModes.cpp:1169, which this PR references as the model — is:
const bool tciUsing = tciServer() && tciServer()->ownsDaxChannel(ch);m_radeDaxStreamId and daxStreamIdForChannel() are both already in scope in MainWindow_DigitalModes.cpp; no other adaptation needed in that TU. TciServer.cpp, TciServer.h, and main.cpp are unaffected by the refactor and are correct as written.
@ten9876 flagged the member rename above and offered to take the rebase — worth taking him up on that given the MainWindow.cpp → MainWindow_DigitalModes.cpp redirect is a second wrinkle on top of it.
Requesting changes only on the rebase adaptation; happy to flip to approved once the two items above are resolved.
…ersdr#3363, aethersdr#3476) Two DAX-RX stream-lifecycle bugs from the TCI/DAX/RADE cluster that share the missing-refcount root cause tracked in aethersdr#3305. stopDax() ownership guard (aethersdr#3363, aethersdr#2886): MainWindow::stopDax() unconditionally `stream remove`d every registered DAX RX stream, so toggling the DAX bridge (or mute) off ripped out the stream TCI had borrowed — silencing WSJT-X RX instantly. The sibling teardown paths (RADE teardown, onDaxChannelChanged de-assign) already guard via TciServer::ownsDaxChannel() / m_radeDaxStreamId; stopDax() was the one that didn't. Now iterate channels 1-4 and keep any a consumer still holds. Liveness-keyed recreation (aethersdr#3476, aethersdr#3364): The reactive "radio removed DAX stream" handler zeroed the cache value but kept the channel key, so ensureDaxForTci()'s `contains(ch)` guard skipped `stream create` after a profile switch — which destroys the radio's dax_rx streams without a TCI disconnect. TCI RX then stayed silent until a full reconnect ("switched profile, never came back"). Erase the key instead; pending creates (value 0) are never matched (streamId != 0), so in-flight requests are safe. Tactical fixes; the structural refcount that subsumes both is aethersdr#3305. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…wn to qCWarning Diagnostic-only instrumentation to root-cause mid-session WSJT-X RX loss and the "TCI failed set rxfreq" throws (incl. at WSJT-X startup). NOT a behavior change. - replyText() helper logs every per-command echo (audio_*, etc.) that bypassed the central dispatch log via its early `continue`. - broadcast() logs every async message — crucially the `vfo:` echo that WSJT-X's do_frequency() waits <=2s on before throwing "TCI failed set rxfreq". - sendInitBurst() logs each startup command (the startup vfo:/dds: WSJT-X reconciles against on connect). - releaseDaxForTci() + client-disconnect + audio_stop now log at qCWarning. These ran INVISIBLY in the 26.6.2 repro because qCInfo(lcCat) is suppressed below the warning threshold even with aether.cat.debug enabled — which is why the DAX-RX teardown that killed WSJT-X RX left no trace. Pairs with capturing WSJT-X's own console (it printf's the throw reason) to nail whether we echo vfo:/ptt correctly and within WSJT-X's timeout windows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…vive A TCP client drop is frequently transient: WSJT-X throws a rig-control error and reconnects within ~2s. Two confirmed triggers: - Tune/ATU: a band change landing during a TGXL ATU tune stalls our TCI loop, so the vfo: echo misses WSJT-X's 2000ms do_frequency timeout → "TCI failed set rxfreq" → socket close. - Profile switch / generic watchdog reconnects (aethersdr#3476/aethersdr#3363 family). onClientDisconnected / audio_stop previously called releaseDaxForTci() immediately, turning that ~2s blip into permanent silence (DAX RX torn down, WSJT-X RX + waterfall dead until restart). Now defer the teardown by kDaxReleaseGraceMs (5s) via a single-shot timer; audio_start from a (re)connecting client cancels it (cancelDaxRelease()), so the stream survives the blip and audio resumes with no recreate. If the radio genuinely removed the streams meanwhile (profile slice recreate), the reactive "stream removed" handler still keeps m_tciDaxStreamIds truthful — the two fixes compose. stop() tears down immediately (cancels the timer first). This is the survivability backstop, not the root fix for the Tune/ATU 2s stall (tracked separately). Pairs with the liveness erase-key fix already on this branch and the structural refcount in aethersdr#3305. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…io disconnect Two hardenings from a re-review of the debounce path (aethersdr#3363/aethersdr#3476 + Tune/ATU). Grace 5s → 10s: Measured WSJT-X reconnect-after-throw gaps in field repros were 2.1s / 3.3s / 3.5s — and WSJT-X is slowest to come back mid-FT8-decode, exactly when the throws happen. The previous 5s left ~1.5s margin; 10s gives ~3x. Cost of lingering after a genuine quit is a few extra seconds with an unconsumed DAX stream and dax flag — trivial. Stale slice de-assign across a radio bounce inside the grace window: connectionStateChanged(false) cleared m_tciDaxStreamIds and m_tciDaxBorrowedChannels but not m_tciDaxSlices. If a quick radio disconnect/reconnect happens during the grace, the later releaseDaxForTci() used to call setDaxChannel(0) against the RECREATED slices — stripping profile-restored DAX assignments from slices we no longer manage. Now also cleared on radio disconnect, so the cleanup is consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…600ms gap) Diagnostic only — no behavior change. A 250ms QTimer heartbeat on the GUI event loop logs "MainThreadWatchdog: event loop stalled ~N ms" whenever a tick is delayed past 600ms. The warning fires immediately after the loop unblocks, so the stall's *end* is the log timestamp and the *start* is (timestamp − reported gap). Whatever logs right after it is what the loop was waiting on. Targets the unresolved ~2s band-change+ATU stall that makes the TCI `vfo:` echo miss WSJT-X's 2000ms do_frequency timeout → "TCI failed set rxfreq" → socket close → DAX RX teardown. We have the symptom (log-silent 22:05:38.7→40.726 window in the repro) but not the location; the watchdog converts every future stall into a single, timestamped, always-visible log line. Visible at default log levels (qWarning). Heartbeat overhead is negligible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
2229984 to
5de583c
Compare
|
@AetherClaude re-review this PR with the latest updates. |
There was a problem hiding this comment.
Thorough, well-reasoned hotfix bundle — the writeup, the field correlation, and the in-code comments make each change easy to verify. I traced all three fixes against the surrounding code and they hold up. CI is green across all six checks (build, macOS, Windows, CodeQL, accessibility).
What I verified:
- Fix #1 (
stopDaxownership guard) — correctly mirrors the existing guard inonDaxChannelChanged()(MainWindow_DigitalModes.cpp:1162-1188): samedaxStreamIdForChannel(ch)/ownsDaxChannel(ch)/m_radeDaxStreamIdtriad, sameHAVE_RADEgating. Kept streams stay registered (nounregisterDaxStream), which is the right behavior. Switching from iteratingdaxStreamIds()to channels 1–4 is fine since DAX RX is 1–4. - Fix #2 (erase-not-zero) — the reactive handler now
erase(it); break;immediately, so the erase-during-iteration is safe, and dropping the key letsensureDaxForTci()'scontains(ch)guard (TciServer.cpp:1904) fall through to recreate. ThestreamId != 0match correctly protects in-flight (value 0) creates.m_tciDaxBorrowedChannels.remove()alongside is correct. - Fix #3 (debounced teardown) —
m_daxReleaseTimeris parented tothis(cleaned up with the object), single-shot, re-checksanyAudiowhen it fires,stop()tears down immediately, andcancelDaxRelease()onaudio_startcancels it. Clearingm_tciDaxSliceson radio disconnect to prevent a latesetDaxChannel(0)on recreated slices is a nice catch.
Two minor, non-blocking notes:
-
Routine events now log at
qCWarning—audio_stopand a clean client disconnect (e.g. a normal WSJT-X quit) now emitqCWarning, not just genuine fault paths. I understand why (theqCInfoteardown logs were invisible in the 26.6.2 bundles), but a clean shutdown isn't a warning, and users skimming logs at default levels may read these as errors. Consider gating the warning on "there was actually DAX to tear down" (e.g. only warn inscheduleDaxRelease/releaseDaxForTciwhen!m_tciDaxStreamIds.isEmpty()), keeping the routine ack at info. Not a blocker given the diagnostic intent. -
Watchdog message slightly overstates the stall — in
main.cpp,gapMsis the full inter-tick gap, which includes one nominal ~250 ms heartbeat interval, so the actual block is closer togapMs − 250. The "stall began ~gapMs before this line" line will read ~250 ms early. Cosmetic for a diagnostic, but worth a tweak if you want the timestamp math to land precisely.
Neither affects correctness. Nice work root-causing this with hardware in the loop and keeping the fixes contained while the structural refcounting waits on #3305. Thanks @jensenpat!
🤖 aethersdr-agent · cost: $2.3168 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Both items from our CHANGES_REQUESTED on the prior head are resolved. Reviewing the full updated diff at 5de583c2.
Our two required items:
| Item | Status |
|---|---|
stopDax() moved to MainWindow_DigitalModes.cpp |
✅ line 1087, correct TU |
m_tciServer → tciServer() accessor |
✅ tciServer()->ownsDaxChannel(ch) throughout |
New content added in this revision:
erase()vsit.value() = 0(m_tciDaxStreamIds): The old zero-value left the map key present, which prevented a new stream from being created on that channel after a profile switch (#3476).erase()removes the entry entirely so the profile-switch path can insert fresh. ✅m_tciDaxSlices.clear()on disconnect: Prevents stalesetDaxChannel(0)from firing on newly-recreated slices after reconnect. ✅m_tciDaxBorrowedChannels.remove()on stream removal: symmetric teardown. ✅replyText(): Centralized per-command echo +qCDebugtrace replacing scatteredws->sendTextMessage()calls throughoutonTextMessage(). Clean. ✅scheduleDaxRelease()/cancelDaxRelease()withkDaxReleaseGraceMs = 10000: 10 s debounce before releasing DAX back from TCI; transient WSJT-X reconnects survive. Logs atqCWarningfor visibility. ✅- Stall watchdog (
src/main.cpp+27): 250 ms heartbeat QTimer logs when the GUI event loop stalls > 600 ms. UsesqWarning()without a named category — intentionally unconditional for always-visible diagnostics; slightly off theqCWarning(lc*)convention used elsewhere but the intent is clear and the deviation is justified.
6/6 CI green. Approved.
…ile-switch recreate, debounced teardown (aethersdr#3363, aethersdr#3364, aethersdr#3476) (aethersdr#3496) ## Summary Closes aethersdr#3363 Closes aethersdr#3476 Hotfix bundle for the TCI/WSJT-X RX-audio-loss cluster (aethersdr#3363, aethersdr#3476, and the already-closed duplicate aethersdr#3364; materially helps aethersdr#2886 and aethersdr#3366), plus the diagnostics that root-caused it. All failures share one shape: a DAX RX stream that WSJT-X depends on is torn down (or never recreated) by a path that doesn't know WSJT-X still needs it. Structural refcounting is tracked in aethersdr#3305; these are the contained fixes users need now. Root-caused with hardware in the loop (FLEX + PGXL + TGXL + WSJT-X-Improved console capture). Full investigation notes are on aethersdr#3305 (two comments, 2026-06-08). ## Fixes ### 1. `stopDax()` ownership guard (aethersdr#3363, helps aethersdr#2886) `MainWindow::stopDax()` unconditionally `stream remove`d every registered DAX RX stream, so toggling the DAX bridge (or slice mute) off ripped out the stream TCI had borrowed — silencing WSJT-X RX instantly (confirmed in aethersdr#3363's support bundle: stream `0x04000009` removed at 14:38:19, ~50 s blackout until WSJT-X retried). The sibling teardown paths (RADE at ~17520, per-slice de-assign from aethersdr#3308) already guard via `TciServer::ownsDaxChannel()` / `m_radeDaxStreamId`; `stopDax()` was the one that didn't. Now iterates channels 1–4 and keeps any stream another consumer still holds. ### 2. Liveness-keyed recreation after profile switch (aethersdr#3476, aethersdr#3364) `profile global load` destroys/recreates slices **without** a radio disconnect. The reactive "radio removed DAX stream" handler zeroed the cached stream id but **kept the channel key**, so `ensureDaxForTci()`'s `contains(ch)` guard skipped `stream create` on the re-arm — TCI RX stayed silent until a full reconnect ("switched profile, never came back"; only Reset Settings recovered). The handler now **erases** the entry (and its borrowed flag), so the next re-arm recreates. Pending creates (value 0) are never matched (`streamId != 0`), so in-flight requests are safe. ### 3. Debounced DAX RX teardown (Tune/ATU + transient reconnects) Field-reproduced: a band change landing during a TGXL ATU tune stalls the main loop ~2 s → the `vfo:` echo misses WSJT-X's hard 2000 ms `do_frequency` timeout → WSJT-X throws `TCI failed set rxfreq`, **closes the socket**, and reconnects ~2–3.5 s later. `onClientDisconnected`/`audio_stop` previously called `releaseDaxForTci()` immediately, turning that blip into permanent silence. Now the teardown is deferred by `kDaxReleaseGraceMs` (10 s — measured drop→`audio_start` gaps were 2.1/3.3/3.5 s); a (re)connecting client's `audio_start` cancels it, so the stream survives and audio resumes with no recreate. `stop()` still tears down immediately. Also clears `m_tciDaxSlices` on radio disconnect so a deferred release after a quick radio bounce can't `setDaxChannel(0)` on recreated slices. The underlying ~2 s stall is **not** fixed here — it's the remaining root bug, and the watchdog below is aimed at it. ## Diagnostics (kept deliberately) - **Outbound TCI traffic logging** — `TCI tx→all:` / `tx→client:` / `tx→init:` (`qCDebug(lcCat)`). We logged every inbound command but nothing we sent; the `vfo:` echo WSJT-X times out on was invisible. This is what made the Tune/ATU root cause provable. - **Teardown logs elevated to `qCWarning`** — `releaseDaxForTci()`, client TCP drop, `audio_stop`. These ran **completely invisibly** in the 26.6.2 field bundles because `qCInfo(lcCat)` sits below the default warning threshold even with `aether.cat.debug` enabled. An audio-killing teardown should never be silent. - **Main-thread stall watchdog** (`main.cpp`) — 250 ms heartbeat; logs `MainThreadWatchdog: event loop stalled ~N ms` (warning) for any GUI-loop block > 600 ms. Stall start ≈ timestamp − gap. Converts the next field stall into a single timestamped line. ## Validation - Built clean on macOS (RelWithDebInfo, Ninja), rebased onto current `main` and rebuilt. - Live rig (FLEX + PGXL + TGXL + WSJT-X-Improved): 4 consecutive `profile global load` switches with TCI audio flowing — `frames_sent` climbed monotonically across all of them, zero teardowns; on client quit, the debounce deferred 10 s and released cleanly (`0 stream(s), 0 slice assignment(s)`). - Watchdog verified live (caught a benign ~1.1 s startup stall; silent during all profile switches — confirming the 2 s stall is specific to the Tune/ATU path). - WSJT-X console + AetherSDR log correlation for the Tune/ATU repro: throw at 05:05:40.715, our delayed `vfo:` processing at 05:05:40.726 — 2.0 s after WSJT-X sent it. ## Out of scope / follow-ups - The ~2 s band-change+ATU main-thread stall (root cause of the WSJT-X throws) — to be filed separately; the watchdog pinpoints it on next occurrence. - WSJT-X waterfall blanking for ~5 s when a profile load retunes the radio to a band the client didn't request (cosmetic; client-side reconciliation) — policy question, separate issue. - Centralized DAX RX refcounting — aethersdr#3305 (design requirements documented there). 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat --------- Co-authored-by: Codex <noreply@openai.com>
Summary
Closes #3363
Closes #3476
Hotfix bundle for the TCI/WSJT-X RX-audio-loss cluster (#3363, #3476, and the already-closed duplicate #3364; materially helps #2886 and #3366), plus the diagnostics that root-caused it. All failures share one shape: a DAX RX stream that WSJT-X depends on is torn down (or never recreated) by a path that doesn't know WSJT-X still needs it. Structural refcounting is tracked in #3305; these are the contained fixes users need now.
Root-caused with hardware in the loop (FLEX + PGXL + TGXL + WSJT-X-Improved console capture). Full investigation notes are on #3305 (two comments, 2026-06-08).
Fixes
1.
stopDax()ownership guard (#3363, helps #2886)MainWindow::stopDax()unconditionallystream removed every registered DAX RX stream, so toggling the DAX bridge (or slice mute) off ripped out the stream TCI had borrowed — silencing WSJT-X RX instantly (confirmed in #3363's support bundle: stream0x04000009removed at 14:38:19, ~50 s blackout until WSJT-X retried). The sibling teardown paths (RADE at ~17520, per-slice de-assign from #3308) already guard viaTciServer::ownsDaxChannel()/m_radeDaxStreamId;stopDax()was the one that didn't. Now iterates channels 1–4 and keeps any stream another consumer still holds.2. Liveness-keyed recreation after profile switch (#3476, #3364)
profile global loaddestroys/recreates slices without a radio disconnect. The reactive "radio removed DAX stream" handler zeroed the cached stream id but kept the channel key, soensureDaxForTci()'scontains(ch)guard skippedstream createon the re-arm — TCI RX stayed silent until a full reconnect ("switched profile, never came back"; only Reset Settings recovered). The handler now erases the entry (and its borrowed flag), so the next re-arm recreates. Pending creates (value 0) are never matched (streamId != 0), so in-flight requests are safe.3. Debounced DAX RX teardown (Tune/ATU + transient reconnects)
Field-reproduced: a band change landing during a TGXL ATU tune stalls the main loop ~2 s → the
vfo:echo misses WSJT-X's hard 2000 msdo_frequencytimeout → WSJT-X throwsTCI failed set rxfreq, closes the socket, and reconnects ~2–3.5 s later.onClientDisconnected/audio_stoppreviously calledreleaseDaxForTci()immediately, turning that blip into permanent silence.Now the teardown is deferred by
kDaxReleaseGraceMs(10 s — measured drop→audio_startgaps were 2.1/3.3/3.5 s); a (re)connecting client'saudio_startcancels it, so the stream survives and audio resumes with no recreate.stop()still tears down immediately. Also clearsm_tciDaxSliceson radio disconnect so a deferred release after a quick radio bounce can'tsetDaxChannel(0)on recreated slices.The underlying ~2 s stall is not fixed here — it's the remaining root bug, and the watchdog below is aimed at it.
Diagnostics (kept deliberately)
TCI tx→all:/tx→client:/tx→init:(qCDebug(lcCat)). We logged every inbound command but nothing we sent; thevfo:echo WSJT-X times out on was invisible. This is what made the Tune/ATU root cause provable.qCWarning—releaseDaxForTci(), client TCP drop,audio_stop. These ran completely invisibly in the 26.6.2 field bundles becauseqCInfo(lcCat)sits below the default warning threshold even withaether.cat.debugenabled. An audio-killing teardown should never be silent.main.cpp) — 250 ms heartbeat; logsMainThreadWatchdog: event loop stalled ~N ms(warning) for any GUI-loop block > 600 ms. Stall start ≈ timestamp − gap. Converts the next field stall into a single timestamped line.Validation
mainand rebuilt.profile global loadswitches with TCI audio flowing —frames_sentclimbed monotonically across all of them, zero teardowns; on client quit, the debounce deferred 10 s and released cleanly (0 stream(s), 0 slice assignment(s)).vfo:processing at 05:05:40.726 — 2.0 s after WSJT-X sent it.Out of scope / follow-ups
💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat