Synchronize TGXL startup state before presence - #5339
Conversation
|
Addressed the issue-triage recommendations in signed commit
I did not add Focused tests, registration validation, and the strict engine-boundary check pass locally. |
There was a problem hiding this comment.
Issue fit
Partially. #5338 asks for three things: carry tuner identity in TunerDelta, apply the complete initial snapshot inside applyChanges(), and publish presence only after all fields are current. All three are delivered, and the payoff is real rather than theoretical — MainWindow_Wiring.cpp:6022 reads tgxlIp() from inside the presenceChanged(true) handler to auto-connect the direct port-9010 socket, so a presence edge that fires before ip lands genuinely mattered. tuner_model_test's ipAtPresence assertion pins exactly that.
What the issue does not ask for is the third change in the diff: the replacingPlaceholder widening of the amplifier routing predicate at RadioModel.cpp:9985-9988. That is a separate defect class (#4203's model-less-status edge), it is the one hunk with no test, and I believe it is a regression — blocker 1.
Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/backends/TunerDelta.h |
adds handle field |
yes — issue's "carry tuner identity in TunerDelta" |
In scope |
src/core/backends/flex/FlexBackend.cpp |
populates d.handle in decodeTunerStatus |
yes | In scope |
src/models/TunerModel.cpp |
applies d.handle; presence edge computed across the whole delta |
yes — this is the fix | In scope |
src/models/RadioModel.cpp — atu branch (9911-9917) |
drops the pre-decode setHandle, folds handle extraction into the decode call |
yes — issue names the atu entry point explicitly |
In scope |
src/models/RadioModel.cpp — amplifier branch, replacingPlaceholder (9985-9988) |
widens which statuses route to TunerModel |
no — #5338 says nothing about routing, only about ordering | Out of scope, and a regression — see blocker 1 |
tests/aetherd_tuner_decode_test.cpp |
handle param + placeholder-carry case | yes | In scope |
tests/tuner_model_test.cpp |
presence-observer, placeholder→real, direct-presence cases | yes | In scope |
No CHANGELOG.md entry — correct. No AppSettings calls, no new public surface, no deleted guards other than the two setHandle calls the issue asked to remove (setHandle itself survives and is still used by the three removal/reset paths at RadioModel.cpp:6941/9966/9979, so it is not dead). No UI defaults or styling touched, so nothing to classify under the preference check.
Blockers
1. replacingPlaceholder routes PGXL statuses into TunerModel and starves AmpModel — inline at src/models/RadioModel.cpp:9985.
The new third disjunct is unconditioned on model or handle. Once m_tunerModel.handle() == "0x00000000", every amplifier <handle> ... status takes the tuner branch, and because the AmpModel decode is the else of that branch, the amp decode is skipped entirely. Concretely, a model=PowerGeniusXL status arriving while the TGXL is still holding the placeholder:
decodeTunerStatus(pgxlHandle, kvs)setsFlexBackend::m_tunerHandle = pgxlHandle(FlexBackend.cpp:977-978), sotuner.operate/tuner.bypass/tuner.autotunethen encodetgxl … handle=<PGXL>.decodeAmplifierStatusatFlexBackend.cpp:950carries an explicit guard against precisely the mirror image of this ("Defense in depth (#4203) … refuse to cache a known-tuner handle so a lateramp.operatecan never mis-target the TGXL") — this change opens the direction that guard does not cover, andtgxl autotuneis a transmit-keying command.d.handleandd.modeloverwriteTunerModel's identity with the PGXL's, soTunerModel::modelName()becomesPowerGeniusXL.m_meterModel.setTgxlHandle(pgxlHandle)fires, sincehandle != "0x00000000" && handle != m_tunerModel.handle().AmpModelnever sees the status, so PGXL presence never latches — no amp applet, no auto-connect atMainWindow_Wiring.cpp:6068.
On main this cannot happen: a PGXL status fails both model == "TunerGeniusXL" and handle == m_tunerModel.handle() and falls correctly to decodeAmplifierStatus. The placeholder being stored in the model is not new — the pre-existing else if (m_tunerModel.handle().isEmpty()) branch stored it too — so the window this opens is a live state on any station running a TGXL, and it stays open indefinitely if a real handle never arrives.
The case the disjunct is presumably aimed at is a model-less TGXL status carrying the real handle while the model still holds the placeholder. Two narrower shapes that don't swallow the amp:
- gate on the absence of an attributing model —
|| (replacingPlaceholder && model.isEmpty()); or, better, - stop adopting
0x00000000as identity at all, sohandle == m_tunerModel.handle()never matches the placeholder and the disjunct is unnecessary.decodeTunerStatus's own comment already assumes this ("RadioModel passes the handle it already extracted+sanitized (never the 0x00000000 placeholder)").
Either way this hunk wants its own test, and arguably its own PR against #4203 rather than riding on the ordering fix.
Nits
- The one risky hunk is the one with no coverage. Both new tests drive
TunerDelta/decodeTunerStatusdirectly; neither exercisesRadioModel::onStatusReceived, so nothing in the suite would catch blocker 1. A socket-free case feeding amodel=PowerGeniusXLamplifier status while the tuner holds the placeholder, and assertingAmpModelpresence, would pin it. - Edge signals now precede presence.
antennaAChanged/tuningChangedare emitted mid-loop inapplyChanges, i.e. before the newpresenceChanged. OnmainsetHandlepublished presence first. A first delta carryingtuning=1now emitstuningChanged(true)for a tuner the rest of the app does not yet consider present.TunerApplet.cpp:282only restyles, so I don't think this bites today — but it inverts the ordering the fix is otherwise establishing, and moving the presence emit above the field block would cost nothing. - Stale comment.
FlexBackend.cpp:974-976still claims RadioModel never passes the placeholder;RadioModel.cpp:9996now passes rawhandleunconditionally. The claim was already loose onmain; this makes it plainly wrong.
What I tried to break
- "Presence only after all fields are current." Held for the fields the delta carries —
wasPresent/nowPresentbracket the entire field block andpresenceChangedprecedesstateChanged. The one seam is the edge signals above. - Direct-connection presence with no radio handle.
isPresent()is!m_handle.isEmpty() || m_directPresence, anddecodeTunerStatusleavesd.handledisengaged on an empty handle, so a handle-less delta cannot clear direct presence. The third new test case pins this; I could not construct a delta that spuriously re-emits the edge. - The
atubranch rewrite. Walked it against the deleted code for: no regex match (both skip), match with model handle already set (both keep the existing handle), direct-presence-but-no-handle (both adopt the radio's own ATU handle — unchanged behavior, not a new defect). I could not find a divergence. - Meter-handle sequencing.
m_meterModel.setTgxlHandlenow readsm_tunerModel.handle()before the decode updates it rather than aftersetHandle. Traced placeholder→real:0x00000000first status sets meter handle 0 via theisEmpty()branch, real handle then satisfies the first branch. Correct under both direct and queuedtunerChangeddelivery. - Removal paths.
setHandle({})at 6941/9966/9979 still clears presence; the newd.handlecannot resurrect it, since a removal returns beforedecodeTunerStatus. - The tests against the unfixed code. They cannot compile pre-
TunerDelta::handle, so I checked them against a mutated fix instead: deleting thepresenceChangedemit failspresence.count() == 1, and deleting thed.handleapply fails the placeholder→real case. They are not self-referential.
CI is green on 6f82e4b (build, check-macos, check-windows, Static checks), but ci.yml -R-filters ctest, so green does not mean tuner_model_test ran — it establishes compile/link only. Everything above is reasoned from the head checkout at /tmp/aetherclaude/pr-5339; I have no build or hardware here, so the PGXL misrouting is a code-path argument, not an observed failure. The quickest disproof would be a station with both a PGXL and a TGXL where the TGXL's first amplifier status carries 0x00000000 — if that placeholder frame never actually occurs on real firmware, blocker 1 collapses (and so does the motivation for the disjunct).
Recommendation
Request changes, narrowly. The ordering fix that #5338 asks for is well-built and well-tested — it should land. The replacingPlaceholder disjunct is a different fix for a different defect, it has no test, and as written it lets a PGXL status capture the tuner's identity and encode handle while hiding the amp. Drop it from this PR and the rest is ready; if the model-less-status edge is worth fixing, it deserves its own issue against #4203 with a routing test, ideally by refusing to adopt 0x00000000 as identity rather than by widening the predicate. Thanks for the clear issue write-up and for being upfront that this one came from audit rather than a user report — that framing made the review much easier.
🤖 aethersdr-agent · cost: $6.2469 · model: claude-opus-5
| const bool replacingPlaceholder = | ||
| m_tunerModel.handle() == QLatin1String("0x00000000"); | ||
| if (model == "TunerGeniusXL" || handle == m_tunerModel.handle() | ||
| || replacingPlaceholder) { |
There was a problem hiding this comment.
Blocker. This third disjunct is unconditioned on model and on handle, so while the tuner holds the 0x00000000 placeholder, every amplifier status takes the tuner branch — and since the AmpModel decode is the else of this branch, PGXL statuses are swallowed entirely.
For a model=PowerGeniusXL status arriving in that window:
decodeTunerStatuscachesm_tunerHandle = <PGXL handle>(FlexBackend.cpp:977), sotuner.autotunethen encodestgxl autotune handle=<PGXL>.FlexBackend.cpp:950carries an explicit FlexBackend: guard amp-handle cache against a mis-routed tuner handle #4203 guard against exactly the mirror of this; this opens the direction it doesn't cover.- the new
d.handle/d.modeloverwriteTunerModel's identity with the amp's. AmpModelpresence never latches — no PGXL applet, no auto-connect.
On main a PGXL status fails both existing disjuncts and routes correctly. Storing the placeholder is pre-existing (the isEmpty() branch below already did it), so this window is live on any TGXL station, and permanent if a real handle never arrives.
If the target is the model-less-TGXL-status edge, gate on that rather than on placeholder state alone:
| const bool replacingPlaceholder = | |
| m_tunerModel.handle() == QLatin1String("0x00000000"); | |
| if (model == "TunerGeniusXL" || handle == m_tunerModel.handle() | |
| || replacingPlaceholder) { | |
| const bool replacingPlaceholder = | |
| m_tunerModel.handle() == QLatin1String("0x00000000") | |
| && model.isEmpty(); // never capture an attributed (PGXL) status | |
| if (model == "TunerGeniusXL" || handle == m_tunerModel.handle() | |
| || replacingPlaceholder) { |
Cleaner still would be to stop adopting 0x00000000 as identity at all — decodeTunerStatus's own comment already assumes RadioModel never passes it — which makes this disjunct unnecessary. Either way it needs a routing test; nothing in the suite exercises onStatusReceived.
| m_meterModel.setTgxlHandle(handle.toUInt(nullptr, 0)); | ||
| } | ||
| if (m_flexBackend) m_flexBackend->decodeTunerStatus(m_tunerModel.handle(), kvs); // #4092/#4198 | ||
| if (m_flexBackend) m_flexBackend->decodeTunerStatus(handle, kvs); // #4092/#4198 |
There was a problem hiding this comment.
Now passes the raw handle, which can be the 0x00000000 placeholder. That contradicts FlexBackend.cpp:974-976 ("RadioModel passes the handle it already extracted+sanitized (never the 0x00000000 placeholder)") — the claim was already loose on main, but this makes it plainly wrong. Worth either updating the comment or filtering the placeholder here.
| const bool wasPresent = isPresent(); | ||
| bool changed = false; | ||
|
|
||
| if (d.handle && m_handle != *d.handle) { m_handle = *d.handle; changed = true; } |
There was a problem hiding this comment.
Nit, non-blocking: antennaAChanged and tuningChanged are emitted below inside the field block, so they now fire before the new presenceChanged. On main, setHandle published presence first. A first delta carrying tuning=1 therefore emits tuningChanged(true) for a tuner the rest of the app doesn't yet consider present. TunerApplet.cpp:282 only restyles so I don't think it bites today, but it inverts the ordering this fix is otherwise establishing — hoisting the presence emit above the field block would settle it.
| CHECK(t.relayC1() == 5 && st.count() == 0); | ||
| } | ||
|
|
||
| // ---- placeholder identity is replaced without a second presence edge ---- |
There was a problem hiding this comment.
These three cases are solid and not self-referential — I mutation-checked them by reading: dropping the presenceChanged emit fails presence.count() == 1, and dropping the d.handle apply fails the placeholder→real case.
The gap is that all of them drive TunerDelta directly, so none reaches RadioModel::onStatusReceived — the replacingPlaceholder hunk, which is the riskiest change in the diff, has no coverage at all. A case feeding a model=PowerGeniusXL amplifier status while the tuner holds the placeholder, asserting AmpModel still latches presence, would pin the blocker above.
|
Addressed the review blocker and remaining nits in signed commit
The atomic initial-snapshot fix, direct-presence/no-radio-handle coverage, existing meter-handle assignment, and existing removal paths remain intact. Focused tests, registration validation, and the strict engine-boundary check pass locally. |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
#5338's atomic-snapshot goal — identity, operate/bypass, and IP applied as one delta so first-presence observers never read defaults — is correctly implemented for the ordering itself, mutation-checked, and tested at the right socket-free layer. The blocker is one deliberate design choice inside it: carrying the 0x00000000 placeholder into the delta "for parity", which the model then adopts unguarded. The old code's refusal to let that literal into m_handle was load-bearing, and removing it opens four verified paths.
Scope
Six files, all explained by the issue. Preflight: no sockets, no fake peers. Honest checkbox: no hardware verification (code-audit-identified issue; model-boundary tested) — appropriate for this class of change.
Blockers
1. The placeholder handle now enters TunerModel::m_handle (inline at the decode). decodeTunerStatus sets d.handle for any non-empty handle — the comment says "carry that identity in the delta for parity, but never cache it" — and applyChanges adopts it on difference with no guard. Four verified consequences:
- A later
0x00000000status overwrites a real handle. The routing predicate (handle == m_tunerModel.handle()) then stops matching model-less TGXL statuses (state freezes), and the removal paths never match — a phantom-present tuner until reconnect. isPresent()goes true on placeholder-only identity, enabling Operate/Bypass/Autotune whose commandsFlexBackend::invokeExtensionrefuses ("no tuner handle") with no user-visible error — the optimistically-flipped buttons lie.- The routing predicate now matches a PGXL's placeholder first status (
amplifier 0x00000000 model=PowerGeniusXL→handle == m_tunerModel.handle()→ tuner branch), so the amp's detect delta can be swallowed by the tuner path. - The vestigial else-arm becomes reachable and calls
setTgxlHandle(0)after a real handle was known, wiping the TGXL meter indices.
Two more consequences the cross-file trace confirmed: with the placeholder parked in m_handle, a PGXL's placeholder first status matches the routing's second disjunct, is decoded as a tuner, and the amp else if is skipped — AmpModel never sees the announcement while TunerModel absorbs model=PowerGeniusXL and MainWindow opens a TGXL port-9010 connection to the PGXL's IP.
The fix is one line at the single right layer: sanitize at the decode (if (!handle.isEmpty() && handle != "0x00000000") d.handle = handle;) so the delta genuinely carries the "normalized tuner identity" its own field comment promises, and no downstream layer needs the rule — plus a presence definition that requires the state snapshot (or the atu branch not minting presence from a bare handle). Placeholder-only presence staying false is the pre-PR behavior.
2. Demo/sim mode loses TGXL identity entirely. On main, m_tunerModel.setHandle(handle) ran outside any backend guard, so a captured amplifier … model=TunerGeniusXL replayed in demo mode (where m_flexBackend is null) still produced presence. At this head, identity reaches the model only through if (m_flexBackend) m_flexBackend->decodeTunerStatus(…) — in demo/sim the TGXL applet can never appear. Verified by diffing the two routing branches directly.
Nits (non-blocking)
- Identity loss stayed outside the seam: three
setHandle({})clears inRadioModelusesetHandle's different signal ordering (presence, then unconditionalstateChanged) versusapplyChanges' — so the teardown edge keeps the stale-values-at-presence-change shape this PR fixes for acquisition. MirroringAmpDelta.removedintoTunerDeltaand reducingsetHandletoclearHandle()closes both, and deletes the second and third copies of the presence-transition logic (the direct-connection lambdas are the third). - The
if/elseat the meter-handle site is now degenerate (both arms identical); collapse to one guarded call. - The
antennaAChanged/tuningChangedlocals shadow the signals of the same name, forcingemit this->…— rename topending*so a droppedthis->can't silently become a non-emit. - The
aturouting branch stamps a handle with no placeholder check at all — same sanitize-at-decode fix covers it. - The placeholder literal now appears at three sites; an
isRealHandle()besideparseStatusHandlewould also catch0x0-style variants a literal compare misses. - The atu branch now routes the radio's internal ATU handle through
decodeTunerStatus, which caches it asm_tunerHandlefor outgoingtgxl setcommands — the old code only set the model handle. On a TGXL-less radio this synthesizes tuner presence and an encode target pointing at the radio's own ATU. Pre-existing in half; the encode-cache half is new — worth a guard or an explicit ruling. - The ATU-before-amplifier ordering still defeats the atomic-snapshot invariant (a state-less
atu <handle>delta flips presence with default operate/bypass and empty IP, so the wiring handler skipsconnectToTgxland never retries) — pre-existing onmain, verified, so an incompleteness note rather than a regression; the presence-requires-state fix would close it. - Signal-ordering coupling is same-thread-safe today (verified:
tunerChangedis a direct connection) but the meter-handle test now depends on a side effect of a signal emitted later in the same function — fragile under any future queued connection; and a synchronousconnectToTgxlcompletion can re-enterapplyChangesmid-emit. - Coordination note, not a defect: this branch fixes
TunerModel's ordering while sibling #5337 fixesAmpModel's — each head still contains the other's bug, so they should land together (and #5337 currently has its own blocker).
What was verified vs read
The blocker's mechanism was verified by me in the PR head (the decode's cache-vs-delta split, the unguarded adoption, isPresent()'s definition, and the routing predicate), not adopted from the automated pass; two independent angles converged on it and a third supplied the overwrite scenario. The mutation claim and focused-test results were read, not re-run. No bridge session — model-layer change with no hardware verification claimed by the PR either.
| // is verbatim. The change-gating / edge signals live in TunerModel::applyChanges. | ||
| TunerDelta d; | ||
| if (!handle.isEmpty()) { | ||
| d.handle = handle; |
There was a problem hiding this comment.
Blocker 1 — the delta carries the placeholder the cache line above just refused. "Carry that identity in the delta for parity" puts 0x00000000 into TunerModel::m_handle (the adoption is unguarded), which the old routing code explicitly prevented — and FlexLib gives the "parity" claim no basis: grep -r 0x00000000 reference/FlexLib_API_* returns nothing, and Tuner.Handle is get-only, assigned once in the constructor and never rewritten by StatusUpdate (Principle I).
Verified consequences: a later placeholder re-announce overwrites a real handle (routing stops matching model-less TGXL statuses; amplifier 0x2000 removed never matches → phantom-present tuner and a 5-s port-9010 reconnect loop); placeholder-only presence enables Operate/Bypass whose commands invokeExtension refuses invisibly while the buttons optimistically flip; and a PGXL's placeholder first status matches handle == m_tunerModel.handle(), is decoded as a tuner, and the amp branch is skipped.
One line closes the family and makes the field match its own "normalized tuner identity" doc:
| d.handle = handle; | |
| if (!handle.isEmpty() && handle != QLatin1String("0x00000000")) { | |
| d.handle = handle; | |
| } |
| m_meterModel.setTgxlHandle(handle.toUInt(nullptr, 0)); | ||
| } | ||
| if (m_flexBackend) m_flexBackend->decodeTunerStatus(m_tunerModel.handle(), kvs); // #4092/#4198 | ||
| if (m_flexBackend) m_flexBackend->decodeTunerStatus(handle, kvs); // #4092/#4198 |
There was a problem hiding this comment.
Blocker 2 — identity is now gated behind m_flexBackend, so demo/sim mode loses the TGXL entirely. On main, m_tunerModel.setHandle(handle) ran unconditionally in this branch; at this head the only path to the model is this guarded decode, and m_flexBackend is null in demo mode. A captured TGXL status replayed in demo/sim can never produce presence — the applet and status-bar block are unreachable there. Either route the delta through a backend-neutral path for the null case, or state the demo-mode loss deliberately.
d2eebb9 to
b166ae4
Compare
|
Addressed both current blockers and the two directly related cleanup nits in signed commit
Validation:
I attempted the required assignee claim before posting, but GitHub rejected it because I left the broader removal-delta and ATU/presence redesign suggestions unchanged: the review identifies those as pre-existing or architectural follow-ups, and expanding ownership here would exceed this ordering fix. |
|
We have pushed updates since the latest review: the latest blocker fixes are now on the branch and all checks are green (current head `b166ae46`). When convenient, @aethersdr-agent, could you please review the current head? Thank you. |
Summary
Fixes #5338.
Carry the normalized TGXL handle in
TunerDeltaso identity, operate/bypass state, and IP from the initial radio status are applied as one model snapshot.TunerModelnow publishes first presence only after those fields are current.SmartSDR's
0x00000000startup placeholder is filtered inFlexBackend, so it cannot become tuner identity, overwrite a real handle, create false presence, or capture a PGXL placeholder status. Captured TGXL status replay under demo/sim preserves the prior backend-neutral identity path without duplicating SmartSDR field decoding inRadioModel.Route both external-TGXL discovery paths through the atomic delta and extend the existing socket-free tuner model, backend decode, and
RadioModelrouting coverage. This preserves direct presence without a radio handle, meter routing, removal paths, and demo/sim identity.Constitution principle honored
Principle XI — Fixes Are Demonstrated. The model test observes state from
presenceChanged(true); it fails when atomic handle application is removed and passes after restoration. Dedicated regressions cover placeholder filtering and backend-neutral demo/sim identity.Test plan
origin/mainaethercoresources compile while building the focused targetsctest --test-dir build -R '^(tuner_model_test|aetherd_tuner_decode_test|aetherd_amp_tuner_encode_test|radio_capability_gating_test)$' --output-on-failure --no-tests=error)python3 tools/check_test_registration.py --strict)python3 tools/check_engine_boundary.py --strict)git diff --check)Checklist
docs/COMMIT-SIGNING.md)AppSettingscallsCHANGELOG.mdis unchanged