fix(spectrum): field-keyed deferred pan writes — never lie, never silently drop (#4142)#4224
fix(spectrum): field-keyed deferred pan writes — never lie, never silently drop (#4142)#4224Ozy311 wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Really solid work, @Ozy311 — this is a careful, well-reasoned fix. The wire-before-model discipline, the field-wise coalescing queue with its own unit test, defer-scheduling owned by the defer path (not the ACK that may never come), and voiding a dead pan's writes at removal time because the radio re-uses pan ids all hang together coherently. I traced the "never lie" contract end-to-end and it holds: both m_seqCounters start at 1, so sendCommand()'s seq != 0 == dispatched is exact; the corrective-cancel true return is truthful; snapCenterLockForSlice null-checks pan; the effective-geometry helpers return NaN safely for unknown pans; and the WFM std::clamp is all-float. All 7 CI checks are green.
Nothing blocking. Two non-blocking observations below.
Polish
setPanBandwidth()(RadioModel.cpp:2720) is the one pan-field writer that was not rerouted — it still calls rawsendCmd("display pan set … bandwidth=…")while its siblingsetPanCenter()now goes throughrequestPanCenter(). It has no callers today so it's latent, but if it's ever wired up it would both silently drop during the hold and trip your new loud "DROPPED a routed pan field write" warning. See inline note.
Non-blocking notes
sendCommand()'s new bool contract returnsfalseonly for the WAN-disconnected case; the LAN path allocates a seq unconditionally, so a write queued onto a dead LAN socket reads as "dispatched." This matches the pre-existing optimistic behavior and disconnect-during-hold is separately handled byonDisconnected()voiding the queue, so it's fine — just noting the doc comment's "dead session" wording is WAN-only in practice.
🤖 aethersdr-agent · cost: $9.0354 · model: claude-opus-4-8
…dr#4142) — Principle II. Review feedback on PR aethersdr#4224: setPanBandwidth() was the one pan-field writer left on raw sendCmd(). It has no callers today, but wired to any user-intent path it would be silently dropped during the profile-load hold and trip the routed-pan-field backstop warning. Route it through requestPanBandwidth() to match its sibling setPanCenter(). Also sharpen sendCommand()'s doc comment: the bool contract only sees the WAN transport failure — the LAN path allocates a seq unconditionally, so a write queued onto a dead LAN socket still reports as dispatched (pre-existing optimistic behavior; onDisconnected() voids the deferred queue instead). Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
|
Holding for next week's release since it changes the shared pan-centering and band-selection paths used during normal operation. |
…#4142) — Principle II. RadioModel::sendCmd() drops any command isProfileOwnedRadioStateWrite() classifies as profile-owned while the profile-load hold is armed: it returns before a TCP sequence number is allocated, so the command never reaches the wire. Every "display pan set <id> center=..." is classified that way. Direct frequency entry emits an atomic pair — "slice tune <id> <freq>" (passes) and "display pan set <pan> center=" (silently dropped). The slice retunes; the radio never retunes the waterfall tile stream. The waterfall then goes black, and the renderer is NOT at fault: PanadapterStream decodes each VITA-49 tile.s own FrameLowFreq/BinBandwidth and remapHistoryRowInto() projects every row by that row.s own span. The liar is the optimistic local update, which advanced the pan center to the typed frequency while the radio stayed put. Honest tiles were projected into a view that falsely claimed the new center, so the non-overlapping region was correctly black — and baked into history. Defer, never drop, mirroring the codebase.s own idiom (requestPanDimensionsForRadio / flushPendingProfileLoadPanDimensions): - RadioModel::requestPanCenter(panId, center, bandwidth=-1) is now the only supported way to write a pan center. While the hold is armed it coalesces the request per pan and returns false WITHOUT advancing local model state; the queue carries the REQUESTED value, since the model still holds the old center at flush time. Coalescing is field-wise so a later center-only request cannot erase a bandwidth an earlier zoom asked for. - flushPendingProfileLoadPanCenters() replays them, re-resolving against the LIVE pan topology so a pan the profile removed (or replaced under a new id) never matches. Hooked into the existing ordered flush pass after dimensions (bin size is bandwidth/xpixels). No fourth timer. - All ten pan-center writers now route through it — typed-frequency reveal (the reported bug), pan-follow, centerActiveSliceInPanadapter, applyPanRangeRequest (zoom/drag, also silently dropped today), snapCenterLockForSlice, the channel-strip applet, edge-pan drag, DigitalModes, ATU pre-tune, and automation. Fixes the class, not the instance. - Local state may only advance when a command actually reaches the wire. Sites that also move the view directly now gate that on the return value. - sendCmd().s signature and guard are UNTOUCHED. Its suppression log is escalated qCDebug -> qCWarning: the backstop stays as defense-in-depth, but anything still reaching it is now a bug we want to see rather than silently swallow. - isProfileOwnedRadioStateWrite() moves out of RadioModel.cpp.s anonymous namespace into header-only ProfileLoadCommand.h (pure move, no behaviour change) so the classification contract is testable; profile_load_command_test covers it. Non-regression proof: every write this introduces lands AFTER hold expiry — a window in which the current code already permits arbitrary pan writes and already schedules one. flushPendingProfileLoadPanCenters() early-returns while the hold is armed, so inside the hold this puts ZERO additional bytes on the wire. It is therefore structurally incapable of reintroducing the missing-slices corruption that the hold (PR aethersdr#3563) exists to prevent. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…aethersdr#4142) Follow-up to the defer-never-drop change, found by live 6700 testing. RadioModel arms the profile-load hold when the profile command is SENT (RadioModel.cpp), but MainWindow::scheduleProfileLoadRecovery() — which schedules every flush timer, including the deferred pan-center replay — runs on profileLoadCompleted, which is emitted on ACK ONLY. Those two are not equivalent. Loading an 8-pan/8-slice global profile on a real FLEX-6700 stalls the radio for 4-5 s; it misses five consecutive pings, the client force-disconnects, and the profile load is NEVER ACKed (5/5 runs). In that case the hold armed but the replay was never scheduled, so a pan center deferred during the load would have been stranded forever — turning a dropped user command into a lost one. No unit test could have caught this; it took real hardware. Fix, as a stronger invariant: whoever DEFERS a write owns scheduling its replay. armProfileLoadPanCenterFlush() is called from the defer path itself, never from the ACK path, and re-arms itself because the hold can be extended after arming (by the ACK, or by a second profile load). onDisconnected() now VOIDS the queue loudly rather than stranding it: the session those requests belonged to is gone, the radio rebuilds its own topology on reconnect, and replaying a pre-disconnect center could land a stale frequency on a pan that is no longer the same pan. Also narrows the sendCmd() suppression warning. Escalating the whole backstop to qCWarning cried wolf: active-slice reasserts, TX-slice reasserts and panafall/waterfall auto-black defaults are model-echo writers that aethersdr#3563 suppresses BY DESIGN, and four of them fire on every profile load. Warn only for pan geometry (center=/bandwidth=), which is the class now routed through requestPanCenter() — one of those reaching the backstop means a caller bypassed the defer path and a user command is being lost. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…ad hold (aethersdr#4142) — Principle II. Header-only ProfileLoadPanWriteQueue value type, owned by RadioModel in the next commit. Field-wise coalescing per pan (a later center defer never erases a queued bandwidth), field-precise supersede for the immediate-send path, cancel-with-receipt for pan removal, takeAll for the flush. Qt6::Core-only so profile_load_command_test links it without the GUI stack. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…hed (aethersdr#4142) — Principle II. false at the aethersdr#3977 foreign-owner gate; otherwise sendCmd(cmd) != 0 — the profile-load hold backstop returns 0 before a sequence number is allocated and a disconnected WAN session returns 0 from WanConnection::sendCommand(), while both live seq counters start at 1, so one predicate captures every silent-drop path. No caller behavior change in this commit. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…ersdr#4142) — Principle II. Replace the center-only defer queue with the field-keyed store: band, center and bandwidth defer per pan, coalesce field-wise, and replay band-first, then center+bandwidth merged — the cross-band typed tune replays in the same order the live path uses. Dedupe is centralized against EFFECTIVE state (pending-else-model; incoming radio status is not hold-gated, so the model tracks radio truth through the hold). A request equal to the model that supersedes a different pending value is a user correction: the pending entry is cancelled, never replayed over the user. The immediate path supersedes exactly the fields it writes, so a stale deferred value cannot replay over newer wire state. Dispatch is wire-BEFORE-model, gated on sendCommand() success, and re-clamps the center against the pan geometry current at flush time — the model can no longer advance for a command the foreign-owner gate or the hold backstop silently destroyed, and a re-entrant request converges (last wire write == last model write). Replay is owned by a single member QTimer, hold-relative, armed by the act of deferring — never by the profile-load ACK, which a large topology can stall into a force-disconnect so it never arrives (measured 5/5 on an 8-pan load). The ACK-gated MainWindow flush call is gone with its false dimensions-before- centers ordering claim: VITA-49 tiles carry their own geometry, so ordering is a self-correcting transient, not a correctness dependency. Zero-bytes-in-hold invariant unchanged: the flush early-returns while the hold is armed. Every voided write is logged with pan id and fields; the flush accounts sent/voided unconditionally. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…thersdr#4142) — Principle II. Three paths void: the bare "removed" status, the Ignore/foreign stale-model teardown, and the ownership-flip adoption (aethersdr#3977 going-quiet). The radio re-uses pan ids across a profile load (observed live on a 6700), so a write left queued past removal would replay onto a same-id newcomer and override the state the profile just restored. The user's typed-tune-during-rebuild race loses both halves consistently — the slice half died in the rebuild too. Every void is a qCWarning naming the pan and the destroyed fields. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…dedupe against effective state (aethersdr#4142) — Principle II. Routes the remaining tune/zoom/band-class user writers: the three band= sites (memory recall, direct-tune band-stack preselect — the reported cross-band bug — and the overlay band select) go through requestPanBand, and the bandwidth drag goes through requestPanBandwidth. During the hold these were bare sendCommand() calls whose bytes were silently destroyed. Caller-side no-op guards and UX clamps switch from the raw model to the effective (pending-else-model) accessors: during the hold the model deliberately lags a deferred request, so a raw-model compare either re-issues the same write per echo or treats the user's pending zoom as already applied. The reveal path keeps its widget baseline — its decision is view-relative, and the centralized dedupe protects the queue regardless. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…hersdr#4142) — Principle II. centerPanAtSlice() now reports whether the pan is centred on the slice NOW — requestPanCenter's result, or the model's agreement when the request deduped. The Doppler escape branch zeroed the NCO unconditionally after asking for a recenter; when the recenter was deferred by the profile-load hold the DAX IQ stream had not moved, and a zero offset tuned the demod to the wrong frequency. Keep best-effort audio at the clamped edge of the IQ window instead; the deferred recenter self-heals at flush. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…hersdr#4142) — Principle II. center=/bandwidth=/band= are the fields requestPan*() carries; one of them reaching the sendCmd() backstop now means a caller bypassed the defer path and a user command is being lost — warn, by name. " band=" keeps its leading space so it cannot match inside "bandwidth=". Non-routed fields stay qCDebug: aethersdr#3563 suppresses the echo/reconcile writers BY DESIGN, and warning on them would bury the one line that means something. Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
…dr#4142) — Principle II. Review feedback on PR aethersdr#4224: setPanBandwidth() was the one pan-field writer left on raw sendCmd(). It has no callers today, but wired to any user-intent path it would be silently dropped during the profile-load hold and trip the routed-pan-field backstop warning. Route it through requestPanBandwidth() to match its sibling setPanCenter(). Also sharpen sendCommand()'s doc comment: the bool contract only sees the WAN transport failure — the LAN path allocates a seq unconditionally, so a write queued onto a dead LAN socket still reports as dispatched (pre-existing optimistic behavior; onDisconnected() voids the deferred queue instead). Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>
c3560a8 to
0f71013
Compare
|
First-pass triage review (not exhaustive — surfacing blockers to keep this moving): Read the full diff and traced the core deferred-write mechanism ( Blockers None spotted in a first pass. The load-bearing correctness properties hold up:
Secondary notes (non-blocking)
Solid, well-scoped change with a genuinely useful unit test. No first-pass blockers; the two questions above are for the deeper review, not gating. |
Summary
Fixes #4142.
During the 10 s profile-load hold,
sendCmd()suppresses every profile-owned radio-statewrite — silently. A typed frequency entry emits an atomic pair: the
slice tunehalfpasses, the
display pan set … center=half is destroyed before a sequence number isallocated, and the client's optimistic model advance then projects honest VITA-49 tiles
into a view claiming a center the radio never took: the non-overlap renders black,
permanently, into waterfall history. The cross-band variant also loses its
band=write,so the slice lands outside the pan entirely.
This PR makes the tune/zoom/band class of user writes (
center=,bandwidth=,band=)defer instead of drop, with three properties the previous behavior lacked:
sendCommand()now reports whether a command was actually dispatched(the Stale MultiFlex session keeps auto-floor-adjusting a reclaimed pan; foreign dBm-range echoes bypass smoothing and bounce the trace (#3951 root cause) #3977 foreign-owner gate, the hold backstop, and a dead WAN session all return
false), and the pan model advances only after a successful wire write — wire-before-
model makes a re-entrant request converge instead of diverge. Dispatch re-clamps the
center against the pan geometry current at flush time, not the geometry the request
was made against.
in a dedicated store (
ProfileLoadPanWriteQueue, unit-tested) and replay when the holdlifts — band first, then center+bandwidth merged, mirroring the live cross-band order.
Every write that cannot be replayed (pan removed, ownership lost, disconnect) is voided
loudly, naming the pan and the destroyed fields; the flush logs a sent/voided
account unconditionally.
one is queued, else the model, which keeps tracking radio status through the hold. A
request equal to the radio's state that supersedes a different pending value is a user
correction: the pending entry is cancelled, never replayed over the user.
Replay scheduling is owned by the defer path (a single member
QTimer, hold-relative,self-re-arming) — not by the profile-load ACK. That matters because a large topology
can stall the radio past the ping timeout into a force-disconnect before the ACK ever
arrives (measured 5/5 on an 8-pan profile on a 6700 — full timing data in #4222), and
MainWindow's ACK-gated recovery pass — including its previous deferred-center flush call —
never runs on such a load. The
pan-removal paths void a dead pan's queued writes at removal time, because the radio
re-uses pan ids across a load (observed live): a queued write must never replay onto a
same-id newcomer and override the state the profile just restored.
The non-regression proof, in one branch:
flushPendingProfileLoadPanWrites()early-returns while the hold is armed, so a deferred write can never put a byte on the
wire inside the hold window. Nothing in this PR can reintroduce the missing-slices
corruption the #3563 hold exists to prevent.
kProfileLoadStateWriteHoldMsis untouched.Honest scope
center=,bandwidth=,band=— every path in the reportedissue, including the cross-band typed tune. Echo/reconcile writers (active-slice/TX
reasserts, dBm auto-floor, fps/average reconciles, auto-black) are deliberately NOT
routed: [codex] Harden profile load recovery #3563 suppresses them by design, and routing discipline at the call site is
the user-intent boundary — no wire-protocol flag.
rxant,wnb/wnb_level,rfgain,loopa/loopb,min_dbm/max_dbm,band_zoom/segment_zoom,fps,average,weighted_average,daxiq_channel,plus the wholesale-suppressed
slice set/display panafall set/display waterfall setfamilies, and one genuinely mixed site(
dbmRangeChangeRequested: user drag and auto-floor share a wire path). Trackedwith the full census in profile-load hold: remaining user-intent writes still silently suppressed (census follow-up to #4201) #4223.
SpectrumWidgetapplies itsown view on zoom/drag/edge-pan before emitting the request, so on those paths the view
can lead the radio for the duration of the hold — a temporary, self-correcting
divergence (each VITA-49 tile carries its own geometry), not a permanent scar. Typed
entry — the reported bug — is model-driven and fully covered.
deferred; it keeps best-effort audio at the clamped IQ-window edge until the flush
self-heals.
Constitution principle honored
Principle II — the radio is authoritative. The client's model no longer advances for a
command that never reached the wire, deferred writes re-validate against the live pan
topology and current geometry before dispatch, and the radio's own state (tracked through
the hold) is the dedupe baseline.
Test plan
cmake --build build)profile_load_command_testextended: classifier contract + fullProfileLoadPanWriteQueuesemantics (field-wise coalescing, field-precisesupersede, cancel-with-receipt, drain)
upstream/mainon the same box —byte-identical (no new failures)
dead-zone, cross-band typed tune, corrective zoom/retype, worst-case multi-pan
load with force-disconnect, pan close with a pending write, untouched-load
missing-slices regression guard)
Checklist
docs/COMMIT-SIGNING.md)AppSettingscallsMeterSmoother(n/a — no meter changes)the fix itself; log lines document the defer/void/flush lifecycle)
73, Ozy K6OZY
AI compute partnership: cloaked.agency — (model: claude-fable-5)