Skip to content

fix(tci): a VFO B request from one client no longer retunes the slice another client operates (#5193) - #5681

Merged
NF0T merged 6 commits into
aethersdr:mainfrom
skerker:fix/5193-tci-vfob-ownership
Sep 15, 2026
Merged

NF0T merged 6 commits into
aethersdr:mainfrom
skerker:fix/5193-tci-vfob-ownership

Conversation

@skerker

@skerker skerker commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5193.

What was wrong. When a WSJT-X instance asks the server to set its VFO B, the server picked the slice that currently holds TX and tuned it. With two WSJT-X instances on two slices, that TX slice belongs to the other instance, so a band change in one instance retuned the other one's slice (#5193). WSJT-X sends the VFO B frame on every band change in Split = Rig mode (None and Fake It never do), which is why the report reads as intermittent.

Why it worked that way. One WSJT-X, TX parked on a second slice for satellite full duplex (#1807): there the TX slice belongs to nobody else, and tuning it as VFO B is exactly what the operator wants.

The fix. The server now checks whether another connected client is operating the TX slice as its receiver. If yes, it does not tune it and echoes the request back. If no, it tunes it as before. Satellite full duplex is unchanged; the two-instance cross-retune is gone (21 of 21 before, 0 of 16 after, same radio).

Mechanics. routingEndpoints() takes the requesting client and flags slices that another client operates, resolved strictly from that client's declared audio_start:<n> receiver (the per-client signal effectiveTrx() already reads, #4547). resolveVfoB() never adopts a flagged slice: without a split request it returns a new RouteAction::EchoOnly and the server replies vfo:<trx>,1,<rx slice frequency> to that client only, the same reply shape as the existing capacity refusal. With a split request the decision falls through to Create.

Unchanged: single-client adoption of an unoperated TX slice (#1807, the assertion the retired tci_protocol_test carried), a requester whose own slice holds TX, and transmit selection (keying from either instance keys the slice it addresses, #4547).

Round 2 (review): the same rule on the PTT path. A VFO B route bound while the TX slice was unclaimed used to survive that slice becoming another client's declared receiver, and resolvePttSlice() trusted it on the next bare PTT — handing that client's slice to this one's key. Now EchoOnly drops such a bind, resolvePttSlice() re-checks ownership of the live TX slice before trusting the cache (an external bind is only ever the live TX slice, so with that slice foreign there is none to keep; a TciCreated route is left in place but refused), and the PTT handler passes the requester to routingEndpoints() so the flags are set there too. PTT keys the requesting client's own slice. The A-side asymmetry in the report (cannot create VFO B: radio slice capacity reached) is unchanged.

Changed in one way: the split flag is one global per server, so any client asserting split turns the echo off for all; the gate then still refuses a slice another client operates and falls through to Create. On a two-slice radio that is the existing capacity refusal; on a radio with more than two slices (FLEX-6500/6600/6700/8600 per ModelCapabilities.cpp) it creates and promotes a new TX slice for the requester instead of adopting the other instance's. Per-client split state is the follow-up the triage named, out of scope here.

Known limit — the gate fails open. A client claims its slice only while it has a declared audio_start:<n> receiver that resolves strictly to a live slice at that moment. Four reachable states where it does not, and the previous adoption applies: (1) startup ordering — until the first instance has sent audio_start, the other instance's channel-1 frame is still adopted and the route is bound (the reporter launches both from a .bat); (2) audio_stop resets the declaration to −1 while the client stays connected; (3) a control-only client, a client whose audio is not carried over TCI, or audio_start with no receiver argument, declares nothing; (4) a declared receiver mid-recreate that the strict resolver cannot map claims nothing. Failing open is deliberate: a read-path guess never refuses a legitimate tune. Both routing docs say so.

Docs: ownership clause added to the VFO B and channel-1 rows of docs/architecture/tci-routing-ordering.md and docs/architecture/tci-receivers.md.

Constitution principle honored

Principle II in shape, applied one hop downstream: the radio's live state is the truth AetherSDR mirrors, and this fix keeps the TCI server from writing over a slice one of its own clients operates on an inference of its own. No principle names the server-to-its-own-clients relationship directly (review round 2).

Test plan

Regression tests, two seams. The decision is pinned at the TciRoutingState seam by extending the registered socket-free tci_trxmap_test: 11 cases covering the two-client echo with no route state written, the TX flag on either slice, #1807 adoption kept, own TX slice shared → Create, split with a claimed slice, a cached route onto a claimed slice, and unflagged endpoints. Mutation: gate removed → the 7 gate cases fail, the rest pass; restored → 11/11. Round 2 adds five cases at the same seam (bind → declare → echo → PTT; PTT with no VFO frame in between; an external bind outliving a GUI TX move; a TciCreated route refused but left in place; split with a foreign live TX slice), 41/41 in the file; mutations: EchoOnly drop removed → 1 fail, ownership flag forced false in resolvePttSlice() → 4 fail, tracked-route ownership check removed → 1 fail, PTT-side drop removed → 3 fail. The routingEndpoints(client) line on the PTT path has no ctest and is covered by the round-2 bench. The ownership join is pinned in tci_server_review_test (review round 1, Blocker 1): one case beside the existing #4547 two-instance fixture asserts routingEndpoints(requester) flags only the other clients' strictly resolved declared receivers — A's slice for requester B and the mirror, both for a control-only requester, nothing with no requester, nothing for a stale declaration, and nothing after audio_stop. That target owns socket cases (ephemeral in-process port, the tests.cmake carve-out note); the added case opens no socket — its QWebSocket objects are unopened identity keys, as in the fixture it sits beside. Mutation: flag assignment removed → the case fails, the other 31 pass; restored → 32/32. Both targets run on full-suite.yml at merge and weekly on sanitizers.yml; neither is on the frozen PR gate (#5405). Static gates pass; no raw wire text added.

Not tested: a radio with more than two slices (the split path above), the split-enabled regime with real WSJT-X (Enable Tx was off throughout), a client requesting a VFO B frequency different from its channel 0 while its TX slice is claimed (the echo returns the RX frequency), control-only clients, Windows and macOS builds.

Proof

FLEX-8400 fw 4.2.20, RX-only into a dummy load, nothing keyed; slices A = 0 (14.074, TX) and B = 1 (7.074); readings over the agent automation bridge, whole app logs in the bundle.

Arm Before 85c9413b After a9b29054
Sim client on rx 1 sends vfo:1,1,<hz>, TX on A A retuned 10/10 (7–20 ms), radio transmit freq= followed echoed to that client only, A held 10/10, 0 slice tune 0
Real WSJT-X ×2, Split = Rig, band change in the RX2 instance A followed B 11/11, RX1 display moved 6 channel-1 frames → 6 echoes, A held, RX1 display still, no rig-control errors
Band change in the RX1 instance (control) cannot create VFO B: … capacity reached, B held same refusal, B held
Single client, TX parked on B, vfo:0,1,<hz> (#1807 shape) B adopted and tuned B adopted and tuned (unchanged)
Two clients sharing receiver 0, TX on A, vfo:0,1,<hz> capacity refusal capacity refusal (unchanged)
Cached route onto a slice another client now operates route honoured echoed, route not honoured

Head 1fb9e762 added only the strict-resolver line; the two-client echo arm was re-run on it: TCI rx: "vfo:1,1,7078000;"vfo:1,1 echoed without tuningtx→client: vfo:1,1,7074000;, A held 14.074.

The round-1 commit on top changes tests and docs only; the binary under proof is unchanged.

Round 2, 60f270b1 (clean build, About over the bridge), FLEX-8400 keyed into a dummy load, 1 W. The review's sequence with the bridge's simulated clients: B binds slice 0 alone, A declares it, B's next VFO B frame is echoed and the cache clears, B's trx keys slice 1 — TCI PTT route: … rxSlice=1(trx1) -> txSlice=1(trx1) (the requested slice) | liveTx=0(trx0) cachedTx=none. PTT-first (no VFO frame between A's declaration and B's key): cachedTx=0(trx0) cachedRx=1(trx1) sampled before the resolve, result slice 1. Two real WSJT-X 3.0.1 instances, Split = Rig, Tune pressed in the RX2 instance: WSJT-X sends vfo:1,1 4 ms before trx:1,true, slice A stayed on 14.074, the carrier went out on slice B; Tune in the RX1 instance keyed slice A. Single client alone still adopts and keys the unclaimed TX slice (#1807). Five keyed events, 5/5 radio-confirmed, 0 timeouts.

  • aethersdr-pr5681-tci-vfob-ownership-2026-09-14.zip — head a9b29054 (plus the parent 0872e461 session for two control arms) · whole app logs, per-arm extracts, scripted-run tables, simulator traces, mutation output, build logs, About-SHA proof, manifest · sha256 0acc99ab…ebf56b · download
  • aethersdr-pr5681-ptt-ownership-bench-2026-09-14.zip — head 60f270b1 · whole app log, PTT-route extract, 50 ms forward-power sampler CSV, bridge log stream, simulator traces, About-SHA screenshot, build logs, instruments · sha256 f75176d2…41e43 · download

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls — n/a, no settings touched
  • Code is clean-room — not decompiled, disassembled, or reverse-engineered from a proprietary binary (Principle IV)
  • All meter UI uses MeterSmoother — n/a, no UI
  • Documentation updated — docs/architecture/tci-routing-ordering.md, docs/architecture/tci-receivers.md; CHANGELOG.md untouched
  • Security-sensitive changes reference a GHSA — n/a

— authored by agent (Claude Code) on behalf of @skerker

skerker and others added 3 commits September 13, 2026 16:50
)

resolveVfoB() adopted the radio-global TX slice as any non-TX receiver's
VFO B. With two WSJT-X instances on two slices (Split = Rig), the non-TX
instance's `vfo:<trx>,1,<hz>` retuned the other instance's slice and the
radio's transmit frequency to its band. routingEndpoints() now flags slices
another connected client operates (its declared audio_start receiver) and
resolveVfoB() answers such a frame with RouteAction::EchoOnly when that TX
slice is not the requester's own; the server echoes the RX slice frequency
and tunes nothing. Single-client adoption (aethersdr#1807 satellite), a requester
whose own slice holds TX, and split-requested routes are unchanged.

Fixes aethersdr#5193.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkDxC9ufZMTbA1JN7JDcp
…dr#5193)

Two-client topology echoes and records no route; TX flag on the other
client's slice echoes too; the aethersdr#1807 satellite adoption still holds; a
requester whose own TX slice is shared still gets Create; a requested
split never names the claimed slice; unflagged endpoints keep the
previous behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkDxC9ufZMTbA1JN7JDcp
…ts (aethersdr#5193)

routingEndpoints() used sliceForTrx(), whose last resort is the first
slice, to map another client's declared audio_start receiver to a slice.
A stale declaration would flag slice 0 and, with slice 0 holding TX,
silence every other client's VFO B. sliceForTrxStrict() claims nothing
when the receiver has no live slice (aethersdr#4547 rule for write decisions).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxkDxC9ufZMTbA1JN7JDcp
@skerker
skerker marked this pull request as ready for review September 14, 2026 01:11
@skerker
skerker requested a review from a team as a code owner September 14, 2026 01:11

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

Issue fit

Partially. The diff does close the mechanism #5193 identifies: resolveVfoB() no longer adopts a foreign TX slice as VFO B when that slice is another connected client's declared receiver, and handleVfoRequest() answers the frame instead of issuing slice tune. Walking skerker's measured transcript (vfo:1,0,10136000split_enable:1,falsevfo:1,1,10136000) against the merged code: routingEndpoints(client) flags slice 0 (RX1's declared receiver), currentTxIsAnotherReceiver is true, m_splitRequested is false, so EchoOnly fires and slice tune 0 is never sent. The #1807 satellite contract ({4,false},{7,true}, nobody operating slice 7) still adopts. The asymmetric control case and the "TX flag moved to B" case both behave as the issue describes.

What is not closed: the guard is entirely conditional on the other client having an audio_start:<n> receiver on file at the moment of the frame, and nothing in this PR tests that the flag is ever actually set. Details in Blockers 1 and 2. EricZimmerman's MSHV comment on the thread is a different symptom (TX never moves to B) and is correctly out of scope here.

Scope

File / group What it changes Claimed by title/issue? Verdict
src/core/TciRoutingState.{h,cpp} operatedByAnotherClient endpoint field + lookup, RouteAction::EchoOnly, gate on the adopt and promote branches Yes In scope
src/core/TciServer.{h,cpp} routingEndpoints(requester) flagging join; EchoOnly reply in handleVfoRequest(); requester passed at the two resolveVfoB() call sites Yes In scope
tests/tci_trxmap_test.cpp six resolveVfoB() cases Yes In scope
docs/architecture/tci-receivers.md, tci-routing-ordering.md VFO B row + channel-1 projection row updated for the echo Yes In scope

Everything in the diff is explained by the issue. No CHANGELOG.md entry (correct). No new wire verb — EchoOnly reuses the existing vfo:<trx>,1,<hz> frame shape, so no new public surface. No removed guard: the pre-existing "Always track the current RX slice / removeSlice() keys off m_rxSliceId" comment and its branch survive intact, just gated. Sibling check: resolveVfoB() has exactly two callers (TciServer.cpp:2107, :2188) and both now pass the requester; the third routingEndpoints() call (:2438, the PTT path) is deliberately left unflagged and that is right — resolvePttSlice() has its own routeApplies gate. No socket test added, removed, or modified; tci_trxmap_test links aethercore Qt6::Core only and is registered at tests/tests.cmake:3735.

Blockers

1. The discriminator itself has no test — every new assertion hand-constructs the flag. (inline on tests/tci_trxmap_test.cpp:103)
All six cases build TciSliceEndpoint{id, isTx, operatedByAnotherClient} literals by hand. Nothing exercises TciServer::routingEndpoints(requester) — the join from m_clients[].audioReceiver through sliceForTrxStrict() to the endpoint flag — which is the entire new decision. If that join produced false for every slice, all six tests still pass and #5193 still reproduces on the reporter's radio. The fix's correctness and its test surface do not overlap.

The seam exists and is already in-repo with the exact fixture: tests/tci_server_review_test.cpp:165 pttBindsToTheDeclaredAudioReceiver() constructs wsjtxA (receiver 0), wsjtxB (receiver 1), and a control-only client as TciServer::ClientState through friend access, socket-free. routingEndpoints(&wsjtxB) there is a handful of lines and pins the three things that matter: A's slice flagged, B's own slice not flagged, the control-only client (audioReceiver == -1) claiming nothing. That target is registered (tests.cmake:3753) and is our own server as subject, so it is squarely inside the AGENTS.md test-layer boundary for a non-event.

2. The guard fails open on audio_start state, and that is undisclosed. (inline on src/core/TciServer.cpp:1658)
operatedByAnotherClient is derived solely from cs.audioReceiver, which is written only by audio_start:<n> (TciServer.cpp:1250) and reset to -1 by audio_stop (:1270). Four reachable states where the guard silently does not apply and the pre-#5193 adoption returns:

  • Startup ordering. The reporter launches both instances from a .bat. Until instance A has sent its audio_start, B's channel-1 frame takes UseExisting, cross-tunes, and binds the route — so the first band change still retunes A even after the fix.
  • audio_stop mid-session clears the receiver while the client stays connected and keeps sending vfo: frames.
  • Audio not routed over TCI, or audio_start with no receiver argument (requestedReceiver stays -1, skipped at :1650). skerker's repro pins audio = TCI; the original report never states the audio configuration, so it is not established that the reporter's own setup produces the signal this fix depends on.
  • Strict resolution miss. Commit 3's sliceForTrxStrict() is the right call for a write gate, but it means a declared receiver that is mid-recreate or unmapped claims nothing — the same trx-map churn already implicated in this file's intermittency.

Fail-open is the right default (better than refusing a legitimate tune), so this is not a design objection — but the PR body and tci-routing-ordering.md currently read as an unconditional rule ("is never adopted either"), and it isn't. Please state the boundary, and it would be worth asking @ricklaut on #5193 whether their WSJT-X instances use TCI audio.

Nits

  • A route bound before the other client declared is not cleared on echo. TciRoutingState.cpp:64-67 deliberately writes nothing, and the comment explains why writing m_rxSliceId would be wrong. The converse is unhandled: if UseExisting already bound (m_rxSliceId=1, m_txSliceId=0) — the startup-ordering case above — echoing leaves that binding intact, so resolvePttSlice()'s routeApplies (TciRoutingState.cpp:113) stays true for a route the server would no longer grant. No cross-tune results (it resolves to the live TX slice anyway), so this is genuinely a nit, but it is the same staleness class the surrounding comments guard against.
  • m_splitRequested is global, so one client can disable another's guard. handleSplitRequest() sets it on the single shared m_routingState from whichever client asked. Client A's split_enable:0,true makes currentTxIsAnotherReceiver && !m_splitRequested false for client B's next channel-1 frame; B then falls to Create (pinned by the "split with a claimed TX slice" test), which on a 6300 is refused by the capacity guard but on a 6600 creates a slice and promotes TX onto it — moving TX off A's slice. Strictly better than the pre-fix cross-tune, and the issue thread already names per-client route state as the durable fix, so I read this as follow-up rather than a defect in this PR — worth a maintainer eye on whether it should be sequenced before this ships broadly.

What I tried to break

  • Replayed the measured transcript through the merged code rather than trusting the summary: EchoOnly is unreachable from handleSplitRequest() (it calls setSplitRequested(true) at :2175 before resolveVfoB() at :2188), so the un-handled EchoOnly in that function is dead rather than a silent split failure. Confirmed by reading, not by running.
  • Tried to make the requester flag its own slice. currentTx != rxSliceId short-circuits before operatedByAnotherClient(), and cs.socket == requester excludes the requester from the loop, so the "second client shares my slice" case still takes Create — the test at sharedOwnTx pins exactly that and it is not self-referential.
  • Attacked the tests for tautology. They are not: EchoOnly does not exist on main, so they cannot compile against the unfixed code, and the "bare PTT after an echo still keys the requested slice" assertion would fail if the echo branch recorded m_rxSliceId — it is checking the comment's claim, not restating it. The unflagged case pins the no-requester caller. The gap is coverage, not circularity.
  • Checked the #1807 and #4547 contracts I expected this to break. resolvePttSlice() is untouched and its routeApplies gate is independent; the satellite adoption survives; the PTT-path routingEndpoints() at :2438 is intentionally unflagged.
  • Could not check: anything at runtime. I have no build and no radio — every statement above is reasoned from the head checkout at /tmp/aetherclaude/pr-5681, not reproduced. I also cannot verify whether WSJT-X emits audio_start in the reporter's configuration, which is the load-bearing assumption in Blocker 2; that one needs the reporter or a bench run.
  • CI is green on 1fb9e76 (build, check-macos, check-windows, static checks, sanitizer configure) — note ctest in ci.yml is -R-filtered, so that green does not say tci_trxmap_test ran.

Recommendation

Request changes — narrowly, for Blocker 1. The routing logic reads correct and the reproduction evidence in the issue thread is the strongest I have seen on a TCI bug, but the one step that decides whether any of it fires is untested at a seam that already exists in tci_server_review_test.cpp with the exact two-instance fixture. Add that, disclose the audio_start dependency from Blocker 2 in the body and the ordering doc, and this is ready. The nits are follow-up, not merge conditions.

Nice work on the strict-resolver commit in particular — using the fail-closed resolver for a decision that gates a write is the right instinct, and the reasoning in TciRoutingState.cpp:45-58 about why only a foreign TX slice can be another client's receiver is exactly the distinction that makes this safe for the single-client cases.


🤖 aethersdr-agent · cost: $7.5021 · model: claude-opus-5

Comment thread tests/tci_trxmap_test.cpp
Comment thread src/core/TciServer.cpp
Comment thread src/core/TciRoutingState.cpp
aethersdr#5193)

Review round 1: the join from each other client's declared audio_start
receiver to TciSliceEndpoint::operatedByAnotherClient was untested (the
tci_trxmap_test cases hand-build the flag). One case in
tci_server_review_test beside the aethersdr#4547 two-instance fixture pins it;
it opens no socket. Routing docs now say the claim is only as live as the
declaration (before audio_start, after audio_stop, strict miss: fail-open).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9xju8rZtZ4vjiDS55Vx5U
@skerker

skerker commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1, addressed in 2f4f5934 (tests and docs only; the binary under proof is unchanged). PR body updated.

Blocker 1. Added routingEndpointsFlagOnlyForeignDeclaredReceivers() in tci_server_review_test.cpp, beside pttBindsToTheDeclaredAudioReceiver(). Requester B → A's slice flagged, its own not; requester A → the mirror; control-only requester → both flagged; no requester → nothing flagged; a declared receiver with no live slice → nothing; after audio_stop → nothing. Mutation: flag assignment removed → the case fails, the other 31 pass; restored → 32/32. The case opens no socket.

Blocker 2. Logic unchanged. The VFO B and channel-1 rows in tci-routing-ordering.md and the channel-1 paragraph in tci-receivers.md now say "currently operates", defined as a declared audio_start:<n> receiver that resolves strictly at that moment; before audio_start, after audio_stop, or on a strict miss the slice is unclaimed and the previous adoption applies, fail-open by design. The body's Known limit paragraph lists the four states.

Nits. Blockers only this round; the stale bound route on echo stays a follow-up alongside per-client split state.

— authored by agent (Claude Code) on behalf of @skerker

@skerker
skerker marked this pull request as ready for review September 14, 2026 16:24
@skerker skerker changed the title fix(tci): never adopt another client's receiver as VFO B (#5193) fix(tci): a VFO B request from one client no longer retunes the slice another client operates (#5193) Sep 14, 2026
@NF0T NF0T self-assigned this Sep 14, 2026

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

What's correct

Walked the full resolveVfoB() branch tree by hand against every case in tests/tci_trxmap_test.cpp and tests/tci_server_review_test.cpp — not re-derived from the PR's own description. The core mechanism is right: routingEndpoints(requester)'s join from each other client's declared audio_start:<n> receiver, through sliceForTrxStrict(), to TciSliceEndpoint::operatedByAnotherClient, correctly identifies a foreign TX slice as unavailable for adoption/promotion, and EchoOnly answers the frame without touching another slice. Traced separately and confirmed correct:

  • The requester's own shared TX slice still takes Create, not EchoOnly (the currentTx == rxSliceId short-circuit fires before the flag is ever consulted).
  • EchoOnly is unreachable from handleSplitRequest()setSplitRequested(true) always runs first, so that branch is dead there, not a silent split failure.
  • The #1807 satellite contract (unoperated external TX slice) and the resolvePttSlice()/#4547 gate are both untouched and still correct.
  • Round-1's two fixes are real: routingEndpointsFlagOnlyForeignDeclaredReceivers() in tci_server_review_test.cpp genuinely exercises the ownership join (not hand-built flags), and the audio_start/audio_stop/no-live-slice fail-open states are now disclosed in the PR body and both routing docs, matching the actual handler code at TciServer.cpp's audio_start/audio_stop blocks.

Scope

File / group Claimed by issue? Verdict
src/core/TciRoutingState.{h,cpp} Yes In scope
src/core/TciServer.{h,cpp} Yes In scope
tests/tci_trxmap_test.cpp, tests/tci_server_review_test.cpp Yes In scope, both pre-registered targets (tests/tests.cmake:3884,3902) — no new registration needed
docs/architecture/tci-receivers.md, tci-routing-ordering.md Yes In scope

No CHANGELOG.md entry (correct). No new wire verb. Fix, not preference — root-caused, reproduced 21/21 → 0/16 on real hardware (FLEX-8400), matches the existing #4547 PTT-path precedent.

CI

All five checks pass on HEAD 2f4f5934. Neither tci_trxmap_test nor tci_server_review_test is on the frozen per-PR gate (.github/ci-test-gate.txt), so this green run did not execute either target — confirmed against the gate file, not assumed. They run unfiltered on full-suite.yml at merge and weekly under sanitizers, per the PR's own disclosure.

Blocker

The EchoOnly branch's "records nothing" is only safe against a route that was never bound. It is not safe against one that was legitimately bound earlier and has since gone stale, and that reopens the exact bug class this PR closes — one hop later, on the PTT path instead of VFO-B, which is worse: a transmit misroute instead of a stale displayed frequency. Concretely (inline on TciRoutingState.cpp):

  1. TX sits on slice X, unclaimed (the #1807 shape). Client B's vfo:1,1 legitimately binds m_rxSliceId=B_rx, m_txSliceId=X, owner=External via the pre-existing UseExisting branch.
  2. Client A later declares audio_start on X's trx. operatedByAnotherClient(X) is now true.
  3. B's next vfo:1,1 (sent on every band change, per this PR's own repro) now takes EchoOnly — correctly, nothing is tuned — but the stale m_rxSliceId=B_rx / m_txSliceId=X binding from step 1 is left untouched, by design.
  4. B sends a bare PTT. resolvePttSlice() (unchanged) checks m_rxSliceId == rxSliceId — still true from the stale bind — so routeApplies=true, and it returns currentTx = X: client A's own operated slice, not B's.

Nothing invalidates m_routingState when a client's declared receiver changes (audio_start/audio_stop only touch ClientState), so there's no other hook that catches this. And the only new EchoOnly regression test constructs a fresh TciRoutingState before calling resolveVfoB() (inline comment on the test) — it never sets up a prior legitimate bind first, so this exact precondition ships untested.

Suggested fix shape: when EchoOnly fires, invalidate the cached route for this rx slice (clear m_rxSliceId/m_txSliceId when the cached m_txSliceId has become operatedByAnotherClient) rather than leaving it — or have resolvePttSlice()'s routeApplies check re-validate ownership before trusting the cache. Either way, a test that binds via UseExisting, flips the bound slice to operatedByAnotherClient, calls resolveVfoB() again to get EchoOnly, and then asserts what resolvePttSlice() returns would have caught this.

Forward guidance (non-blocking)

  • Constitution citation: Principle II's actual text (CONSTITUTION.md:63-88) is about AetherSDR-as-client not overriding the radio's live state ("the client never writes its remembered value back over the radio's") — a different relationship than this fix, which is the TCI server not overriding a downstream client's slice ownership. Right shape, wrong relationship; worth tightening the squash-commit citation, though no principle in the 14 is a clean fit for server-vs-own-downstream-client ownership.
  • #5682 overlap: jensenpat's same-day RFC ("Isolate TCI RX/TX audio from UI scheduling") explicitly names this PR as overlapping work to refresh before implementation. Worth flagging for the maintainer: routingEndpoints(const QWebSocket* requester) and the cs.socket == requester comparison this PR adds are new call sites keying client identity off a raw QWebSocket*, which is exactly what #5682's proposed worker design says must become an opaque client ID before TCI routing can move off the UI thread. Not a defect today (single-threaded), just more surface for that future migration.

What I tried to break

  • Hand-traced all five branches of resolveVfoB() (two-client echo, TX-flag-swapped echo, #1807 adoption, split-with-claimed-slice, requester's-own-shared-TX-slice) against every new test case — four confirmed correct, the fifth is the blocker above.
  • Tried to make the requester's own slice trigger EchoOnly via the ownership flag — blocked by the currentTx == rxSliceId short-circuit, confirmed against sharedOwnTx.
  • Traced EchoOnly reachability from handleSplitRequest() — dead branch there, confirmed by call order.
  • Verified every source-line claim in the PR body (sliceForTrxStrict, audio_start/audio_stop handlers, ClientState fields, test registration, CI gate membership) against actual file contents rather than the description.
  • Could not verify at runtime — no build, no radio, no bridge session; everything above is reasoned from source.

Recommendation

Request changes, narrowly, for the stale-cache/PTT gap above. The ownership mechanism itself is correctly designed and both of the prior round's blockers are genuinely fixed — this is one more edge in the same class, not a redesign. Nice work tracing the #1807/#4547 precedent and keeping the fail-open disclosure honest in both the body and the docs; that discipline is exactly what made this gap traceable in the first place.


👨🏼‍💻 Co-authored by Claude Sonnet 5

Comment thread src/core/TciRoutingState.cpp
Comment thread tests/tci_trxmap_test.cpp
@NF0T NF0T removed their assignment Sep 14, 2026
skerker and others added 2 commits September 14, 2026 16:06
aethersdr#5193)

Review round 2: a route bound while the TX slice was unclaimed survived
that slice becoming another client's declared receiver, and
resolvePttSlice() trusted it on the next bare PTT. EchoOnly now drops a
stale external bind; resolvePttSlice() re-checks ownership of the live
TX slice before trusting the cache (a TciCreated route is kept for
teardown but refused; a negotiated non-TX slice is still promoted); the
PTT handler passes the requester so the flags are set on that path.
Four tci_trxmap_test cases pin each mechanism; routing docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XZCrCU9XP5Re7g2TYSrNq
… client's (aethersdr#5193)

Adversarial pass on 9f9532e: an external bind onto a slice the operator
had since moved TX away from survived the drop (it only fired when the
cached slice was the live one) and a bare PTT promoted that slice. An
external bind is only ever the live TX slice, so with that slice foreign
there is none to keep: both drop sites now key on owner alone. Comments
and the routing doc row no longer claim a refused TciCreated route stays
tracked for teardown (the Create branch untracks it; pre-existing).
One tci_trxmap_test case for the moved-TX shape; 41/41.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XZCrCU9XP5Re7g2TYSrNq
@skerker

skerker commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@NF0T Review round 2, addressed in 9f9532eb + 60f270b1 (the second from my own adversarial pass: an external bind onto a slice TX had since left also survived the drop).

Blocker (stale bind → PTT). Confirmed in source; pre-existing on the PTT path. Both suggested shapes are in:

Change Where
A stale external bind is dropped (an external bind is only ever the live TX slice) resolveVfoB() EchoOnly
Ownership of the live TX slice re-checked before the cache is trusted; a TciCreated route is left in place but refused resolvePttSlice()
The requester is passed, so the flags are set on this path too PTT handler

Five new tci_trxmap_test cases (41/41), incl. the review's bind → declare → echo → PTT sequence and the PTT-first ordering; each mechanism has a failing mutation (1 / 4 / 1 / 3 cases). The requester one-liner has no ctest; the bench covers it. Docs updated; they now say plainly that a refused TciCreated route is untracked on the next VFO B frame (pre-existing).

Bench — FLEX-8400 into a dummy load, clean build of 60f270b1, 5 keyed events, 5/5 radio-confirmed. Bundle on the PR body.

Arm Result
Bridge TCI simulator: B binds slice 0 alone → A declares it → B's VFO B echoed → B keys slice 1, cachedTx=none
Bridge TCI simulator: same, B keys with no VFO frame after A's declaration cachedTx=0 cachedRx=1 before the resolve → slice 1
Two WSJT-X 3.0.1 instances, Split = Rig, Tune in RX2 (slice B) vfo:1,1 arrives 4 ms before trx; slice A held; carrier on slice B
Two WSJT-X 3.0.1 instances, Split = Rig, Tune in RX1 (slice A) carrier on slice A
Bridge TCI simulator: single client binds and keys (#1807) slice 0, route kept

Principle II. Body sentence reworded as suggested; the squash message is the maintainer's call.

#5682. routingEndpoints(const QWebSocket*) / cs.socket == requester key identity off the socket pointer, single-threaded today — the surface the RFC's opaque client IDs would replace.

— authored by agent (Claude Code) on behalf of @skerker

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

Round 2 re-review — approving

My round-2 CHANGES_REQUESTED blocker is fixed. Recap: an EchoOnly decision left a previously-bound external route in TciRoutingState untouched, so a stale cache could hand a bare PTT to a slice another client now operates — a transmit misroute, one hop worse than the original #5193 bug.

9f9532eb + 60f270b1 close it with both suggested shapes: resolveVfoB()'s EchoOnly branch now drops a stale external bind unconditionally on m_owner == External (not gated on which slice it names — the adversarial follow-up commit caught and fixed that narrower version), and resolvePttSlice() independently re-validates live-TX ownership before trusting the cache, closing the "PTT with no VFO frame in between" ordering that the first fix alone can't reach. Traced all four reachable orderings (stale bind → echo → PTT, PTT-first, external bind surviving a GUI-driven TX move, a TciCreated route correctly left in place but refused) against the new tci_trxmap_test cases — all match.

Round 1's blocker (the ownership join itself was untested) is also confirmed fixed: routingEndpointsFlagOnlyForeignDeclaredReceivers() exercises the actual join, not hand-built flags. The fail-open disclosure in the PR body and both routing docs checks out against the real audio_start/audio_stop handlers, and issue #5193's thread confirms the reporter's setup (TCI audio, Split=Rig both instances) matches the guard's dependency.

CI: green on 60f270b1 (build, check-macos, check-windows, static checks, sanitizer-configure). Neither tci_trxmap_test nor tci_server_review_test is on the frozen per-PR gate, so this doesn't confirm they ran in CI — they run unfiltered on full-suite.yml at merge and weekly sanitizers, per the PR's own disclosure.

Scope: every file maps to the issue or to review rounds 1/2; no CHANGELOG entry, no new wire verb, no unrelated churn.

This round is code-level only — no bridge/hardware re-verification was done for this specific edit; skerker's own bench (60f270b1, FLEX-8400, 5/5 radio-confirmed keyed events) is the hardware evidence for this head.

Approving. Squash-merging as Tier 3 (src/core/, tests/, docs/architecture/ all @aethersdr/reviewers).


👨🏼‍💻 Co-authored by Claude Sonnet 5

@NF0T
NF0T merged commit 03b832a into aethersdr:main Sep 15, 2026
5 checks passed
@NF0T NF0T removed their assignment Sep 15, 2026
ten9876 added a commit that referenced this pull request Sep 17, 2026
## Summary

Implements RFC #5468 A4. TCI receive audio now consumes typed per-slice
PCM, resolves live slice identity through `TciTrxMap`, and converts from
the producer's actual 24/48 kHz rate with independent left/right
histories. This prevents antiphase cancellation, accepts typed 48 kHz
input, and preserves the independent Flex DAX24 route and its channel
gain. Speaker audio does not feed TCI.

Each client/source has a continuous converter. Epoch changes, forward
gaps, accepted format/rate changes, subscription changes,
removal/recreation, disconnect and backend replacement discard the
affected staging/history; replay cursors persist across resets. The
shared text-command handler uses `QPointer` guards and refetches client
state for each command, including when reentrant callbacks grow or
remove the client list. Accepted rate changes discard samples staged at
the old rate.

Wire negotiation accepts only the published **8/12/24/48 kHz** rates.
Unsupported requests, including 44.1 kHz, retain and echo the prior
accepted rate without resetting staged audio. Internal 44.1 kHz
converter vectors remain covered; no 44.1 kHz wire or TX support is
introduced. The default remains 48 kHz. `DEFAULT_SAMPLE_RATE` remains
24000, `m_rxOutputRate` remains the sound-device rate, and
RTL48/multiple-receiver runtime remains disabled. A5 stays separate.

Input is bounded to 65,536 frames, source pins to 32 (at most 16 MiB
retained PCM), and output packets to 1,024 frames. Invalid pending-byte
counts or a next packet exceeding the 256 KiB RX admission limit
stop/reset audio and request graceful close of the shared audio/CAT/PTT
connection. Existing cleanup runs when disconnect is delivered; this is
not a bound on all socket writers, disconnect timing or real-radio unkey
time. Short/failed sends stop/reset audio without explicitly closing.
Both paths log the reason, available peer and byte counts once when
audio stops. Other clients continue; sent-frame accounting includes only
fully accepted packets.

The receive contract, delay table, committed transport history and
lifetime limits are documented in `docs/tci-receive-audio.md`. The table
explicitly labels 44.1 kHz as internal converter evidence.

## Operator-visible changes

- **A cold DAX channel no longer guesses.** The old code fell back to a
positional `trx = channel - 1` when no mapping had resolved; #3669
documented that guess misrouting audio into the wrong receiver. The
owner is now resolved against live model state for every packet, and a
packet whose channel no slice claims is dropped rather than misrouted.
Dropping is the safer failure, and the window is only before the radio
has reported the channel assignment.
- **A DAX route now survives a slice re-create** (fixed during review).
A slice removal tombstoned its DAX route, and because a DAX producer
owns a stream/channel rather than a slice, nothing ever revoked it — the
route could never recover and TCI RX audio on that channel went silent
for the rest of the session. Flex band recall drops and re-creates a
slice with the same id, so this was reachable by a routine operator
action. Pinned by `daxRouteSurvivesSliceRecreate`.

## Constitution principle honored

Principle VII: receive-boundary format, identity, liveness, continuity
and capacity checks. Principles X and XI: producer claims expire with
their epochs, and behavioral regressions have demonstrated negative
controls.

## Validation

- Current head: `c7e533e43108b61b2a4e5ed76ea9d7c28e1b5af6`.
- Native Mac production engine and four focused socket-free CTests: 4/4
pass.
- New regressions fail against the prior implementation. Two separate
mutations (restore 44100 wire acceptance; omit short-send audio stop)
fail their intended assertions; restored controls pass. Tests cover
retained default/prior rates, exact PCM continuity, short/failed sends
across later batches, healthy-client isolation, one diagnostic per
failure, and socket deletion inside a failed send.
- Current Mac ASan/UBSan: 4/4 focused tests pass without sanitizer
reports. Production engine and test object instrumentation verified;
Homebrew Qt and system Opus are uninstrumented. ASR, RADE, DSTAR, MQTT
and RTL are disabled for this focused configuration; no leak-detection
claim.
- Registration, frozen CI gate, engine boundary, command-plane and
capability checks pass. No CI allow-list change.
- GitHub checks at the current head: 7/7 pass, including Linux, macOS
and Windows builds.
- Prior broader converter/mutation and instrumented Linux Qt TSan
evidence belongs to baseline `616e8b45`; TSan was not repeated for this
review-fix delta.

The four focused tests are `pcm_frame_test`, `tci_rx_converter_test`,
`tci_rx_audio_test` and `tci_trxmap_test`. Their applicable default
registrations run in unfiltered main/weekly lanes; new RX tests are
outside the frozen per-PR allow-list. Unopened QWebSocket objects use
injected send/backlog behavior and open no sockets. The existing mixed
`tci_server_review_test` was compiled at this head, not executed. No GUI
playback, native-client interoperability, device/radio or RF/TX
qualification is claimed.

## Integration

Includes main `03b832aa` and its merged #5681 routing fix. A3 #5720 is
not imported. rfoust's TX ownership work in #5659 remains separate; this
delta adds no TX decoding or authority change. Whole-track #5554
sign-off remains outstanding.

## Checklist

- Signed Ozy311 noreply commits; GitHub verification checked after push.
No new coauthor trailers.
- No new settings, UI, backend-family decisions, dependencies or
CHANGELOG entry.
- Clean-room work based on the published TCI protocol and project
source.

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Opus 5 (1M context) <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.

[bug] FLEX-6300 v26.8.4 — Slice B TCI band change can also retune Slice A when running two WSJT-X instances

2 participants