fix(tci): route DAX RX audio by cached channel→TRX, resilient to transient dax=0 (#3669)#3759
Conversation
…sient dax=0 (aethersdr#3669) ## Root cause (code-confirmed) TciServer::onDaxAudioReady() re-derived the destination TCI receiver (trx) on EVERY DAX audio packet by scanning slices for daxChannel()==channel: int trx = std::max(0, channel - 1); // positional fallback for (s : slices) if (s->daxChannel()==channel) { trx = tciTrxForSlice(s); ... } The radio re-broadcasts `slice N dax=0` then `dax=1` during a band/mode retune or when a second client (re)subscribes. SliceModel::applyStatus() zeroes m_daxChannel unconditionally on the `dax=0` (SliceModel.cpp), so for that transient window the scan finds no owning slice and trx falls back to `channel-1`. tciTrxForSlice() returns the slice's INDEX in slices() (TciProtocol.cpp). In a single-receiver / slice-0 setup, index 0 == channel(1)-1, so the fallback accidentally matches and audio keeps flowing — which is why single-slice captures look healthy. In a MULTI-RECEIVER setup (a foreign client owns a slice, pushing our slice to a non-zero index) `channel-1` diverges from the index-based trx, the `audioReceiver != trx` filter drops the packet, and the correctly-bound client goes silent — issue aethersdr#3669, and the likely mechanism behind the "second client kills audio" report. ## Fix Cache the last successfully-resolved channel→trx mapping (m_channelTrx) and, when the live scan transiently misses, route by the cached trx instead of the positional `channel-1` guess. Self-healing: the next status with dax!=0 re-resolves and refreshes the cache. Cold start (no mapping yet) keeps the legacy positional behavior, so single-receiver setups are byte-for-byte unchanged. Cache is cleared in releaseDaxForTci()/rearmDaxForProfileLoad() alongside the channel→stream map. Contained to TciServer routing — independent of the stream-ownership refactor (aethersdr#3305) and slice-lifecycle authority RFC (aethersdr#3715), which address the upstream churn this fix hardens against. The SliceModel transient-zero is the upstream cause; not changed here to avoid fighting radio-authoritative state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for this — the root-cause writeup is excellent, and the fix is appropriately contained: it only changes the trx fallback path, leaves the tciTrxForSlice() happy path and the cold-start/single-receiver behavior byte-for-byte identical, and self-heals on the next dax!=0 resolve. CI is green across all six checks (build, macOS, Windows, CodeQL). The conservatism — hardening frame routing rather than fighting the radio's authoritative dax=0 in SliceModel — is the right call.
One concrete gap worth addressing before merge:
The cache isn't cleared on the radio-disconnect teardown path. You clear m_channelTrx in rearmDaxForProfileLoad() and releaseDaxForTci(), but there's a third spot that tears down the same channel→stream bookkeeping: the connectionStateChanged(false) handler around TciServer.cpp:219 (#3270), which clears m_tciDaxStreamIds, m_tciDaxBorrowedChannels, and m_tciDaxSlices:
m_tciDaxStreamIds.clear();
m_tciDaxBorrowedChannels.clear();
// ...
m_tciDaxSlices.clear();
return;By your own invariant in the comment ("routing cache stale once the channel→stream map is torn down"), this path should also clear m_channelTrx. On disconnect the slices are destroyed and recreated on reconnect, so a cached channel→trx (a slice index) can be stale if the slice topology differs after reconnect — and the cache would be consulted for the transient window before the first dax!=0 re-resolves. In practice it self-heals quickly and channels are bounded to 1–4, so impact is low, but it's an inconsistency with the two paths you did handle. Adding the same one-line m_channelTrx.clear(); there closes it.
Minor (non-blocking): the cached trx is the slice's index, so if a foreign client adds/removes a slice during the exact transient window, the cached index can momentarily diverge from the live index — same self-healing applies, and it's still strictly better than the channel-1 guess. Worth a sentence in the comment if you want to be fully explicit about it.
No convention issues (RAII/AppSettings/C++20 all fine), no null-pointer or leak concerns — m_model is guarded and the QMap keys are bounded. Nice work.
🤖 aethersdr-agent · cost: $1.7928 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Approving. Excellent root-cause analysis and a correctly-contained fix: it hardens only the trx fallback path (scan → cached channel→trx → legacy channel-1), leaves the tciTrxForSlice() happy path and cold-start/single-receiver behavior byte-identical, self-heals on the next dax!=0, and rightly doesn't fight the radio's authoritative dax=0 (deferring the upstream churn to #3305/#3715). Confirmed the cache-clear is correct in rearmDaxForProfileLoad() and releaseDaxForTci(). CI green.
One non-blocking gap (bot-identified, I confirmed): the connectionStateChanged(false) disconnect handler (lines 219-227) clears the three sibling DAX maps but not m_channelTrx — so by the PR's own 'cache stale once the channel→stream map is torn down' invariant it should clear there too (a reconnect with different slice topology could consult a stale cached index for the transient window before the first dax!=0 re-resolves). Low impact (self-heals; channels bounded 1-4). Landing this now and following up with the one-liner + the missing onDaxAudioReady routing test. Thanks @jensenpat.
…3767) ## Summary Closes #3766. Adds the missing third cache-clear for `m_channelTrx` (the DAX channel→TRX routing cache introduced in #3759). #3759 clears the cache in `rearmDaxForProfileLoad()` and `releaseDaxForTci()` but not in the `connectionStateChanged(false)` disconnect handler — which already tears down the three sibling DAX maps (`m_tciDaxStreamIds` / `m_tciDaxBorrowedChannels` / `m_tciDaxSlices`). Per #3759's own invariant ("routing cache stale once the channel→stream map is torn down"), the disconnect path should clear it too: on a disconnect→reconnect where the slice topology differs, the cached `channel→trx` (a slice **index**) could be consulted while stale for the transient window before the first `dax!=0` re-resolves. Low impact (self-heals; channels bounded 1–4), but it closes the 2-of-3 inconsistency. One line, alongside the existing sibling clears. ## Test plan - [x] Local build passes (`cmake --build build --target AetherSDR`) - [ ] Behavior verified on a real radio if applicable — multi-receiver disconnect/reconnect; audio routes correctly after reconnect with changed slice topology 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes #3669 (DAX-1 TCI audio silent on connect; second TCP client kills audio — FLEX-6400, 26.6.3, multi-client).
TciServer::onDaxAudioReady()re-derives the destination TCI receiver (trx) on every DAX audio packet by scanningslices()fordaxChannel()==channel, with a positionalchannel-1fallback. That fallback is fragile, and this PR makes the routing resilient to transient slice-state corruption.Root cause (code-confirmed across the whole lifecycle)
slice N dax=0thendax=1during a band/mode retune or when a second client (re)subscribes.SliceModel::applyStatus()zeroesm_daxChannelunconditionally on thedax=0(SliceModel.cpp) — no transient guard.onDaxAudioReady's scan finds no owning slice →trxfalls back tomax(0, channel-1)(TciServer.cpp).tciTrxForSlice()returns the slice's index inslices()(TciProtocol.cpp).Why single-slice looks healthy but multi-receiver breaks: with one receiver on slice 0,
index 0 == channel(1)-1, so the fallback accidentally matches and audio keeps flowing. In a multi-receiver setup — a foreign client owns a slice, pushing our slice to a non-zero index —channel-1diverges from the index-based trx, theaudioReceiver != trxfilter drops the packet, and the correctly-bound client goes silent. This is #3669, and the most likely mechanism behind the "second client kills audio" report.This was established by a full-lifecycle analysis of both support captures: the "healthy" capture (
…-205353, CAT+DAX logging) is single-slice/single-receiver andframes_sentclimbed 96k→4.59M continuously — it never reproduced the trigger; theslice=-1entries in its DAX stat lines are exactly this transient scan-miss, benign only by the index/channel-1 coincidence.Fix
Cache the last successfully-resolved
channel→trxmapping (m_channelTrx); when the live scan transiently misses, route by the cached trx instead of the positional guess. Self-healing — the nextdax!=0status re-resolves and refreshes the cache. Cold start (no mapping yet) keeps the legacy positional behavior, so single-receiver setups are byte-for-byte unchanged. Cache cleared inreleaseDaxForTci()/rearmDaxForProfileLoad()alongside the channel→stream map.+18 / −1 in
TciServer.cpp, +1 inTciServer.h.trxis always assigned a valid value (scan → cache →channel-1default); null-model and cold-start paths are identical to before.Why this is contained (not the #3305/#3715 refactor)
The maintainer asked whether this needs the full refactor. It does not: #3305 (centralize
dax_rxstream create/remove ownership) and #3715 (single slice-lifecycle authority) address the upstream churn that produces the transientdax=0. This PR hardens the frame-routing layer against that churn —onDaxAudioReady's trx derivation is untouched by either refactor and would survive both. TheSliceModeltransient-zero is the upstream cause and is intentionally not changed here (suppressing the radio'sdax=0would fight radio-authoritative state).Validation
slice_label_test,slice_model_letter_test,slice_recreate_policy_test.theme_manager_test:744,cwx_local_keyer_drift_testsegfault) — both in subsystems with 0 references to TciServer/DAX; not exercised by this change (theme_managerhas its own dedicated fix worktree).Regression review across the open TCI/DAX cluster
The change only alters the trx fallback path (when the slice scan misses); the happy path (
tciTrxForSlice) is unchanged. Reviewed against the open cluster — no regressions, and it strengthens the multi-receiver cases:Honesty / scope
cat+connectionON, sustained 2 slices with a foreign-owned slice) would give the final on-hardware confirmation. Requested on the issue.onDaxAudioReadyrouting today (a gap noted for follow-up).sliceRemovedeviction) were contradicted by the captures — registration succeeded cleanly and no reclaim/eviction fired; this PR targets the actually-observed mechanism instead.💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat