Skip to content

fix(tci): route DAX RX audio by cached channel→TRX, resilient to transient dax=0 (#3669)#3759

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/3669-dax-trx-routing
Jun 23, 2026
Merged

fix(tci): route DAX RX audio by cached channel→TRX, resilient to transient dax=0 (#3669)#3759
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/3669-dax-trx-routing

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

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 scanning slices() for daxChannel()==channel, with a positional channel-1 fallback. That fallback is fragile, and this PR makes the routing resilient to transient slice-state corruption.

Root cause (code-confirmed across the whole lifecycle)

  1. The radio re-broadcasts slice N dax=0 then dax=1 during a band/mode retune or when a second client (re)subscribes.
  2. SliceModel::applyStatus() zeroes m_daxChannel unconditionally on the dax=0 (SliceModel.cpp) — no transient guard.
  3. For that window, onDaxAudioReady's scan finds no owning slice → trx falls back to max(0, channel-1) (TciServer.cpp).
  4. tciTrxForSlice() returns the slice's index in slices() (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-1 diverges from the index-based trx, the audioReceiver != trx filter 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 and frames_sent climbed 96k→4.59M continuously — it never reproduced the trigger; the slice=-1 entries 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→trx mapping (m_channelTrx); when the live scan transiently misses, route by the cached trx instead of the positional guess. Self-healing — the next dax!=0 status 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 in releaseDaxForTci() / rearmDaxForProfileLoad() alongside the channel→stream map.

+18 / −1 in TciServer.cpp, +1 in TciServer.h. trx is always assigned a valid value (scan → cache → channel-1 default); 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_rx stream create/remove ownership) and #3715 (single slice-lifecycle authority) address the upstream churn that produces the transient dax=0. This PR hardens the frame-routing layer against that churn — onDaxAudioReady's trx derivation is untouched by either refactor and would survive both. The SliceModel transient-zero is the upstream cause and is intentionally not changed here (suppressing the radio's dax=0 would fight radio-authoritative state).

Validation

  • Built clean on macOS (RelWithDebInfo / Ninja), full tree incl. tests.
  • Slice test suite passes: slice_label_test, slice_model_letter_test, slice_recreate_policy_test.
  • Two unrelated pre-existing failures (theme_manager_test:744, cwx_local_keyer_drift_test segfault) — both in subsystems with 0 references to TciServer/DAX; not exercised by this change (theme_manager has 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

  • The routing mechanism is code-confirmed; the multi-receiver field break is established by code analysis — the existing captures were single-receiver, so a combined-logging capture (cat+connection ON, sustained 2 slices with a foreign-owned slice) would give the final on-hardware confirmation. Requested on the issue.
  • No unit test covers onDaxAudioReady routing today (a gap noted for follow-up).
  • The agent triage's two earlier hypotheses (registration race; foreign-owner sliceRemoved eviction) 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

…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>
@jensenpat jensenpat marked this pull request as ready for review June 23, 2026 03:55
@jensenpat jensenpat requested a review from a team as a code owner June 23, 2026 03:55

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

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.

@ten9876 ten9876 merged commit c7d93c0 into aethersdr:main Jun 23, 2026
6 checks passed
ten9876 added a commit that referenced this pull request Jun 23, 2026
…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>
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.

TCI audio (DAX 1) does not start on connect; requires mode change + DAX toggle to recover

2 participants