Skip to content

fix(tci/dax): DAX RX lifecycle hotfix — stopDax ownership guard, profile-switch recreate, debounced teardown (#3363, #3364, #3476)#3496

Merged
NF0T merged 5 commits into
aethersdr:mainfrom
jensenpat:fix/tci-dax-rx-lifecycle-hotfix
Jun 13, 2026
Merged

fix(tci/dax): DAX RX lifecycle hotfix — stopDax ownership guard, profile-switch recreate, debounced teardown (#3363, #3364, #3476)#3496
NF0T merged 5 commits into
aethersdr:mainfrom
jensenpat:fix/tci-dax-rx-lifecycle-hotfix

Conversation

@jensenpat

@jensenpat jensenpat commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

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() unconditionally stream 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: 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 #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 (#3476, #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 loggingTCI 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 qCWarningreleaseDaxForTci(), 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

💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat

@jensenpat jensenpat marked this pull request as ready for review June 10, 2026 01:14
@jensenpat jensenpat requested a review from a team as a code owner June 10, 2026 01:14

@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 @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 in onDaxChannelChanged() (MainWindow.cpp ~18394) exactly, including the #ifdef HAVE_RADE handling (m_radeDaxStreamId is declared under that guard in MainWindow.h). Channels 1–4 is consistent with every registration site (registerDaxStream callers all gate on 1–4), so nothing can be leaked by switching from daxStreamIds() 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. The erase(it); break; pattern is fine.
  • The grace-timer lifecycle looks right: stop() stops the timer before its existing immediate releaseDaxForTci(), the timeout lambda re-checks for live audio clients before releasing, and clearing m_tciDaxSlices on 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 qCWarning elevation means every normal client quit (graceful audio_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, the audio_stop/disconnect lines are candidates to drop back to qCInfo, keeping releaseDaxForTci() 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

@jensenpat

jensenpat commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@ten9876

ten9876 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Heads-up — main moved under the #3351 MainWindow decomposition while this PR was open, so it'll need a rebase before it can land. Trial-merge against current main shows a conflict in src/gui/MainWindow.cpp.

One architectural adaptation needed (the only one across all open PRs): this PR adds

const bool tciUsing = m_tciServer && m_tciServer->ownsDaxChannel(ch);

m_tciServer no longer exists as a MainWindow member. Session-v2 (#3545) moved TciServer ownership onto RadioSession; MainWindow now reaches it through an inline accessor. The fix is a mechanical rename:

const bool tciUsing = tciServer() && tciServer()->ownsDaxChannel(ch);

Your DAX-lifecycle logic is otherwise untouched — ownsDaxChannel() is the same query on the same object; only who owns the TciServer changed, not what it does. So: rebase, apply that one rename, done.

Context for the rebase — MainWindow.cpp went from ~19.5k lines to ~8.1k across PRs #3508#3545; methods moved into sibling TUs (MainWindow_Session.cpp, _DigitalModes.cpp, etc.). If your conflict hunk landed in a block that moved, the resolution is "put it in the TU that now owns that method" rather than back in MainWindow.cpp. Happy to take the rebase off your hands if you'd rather — just say the word.

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

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

jensenpat and others added 5 commits June 13, 2026 08:37
…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>
@jensenpat jensenpat force-pushed the fix/tci-dax-rx-lifecycle-hotfix branch from 2229984 to 5de583c Compare June 13, 2026 15:38
@ten9876

ten9876 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

@AetherClaude re-review this PR with the latest updates.

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

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 (stopDax ownership guard) — correctly mirrors the existing guard in onDaxChannelChanged() (MainWindow_DigitalModes.cpp:1162-1188): same daxStreamIdForChannel(ch) / ownsDaxChannel(ch) / m_radeDaxStreamId triad, same HAVE_RADE gating. Kept streams stay registered (no unregisterDaxStream), which is the right behavior. Switching from iterating daxStreamIds() 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 lets ensureDaxForTci()'s contains(ch) guard (TciServer.cpp:1904) fall through to recreate. The streamId != 0 match correctly protects in-flight (value 0) creates. m_tciDaxBorrowedChannels.remove() alongside is correct.
  • Fix #3 (debounced teardown)m_daxReleaseTimer is parented to this (cleaned up with the object), single-shot, re-checks anyAudio when it fires, stop() tears down immediately, and cancelDaxRelease() on audio_start cancels it. Clearing m_tciDaxSlices on radio disconnect to prevent a late setDaxChannel(0) on recreated slices is a nice catch.

Two minor, non-blocking notes:

  1. Routine events now log at qCWarningaudio_stop and a clean client disconnect (e.g. a normal WSJT-X quit) now emit qCWarning, not just genuine fault paths. I understand why (the qCInfo teardown 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 in scheduleDaxRelease/releaseDaxForTci when !m_tciDaxStreamIds.isEmpty()), keeping the routine ack at info. Not a blocker given the diagnostic intent.

  2. Watchdog message slightly overstates the stall — in main.cpp, gapMs is the full inter-tick gap, which includes one nominal ~250 ms heartbeat interval, so the actual block is closer to gapMs − 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 NF0T enabled auto-merge (squash) June 13, 2026 20:43

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

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_tciServertciServer() accessor tciServer()->ownsDaxChannel(ch) throughout

New content added in this revision:

  • erase() vs it.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 stale setDaxChannel(0) from firing on newly-recreated slices after reconnect. ✅
  • m_tciDaxBorrowedChannels.remove() on stream removal: symmetric teardown. ✅
  • replyText(): Centralized per-command echo + qCDebug trace replacing scattered ws->sendTextMessage() calls throughout onTextMessage(). Clean. ✅
  • scheduleDaxRelease() / cancelDaxRelease() with kDaxReleaseGraceMs = 10000: 10 s debounce before releasing DAX back from TCI; transient WSJT-X reconnects survive. Logs at qCWarning for visibility. ✅
  • Stall watchdog (src/main.cpp +27): 250 ms heartbeat QTimer logs when the GUI event loop stalls > 600 ms. Uses qWarning() without a named category — intentionally unconditional for always-visible diagnostics; slightly off the qCWarning(lc*) convention used elsewhere but the intent is clear and the deviation is justified.

6/6 CI green. Approved.

@NF0T NF0T merged commit 9215021 into aethersdr:main Jun 13, 2026
6 checks passed
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
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.

Upgraded to 26.6.2 and lost TCI in WSJT-X No tci audio again and no dax audio

3 participants