fix(dax): create DAX RX stream on per-slice channel (re)assignment (#2895)#3308
Conversation
…ethersdr#2895) startDax() only sent `stream create type=dax_rx dax_channel=N` once at bridge startup, iterating slices that already had a DAX channel (in practice just slice 0 / DAX 1). When a DAX channel was assigned to another slice later via the UI, SliceModel only sent `slice set <id> dax=<ch>` and never created the stream, so the radio never registered a DAX client (dax_clients stayed 0) and sent silence — slices 1-3 were mute on Linux PipeWire and macOS. React to per-slice daxChannelChanged (and RadioModel::sliceAdded) while the bridge is up: on 0->1..4 create the stream if not already registered and re-assert slice set dax= (the aethersdr#1439 client-registration workaround, mirrored from TciServer::ensureDaxForTci); on release/move, remove the old channel's stream when no other slice references it. Ownership guard: the PanadapterStream DAX map is shared across the bridge, TCI (which borrows bridge-created streams) and RADE. The removal path now consults TciServer::ownsDaxChannel() and m_radeDaxStreamId so it never tears out a stream WSJT-X or RADE is still using (the mirror image of aethersdr#3270). Tracked for full consolidation in aethersdr#3305. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NF0T
left a comment
There was a problem hiding this comment.
Reviewed independently as @NF0T (Tier 3 reviewer).
Summary
The overall architecture is sound and the diagnosis of #2895 is correct — startDax() only handled slices present at bridge startup, leaving channels 2–4 silent when DAX assignments arrive after startup. The wireDaxSlice + onDaxChannelChanged + sliceAdded approach mirrors the proven TciServer::ensureDaxForTci pattern and is the right design. However, there are two blocking issues that must be fixed before merge.
BLOCKING Issue #1 — sliceAdded lambda self-defeats its stream-creation path
In startDax(), the sliceAdded lambda:
m_daxSliceLastCh[s->sliceId()] = s->daxChannel(); // records current ch as "last known"
wireDaxSlice(s);
if (s->daxChannel() >= 1 && s->daxChannel() <= 4) {
onDaxChannelChanged(s, s->daxChannel()); // always hits oldCh==newCh guard → no-op
}The first line in onDaxChannelChanged is:
const int oldCh = m_daxSliceLastCh.value(sliceId, 0);
if (oldCh == newCh) return;Because m_daxSliceLastCh was just set to s->daxChannel() on the line immediately before the call, oldCh == newCh is always true. The call is a permanent no-op.
The comment above that call reads "A slice can arrive already carrying a DAX channel (radio profile restore); make sure its stream exists too" — but this code never creates the stream. The primary case from #2895 (saved profile with DAX channels on slices 1–3, slices arriving via sliceAdded after bridge startup) will still not work.
Fix: Set m_daxSliceLastCh[s->sliceId()] = 0 before calling onDaxChannelChanged — or omit the pre-set entirely and let QHash::value(..., 0) default it — so onDaxChannelChanged sees the 0 → newCh transition and creates the stream:
// don't pre-set lastCh here; let onDaxChannelChanged see the 0→newCh transition
wireDaxSlice(s);
if (s->daxChannel() >= 1 && s->daxChannel() <= 4)
onDaxChannelChanged(s, s->daxChannel());BLOCKING Issue #2 — m_radeDaxStreamId referenced without #ifdef HAVE_RADE
onDaxChannelChanged lives inside #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) and unconditionally references:
const bool radeUsing = (id != 0 && id == m_radeDaxStreamId);m_radeDaxStreamId is declared in MainWindow.h inside #ifdef HAVE_RADE. A Linux build with HAVE_PIPEWIRE=true but HAVE_RADE=false (any dev build without the RADE libraries installed, or the AppImage release build which does not install RADE) will fail to compile with "use of undeclared identifier 'm_radeDaxStreamId'".
This isn't caught by CI: the Windows job excludes the entire HAVE_PIPEWIRE block; the macOS CI job always has RADE; the AppImage release job only runs on tags, not PRs.
Fix — the exact pattern already used in deactivateRADE() at line ~16382:
#ifdef HAVE_RADE
const bool radeUsing = (id != 0 && id == m_radeDaxStreamId);
#else
const bool radeUsing = false;
#endifNon-blocking observations
sliceRemovedgap (pre-existing): Dead entries accumulate inm_daxSliceConnsandm_daxSliceLastChwhen slices are removed. Harmless with max 4 slices; Qt auto-disconnects destroyed objects.stopDax()removes streams without consulting TCI/RADE ownership (pre-existing): Correctly noted in the PR description as tracked separately in #3305.- Double-call risk after blocking fix #1: Once
lastChis initialised to 0 beforewireDaxSlice, the directonDaxChannelChangedcall and any subsequent signal-driven call will both checkm_finished/oldChcorrectly. ThedaxChannelChangedsignal only fires on actual value changes, so a second fire for the same transition is unlikely in practice.
Verdict: REQUEST CHANGES
Both blocking issues are small, targeted fixes — the PR needs no architectural changes. Once fixed this is ready to merge. All other project rules pass (no QSettings, no raw new/delete outside Qt parent ownership, correct naming conventions). CI is green on the checked configurations.
Tier 3 (src/ only) — within our review authority.
Force sliceAdded profile-restore paths through a 0-to-DAX-channel transition so onDaxChannelChanged creates or reasserts the DAX RX stream instead of returning early. Guard the RADE ownership check behind HAVE_RADE so macOS/PipeWire DAX builds without RADE still compile. Co-authored-by: Codex <noreply@openai.com>
|
Addressed the two blocking review items from @NF0T in Changes made:
Verification performed locally:
This should resolve both requested-change blockers; CI is queued/running on the pushed commit. |
NF0T
left a comment
There was a problem hiding this comment.
Both blockers addressed in 60a4bf0b, verified against the diff:
Blocker #1 (sliceAdded self-defeat): m_daxSliceLastCh[s->sliceId()] = 0 is now set before wireDaxSlice and before onDaxChannelChanged in the lambda. wireDaxSlice doesn't touch lastCh, so onDaxChannelChanged sees oldCh == 0, newCh >= 1 — the 0 → newCh transition proceeds and creates the stream. The startup loop correctly seeds lastCh to the actual current channel for slices already connected at bridge start, which is the right distinction between the two paths. ✅
Blocker #2 (m_radeDaxStreamId without #ifdef HAVE_RADE): Wrapped exactly as requested — #ifdef HAVE_RADE / #else radeUsing = false / #endif. ✅
CI green on all five checks (CodeQL, build, check-macos, check-windows, analyze). jensenpat's local no-RADE compile with -DENABLE_RADE=OFF confirms the platform-guard fix independently.
The pre-existing non-blocking items from the original review (sliceRemoved dead entries, stopDax ownership coordination) remain unchanged and are correctly tracked in #3305 — no issue.
✅ Approved.
…ile-switch recreate, debounced teardown (#3363, #3364, #3476) (#3496) ## 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 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 #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 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 — #3305 (design requirements documented there). 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat --------- Co-authored-by: Codex <noreply@openai.com>
…ethersdr#2895) (aethersdr#3308) Fixes aethersdr#2895 ## Problem `MainWindow::startDax()` only sent `stream create type=dax_rx dax_channel=N` once at bridge startup, iterating slices that *already* had a DAX channel — in practice just slice 0 / DAX 1. When the user later assigned DAX 2–4 to another slice via the UI, `SliceModel::setDaxChannel()` only sends `slice set <id> dax=<ch>` and never `stream create`, so the radio never registers a DAX client (`dax_clients` stays 0) and sends silence. Slices 1–3 were mute on Linux PipeWire (the report) and macOS. The reporter confirmed slices 1–3 DAX assignments aren't persisted, so the "assign-all-then-toggle" workaround can't work — a code fix was the only path. ## Fix React to per-slice `daxChannelChanged` (and `RadioModel::sliceAdded`) while the bridge is up, mirroring the proven TCI path (`TciServer::ensureDaxForTci`, aethersdr#1331/aethersdr#1439) and FlexLib `RequestDAXRXAudioStream(channel)`: - **0 → 1..4**: create the stream if `daxStreamIdForChannel(newCh) == 0`, then re-assert `slice set <id> dax=<newCh>` (the aethersdr#1439 client-registration workaround). The status echo registers the stream in `PanadapterStream`. - **release / move**: remove the old channel's stream when no other slice references it. - `stopDax()` drops the new connections so late signals can't hit a torn-down bridge. ### Cross-consumer ownership guard The `PanadapterStream` DAX map is shared across the bridge, TCI (which *borrows* bridge-created streams) and RADE. The removal path now consults `TciServer::ownsDaxChannel()` and `m_radeDaxStreamId`, so de-assigning a slice's DAX channel never tears out a stream WSJT-X or RADE is still using — the mirror image of aethersdr#3270. Full consolidation of this hand-rolled coordination is tracked in aethersdr#3305. ## Files - `src/gui/MainWindow.cpp` / `.h` — `startDax()`/`stopDax()`, new `wireDaxSlice()` / `onDaxChannelChanged()`, `m_daxSliceConns` / `m_daxSliceLastCh` - `src/core/TciServer.h` — new `ownsDaxChannel(int)` accessor ## Testing Builds clean (macOS). Manual: 1. 2 slices (A on DAX 1, B on none); enable DAX bridge → A audio flows, `dax_clients=1` on ch 1. 2. Assign DAX 2 to slice B via UI → expect `stream create dax_channel=2` + `slice set B dax=2`, `dax_clients=1` on ch 2, audio on the previously-idle sink. 3. Re-assign B back to none → `stream remove` sent, ch 2 quiet, others keep flowing. 4. **Ownership guard:** with WSJT-X decoding on a DAX channel over TCI *and* the bridge up, de-assign that channel from the slice → WSJT-X audio keeps flowing; log shows `keeping DAX RX stream … still used by TCI`. 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…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>
Fixes #2895
Problem
MainWindow::startDax()only sentstream create type=dax_rx dax_channel=Nonce at bridge startup, iterating slices that already had a DAX channel — in practice just slice 0 / DAX 1. When the user later assigned DAX 2–4 to another slice via the UI,SliceModel::setDaxChannel()only sendsslice set <id> dax=<ch>and neverstream create, so the radio never registers a DAX client (dax_clientsstays 0) and sends silence. Slices 1–3 were mute on Linux PipeWire (the report) and macOS. The reporter confirmed slices 1–3 DAX assignments aren't persisted, so the "assign-all-then-toggle" workaround can't work — a code fix was the only path.Fix
React to per-slice
daxChannelChanged(andRadioModel::sliceAdded) while the bridge is up, mirroring the proven TCI path (TciServer::ensureDaxForTci, #1331/#1439) and FlexLibRequestDAXRXAudioStream(channel):daxStreamIdForChannel(newCh) == 0, then re-assertslice set <id> dax=<newCh>(the Broken TCI audio tested on Win11 #1439 client-registration workaround). The status echo registers the stream inPanadapterStream.stopDax()drops the new connections so late signals can't hit a torn-down bridge.Cross-consumer ownership guard
The
PanadapterStreamDAX map is shared across the bridge, TCI (which borrows bridge-created streams) and RADE. The removal path now consultsTciServer::ownsDaxChannel()andm_radeDaxStreamId, so de-assigning a slice's DAX channel never tears out a stream WSJT-X or RADE is still using — the mirror image of #3270. Full consolidation of this hand-rolled coordination is tracked in #3305.Files
src/gui/MainWindow.cpp/.h—startDax()/stopDax(), newwireDaxSlice()/onDaxChannelChanged(),m_daxSliceConns/m_daxSliceLastChsrc/core/TciServer.h— newownsDaxChannel(int)accessorTesting
Builds clean (macOS). Manual:
dax_clients=1on ch 1.stream create dax_channel=2+slice set B dax=2,dax_clients=1on ch 2, audio on the previously-idle sink.stream removesent, ch 2 quiet, others keep flowing.keeping DAX RX stream … still used by TCI.💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat