Skip to content

fix(spectrum): field-keyed deferred pan writes — never lie, never silently drop (#4142)#4224

Open
Ozy311 wants to merge 10 commits into
aethersdr:mainfrom
Ozy311:fix/4142-pan-write-queue
Open

fix(spectrum): field-keyed deferred pan writes — never lie, never silently drop (#4142)#4224
Ozy311 wants to merge 10 commits into
aethersdr:mainfrom
Ozy311:fix/4142-pan-write-queue

Conversation

@Ozy311

@Ozy311 Ozy311 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #4142.

During the 10 s profile-load hold, sendCmd() suppresses every profile-owned radio-state
write — silently. A typed frequency entry emits an atomic pair: the slice tune half
passes, the display pan set … center= half is destroyed before a sequence number is
allocated, 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:

  • Never lie. 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.
  • Never silently drop. User writes made during the hold coalesce field-wise per pan
    in a dedicated store (ProfileLoadPanWriteQueue, unit-tested) and replay when the hold
    lifts — 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.
  • Respect corrections. Dedupe runs against effective state — the pending value if
    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. kProfileLoadStateWriteHoldMs is untouched.

Honest scope

  • Routed in this PR: center=, bandwidth=, band= — every path in the reported
    issue, 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.
  • Still suppressed during the hold (mechanism is ready; enumerated for follow-up):
    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 set families, and one genuinely mixed site
    (dbmRangeChangeRequested: user drag and auto-floor share a wire path). Tracked
    with the full census in profile-load hold: remaining user-intent writes still silently suppressed (census follow-up to #4201) #4223.
  • Gesture paths self-heal rather than never diverging: SpectrumWidget applies its
    own 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.
  • The WFM/Doppler escape branch no longer zeroes the demod NCO when its recenter was
    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

  • Local build passes (cmake --build build)
  • profile_load_command_test extended: classifier contract + full
    ProfileLoadPanWriteQueue semantics (field-wise coalescing, field-precise
    supersede, cancel-with-receipt, drain)
  • Full ctest failure set diffed against clean upstream/main on the same box —
    byte-identical (no new failures)
  • Behavior verified on a real radio (FLEX-6700, RX-only, 2026-07-13: the black-waterfall scenarios no longer reproduce on the fix build and do on the baseline; runbook: typed tune in the hold
    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)
  • Reproduction steps documented in Profile-load state-write hold silently drops user-initiated pan recenter; waterfall stays black after direct frequency entry #4142

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls
  • Code is clean-room (Principle IV)
  • All meter UI uses MeterSmoother (n/a — no meter changes)
  • Documentation updated if user-visible behavior changed (n/a — behavior change is
    the fix itself; log lines document the defer/void/flush lifecycle)
  • Security-sensitive changes reference a GHSA (n/a)

73, Ozy K6OZY
AI compute partnership: cloaked.agency — (model: claude-fable-5)

@Ozy311
Ozy311 marked this pull request as ready for review July 13, 2026 05:18
@Ozy311
Ozy311 requested review from a team as code owners July 13, 2026 05:18

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

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 raw sendCmd("display pan set … bandwidth=…") while its sibling setPanCenter() now goes through requestPanCenter(). 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 returns false only 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 by onDisconnected() 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

Comment thread src/models/RadioModel.cpp
Ozy311 added a commit to Ozy311/AetherSDR that referenced this pull request Jul 13, 2026
…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>
@jensenpat
jensenpat marked this pull request as draft July 17, 2026 16:14
@jensenpat

jensenpat commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Holding for next week's release since it changes the shared pan-centering and band-selection paths used during normal operation.

Ozy311 and others added 10 commits July 18, 2026 15:36
…#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>
@Ozy311
Ozy311 force-pushed the fix/4142-pan-write-queue branch from c3560a8 to 0f71013 Compare July 18, 2026 22:48
@jensenpat
jensenpat marked this pull request as ready for review July 19, 2026 03:23
@ten9876

ten9876 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

First-pass triage review (not exhaustive — surfacing blockers to keep this moving):

Read the full diff and traced the core deferred-write mechanism (RadioModel::requestPan*dispatchPan*flushPendingProfileLoadPanWrites), the ProfileLoadPanWriteQueue coalescing/supersede/cancel semantics, and the sendCmd/sendCommand return-value plumbing.

Blockers

None spotted in a first pass. The load-bearing correctness properties hold up:

  • The "was it dispatched?" contract is real: m_seqCounter{1} (RadioModel.h:846) so sendCmd() never returns 0 for a live send, while both drop paths return 0 — the hold-suppression (RadioModel.cpp:5306) and WAN-disconnected (WanConnection.cpp:192). sendCommand() mapping seq != 0 → dispatched is sound, and dispatchPanCenterBandwidth() advances PanadapterModel only after a successful send (wire-before-model), so the optimistic-lie / black-waterfall class can't reappear.
  • Field-wise coalescing (never whole-struct replace) is the right call and is unit-tested; supersede/cancel are field-precise, and the immediate path supersedes-before-dispatch, closing the hold-expiry→flush window.
  • Every teardown voids deferred writes: pan-removed, ownership-lost/reassigned, and disconnect (with the reused-pan-id hazard handled at removal time, not deferred to flush). flushPendingProfileLoadPanWrites() early-returns while the hold is armed, preserving the [codex] Harden profile load recovery #3563 non-regression invariant.
  • Classifier move to ProfileLoadCommand.h is include-clean (<QLatin1Char> is a valid Qt6 forwarding header; RadioModel.cpp:18 includes the moved header).

Secondary notes (non-blocking)

  • The correction-cancel logic (requestPanCenter equalsModel branch, RadioModel.cpp ~808) is load-bearing on the assumption that the model keeps tracking radio status through the hold (only writes are suppressed by [codex] Harden profile load recovery #3563, not status application). Worth a one-line confirmation that no status-apply path is gated by the same hold — if it were, a stale equalsModel could cancel a still-needed deferred write.
  • Gesture paths (zoom/drag/edge-pan) still let SpectrumWidget's view lead the radio during the hold; the PR argues this self-heals because each VITA-49 tile carries its own geometry. Reasonable, and typed entry (the reported bug) is fully model-gated — but a second reviewer eye on the "self-correcting, not a scar" claim for a drag held open across a long multi-pan load would be worthwhile.
  • Cosmetic: the flush sent/voided account counts a pan carrying both band= and center= as two, so the totals are per-dispatch, not per-pan — fine for a log line, just not a pan count.

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.

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.

Profile-load state-write hold silently drops user-initiated pan recenter; waterfall stays black after direct frequency entry

3 participants