Skip to content

fix(dax): break DAX RX stream create/remove storm that drops connection quality to red (#3626)#3796

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/3626-dax-rx-stream-storm
Jun 26, 2026
Merged

fix(dax): break DAX RX stream create/remove storm that drops connection quality to red (#3626)#3796
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/3626-dax-rx-stream-storm

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Fixes #3626

Symptom

On a FLEX-8600M (macOS), connection quality reads EXCELLENT for ~5–10 min
(sometimes ~30 s), then drops to POOR / red with the packet-loss graph
ramping up. While degraded, DAX recordings crackle and chop badly. A restart
resets the cycle. Multiple 8000-series users report the same.

Root cause — a DAX RX stream create/remove storm

Traced entirely from the reporter's attached log (847k lines):

  • AetherSDR sent 20,610 stream create type=dax_rx and 20,609 stream remove commands in one session — a tight loop running at 12–25 Hz.
  • Each cycle re-registered the DAX audio stream (13,188 "first DAX packet
    seq=0"
    resets in the log).

The loop is a feedback cycle specific to 8000-series radios:

  1. MainWindow::onDaxChannelChanged() issues stream create type=dax_rx dax_channel=1 for a slice that already has dax=1.
  2. The radio momentarily detaches the stream and re-broadcasts slice 0 dax=0 immediately followed by slice 0 dax=1 (confirmed in the log; same
    transient-rebroadcast behavior noted for TCI audio (DAX 1) does not start on connect; requires mode change + DAX toggle to recover #3669).
  3. SliceModel::applyStatus mirrors every echo into daxChannelChanged
    (SliceModel.cpp:846), so the reconciler reads the transient dax=0 as a
    real de-assignment → stream remove, then reads the dax=1 that lands
    ~80 ms later as a new assignment → stream create + slice set → back
    to step 2.

Why that turns the connection red

The storm floods the radio's TCP command channel and resets the DAX VITA-49
sequence on every recreate. Each reset is a sequence discontinuity counted in
PanadapterStream::packetErrorCount(), which feeds
RadioModel::networkQualityTargetScore() packet-loss term → the score falls →
NetState walks down to POOR. That is exactly the rising packet-loss graph
and red status the reporter screenshotted. (The variable 5–10 min / 30 s onset
matches a user action — band/profile/slice reconfiguration — kicking off the
first transient; in this capture the storm began at a panadapter
add/remove reconfiguration ~1h27m in.)

Fix

Defer the DAX RX stream teardown in onDaxChannelChanged and re-validate at
fire time (scheduleDaxRxStreamRemoval):

  • A transient dax=0 schedules a removal 1.5 s out instead of firing inline.
  • The dax=1 rebroadcast lands ~80 ms later, so when the timer fires a slice is
    back on the channel and the removal is declined — the loop never starts.
  • Because the stream stays registered across the transient, the dax=1 edge's
    stream create is skipped (already idempotent via daxStreamIdForChannel),
    so the create side is broken too.
  • A genuine user de-assignment has no follow-up rebroadcast → the channel stays
    unwanted → the stream is removed after the grace window. Existing TCI/RADE
    ownership guards are preserved.

Minimal, contained change (2 files). Full DAX RX stream refcounting remains
tracked in #3305.

Proof

Broken baseline (reporter's log, build 26.6.3, FLEX-8600M): 20,610 stream create type=dax_rx + 20,609 stream remove, ramping 12 Hz → 25 Hz; 13,188 DAX
stream re-registrations; trim/packet-loss climbing into the red.

Code trace: the deferred + re-validated teardown collapses each transient
dax=0dax=1 rebroadcast to a single harmless slice set no-op, sending zero
stream create / stream remove — the storm cannot sustain.

Agent automation bridge — could not drive the live path (gap, see below):
the storm only runs when the DAX bridge is active (m_daxBridge != null), which
requires the macOS DAX HAL plugin. The plugin is not installed on the test
Mac (startDax() logs DAX HAL plugin not installed and bails), so
onDaxChannelChanged never runs. The fixed build was confirmed to build, launch,
and run RX-clean against a FLEX-8400M (same 8000-series family/firmware
4.2.18) via the bridge; the DAX-bridge code path itself could not be exercised
here. Recommend validating on a machine with the DAX HAL plugin installed (or
by the reporter).

Out of scope (separate bug, filed separately)

The same logs show DAX RX audio delivered at ~2× real-time from session
start (reduced-BW packets pcc=0x0123, 128 samples/pkt × ~375 pkt/s = 48 kHz
mono), but VirtualAudioBridge/HAL assume 24 kHz, so ~50 % is trimmed every
second → the constant chop and "very quiet at 100 %" the reporter also reports.
That is a rate-assumption bug independent of this storm and needs its own change.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…dr#3626)

On 8000-series FLEX radios, issuing `stream create type=dax_rx
dax_channel=<ch>` while the bound slice already has dax=<ch> makes the
radio momentarily detach the stream and re-broadcast `slice <id> dax=0`
immediately followed by `slice <id> dax=<ch>` again. SliceModel mirrors
every echo into daxChannelChanged, so MainWindow::onDaxChannelChanged read
the transient dax=0 as a real de-assignment, removed the DAX RX stream,
then re-created it on the dax=<ch> that lands ~80 ms later — a
self-sustaining create/remove storm.

In the aethersdr#3626 report (FLEX-8600M) the loop ran at 12–25 Hz: 20,610 `stream
create type=dax_rx` and 20,609 `stream remove` commands in a single
session. The churn floods the radio's TCP command channel and resets the
DAX VITA-49 sequence every cycle (13,188 "first DAX packet seq=0"
re-registrations), inflating PanadapterStream packetErrorCount until the
network-quality monitor drops to POOR — the "connection quality goes red
after 5–10 min, DAX audio drops out" the reporter sees.

Fix: defer the stream teardown and re-validate at fire time. The dax=<ch>
rebroadcast lands long before the 1.5 s grace window elapses, so a slice
is back on the channel and the removal is declined — the loop never
starts. A genuine user de-assignment has no follow-up rebroadcast, so the
channel stays unwanted and the stream is removed after the grace window.
The stream stays registered across the transient, so the dax=<ch> edge's
create is skipped (idempotent), breaking the create side too.

Full DAX RX stream refcounting remains tracked in aethersdr#3305.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Diverdown77

Copy link
Copy Markdown

If this is specific to 8000 radios does any of this pertain to the original #3669 bug still occurring in a 6000 radio? TNX 73

@jensenpat jensenpat changed the title fix(dax): break FLEX-8600M DAX RX stream create/remove storm that drops connection quality to red (#3626) fix(dax): break DAX RX stream create/remove storm that drops connection quality to red (#3626) Jun 25, 2026
…generation race (aethersdr#3626)

Defensive-review follow-up to the storm fix. Two hardening tweaks, no
behavior change to the happy path:

- scheduleDaxRxStreamRemoval: the in-flight QTimer::singleShot was not
  cancelled by stopDax() (singleShot has no handle), and the fire-time
  lambda only guarded on m_daxBridge. A DAX off→on toggle inside the
  1.5 s grace window leaves m_daxBridge non-null again, so a stale timer
  from the previous bridge generation could fire and spuriously
  `stream remove` a channel the new bridge wants (mitigated by
  re-validation, but real). Make the pending set authoritative: bail when
  QSet::remove(ch) reports the key already gone — stopDax()'s clear() now
  genuinely cancels in-flight fires.

- Document that the unconditional `slice set <id> dax=<ch>` re-assert is
  storm-safe only because SliceModel::applyStatus drops same-value dax
  echoes; that equality guard is load-bearing for this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

Excellent root-cause from the 847k-line log. Verified the storm-break logic on both sides:

  • Remove side: scheduleDaxRxStreamRemoval() defers teardown 1.5s and re-validates at fire time (if any slice still has daxChannel()==ch, decline). The dax=ch rebroadcast lands ~80ms later (≪ grace), so the removal is declined and the loop never starts; a genuine de-assignment has no rebroadcast so it's removed after grace.
  • Create side: broken automatically — the stream stays registered, so the daxStreamIdForChannel(newCh)==0 guard (MainWindow_DigitalModes.cpp:1125) skips the create.
  • Timer lifecycle clean: singleShot (no leak), m_daxPendingRxRemoval dedups so timers don't stack, stopDax() clears the set so a stale fire from a previous bridge generation self-cancels (if (!remove(ch)) return), with re-validation as defense-in-depth. TCI/RADE ownership guards preserved.
  • Good call documenting that SliceModel's same-value-echo drop (if (m_daxChannel != ch)) is now load-bearing for this fix.

This also removes another packetErrorCount source feeding the network-quality monitor (same chain as #3810).

Caveat acknowledged: not live-validated on the failing path (DAX HAL plugin absent on the test Mac) — proven by code-trace + the reporter's log. Merging given the contained, airtight change and that 8000-series users are hitting red connections; the reporter should confirm on a DAX-HAL machine. Thanks @jensenpat.

@ten9876 ten9876 marked this pull request as ready for review June 26, 2026 04:42
@ten9876 ten9876 requested review from a team as code owners June 26, 2026 04:42
@ten9876 ten9876 merged commit d8c10f5 into aethersdr:main Jun 26, 2026
6 checks passed
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Hardware validation — FLEX-8400M, both DAX paths ✅

Post-merge validation on a FLEX-8400M (8000-series, fw 4.2.18). Deployed the build to a test Mac and exercised both the TCI and DAX-audio-bridge paths, then pulled logs.

Radio-side trigger confirmed on hardware. On every stream create type=dax_rx the 8400M emits the transient slice dax=0slice dax=<ch> rebroadcast (e.g. dax=2 → dax=0 → dax=2) — the exact behavior root-caused from the 8600M report, now observed directly.

TCI path (…-092656): WSJT-X over TCI → 1 dax_rx create, transient rebroadcast, stable ~17 min stream, clean release, no storm. Confirms the TCI reconciler (ensureDaxForTci) is not echo-driven and was never vulnerable — the fix's scoping to onDaxChannelChanged is correct.

DAX bridge path (…-101017) — the path this PR changes: HAL plugin installed, DAX recording active, heavy profile/band/slice switching →

baseline (8600M report) this build (8400M)
transient dax=0 rebroadcasts 200 / ~3 min
stream create type=dax_rx 20,610 8
stream remove 20,609 5
re-registrations 13,188 20
peak stream-cmds/sec 12–25 sustained 3 (one sec)
disconnect / pings-lost / throttle / POOR → red 0 / 0 / 0 / 0

200 triggers → 8 creates, ~195 deferred teardowns declined by re-validation, connection healthy throughout. A pre-fix build would have been visibly ramping inside this window. Fix confirmed working on real hardware.

The stale-timer bridge-generation hardening (if (!m_daxPendingRxRemoval.remove(ch)) return) is included in the merged build.

Out of scope / follow-up: every DAX RX ch N summary showed written_ms + trimmed_ms ≈ 2× real-time (48 kHz delivered, 24 kHz assumed → ~50 % trimmed). That's the chopped/quiet recording symptom, a separate root cause now tracked in #3837 — untouched by this PR.

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.

FLEX-8600M connection quality drops to red after 5-10 min, causing DAX audio dropouts (macOS)

3 participants