Skip to content

fix(spectrum): defer, never drop, profile-owned pan writes (#4142)#4201

Closed
Ozy311 wants to merge 2 commits into
aethersdr:mainfrom
Ozy311:fix/4142-profile-hold-user-recenter
Closed

fix(spectrum): defer, never drop, profile-owned pan writes (#4142)#4201
Ozy311 wants to merge 2 commits into
aethersdr:mainfrom
Ozy311:fix/4142-profile-hold-user-recenter

Conversation

@Ozy311

@Ozy311 Ozy311 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

RadioModel::sendCmd() drops any command that isProfileOwnedRadioStateWrite() classifies as profile-owned while the profile-load hold is armed — it return 0s 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:

  1. slice tune <id> <freq> → not classified → sent, ACKed
  2. display pan set <panId> center=<freq> → classified → silently dropped

The slice retunes. The radio never retunes the waterfall tile stream.

This PR makes those writes deferred, never dropped — without letting one extra byte onto the wire during the hold.

Why the waterfall goes black — and why it drives the design

The renderer is not at fault. PanadapterStream decodes each VITA-49 tile's own FrameLowFreq / BinBandwidth, and remapHistoryRowInto() projects every row using that row's own span. Tiles are honest.

The liar is the optimistic local update. The client set its 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 those rows entered history permanently.

Corollary that drives the whole design: removing the optimistic update alone eliminates the black. Leave the pan where the radio actually has it and every tile lines up. The pan simply doesn't jump until the deferred command flushes.

What the hold protects — and why this does not weaken it

The hold comes from #3563 (Harden profile load recovery). Its body enumerates every suppression target: early pan dimension writes, FPS/line-duration reconciliation, bandstack autosave, active-slice/TX-slice reasserts, min/max dBm defaults, noise-floor auto-positioning. Every one is a model-echo / reconcile writer. Not one is a user action. And it explicitly promises:

"Normal user-driven active-slice selection still works."

The corruption it guards is documented on requestPanDimensionsForRadio(): writes during profile teardown "dirty the GUIClient restore snapshot with an intermediate topology. That was the root cause of profile-loaded sessions later restarting with missing slices." Severe, server-side, persistent — and not reproducible on demand.

So this is not a request to relax your guard. It closes the gap between what #3563 promised and what it does — the low-level sendCmd() backstop breaks that promise for the pan half of a typed retune.

The non-regression proof

Every write this patch introduces lands after hold expiry — a window in which the current code already permits arbitrary pan writes, and in which it already schedules one (the hold + 1000 ms dimension backstop). Inside the hold, this patch puts zero additional bytes on the wire.

That is enforced in one branch, not by argument: flushPendingProfileLoadPanCenters() early-returns while the hold is armed. No deferred center can escape early even if the call site moved. The patch is therefore structurally incapable of reintroducing the missing-slices corruption — verifiable mechanically, in one function, without a judgment call.

The design — defer-and-coalesce

Mirrors this codebase's own idiom (requestPanDimensionsForRadio() / flushPendingProfileLoadPanDimensions()). sendCmd()'s signature and guard are untouched.

  • RadioModel::requestPanCenter(panId, center, bandwidth = -1) is now the only supported way to write a pan center. While the hold is armed it coalesces per pan and returns false without advancing local model state.
  • It gates on exactly the predicate sendCmd() guards on, so "if sendCmd would drop it, we queue it" is an identity rather than an approximation — two separate clocks could skew and silently re-open the same hole.
  • The queue carries the requested value. Once the optimistic update is gone the model still holds the old center at flush time; re-deriving from the model would send the old value.
  • Coalescing is field-wise: a later center-only request must not erase a bandwidth an earlier zoom asked for — that bandwidth is the very thing we set out to stop dropping.
  • flushPendingProfileLoadPanCenters() re-resolves against the live pan topology, so a pan the profile removed (or replaced under a new id) never matches and never gets a stale center.
  • One ordered flush, hooked into the existing pass after dimensions (bin size = bandwidth ÷ xpixels). No fourth timer.
  • Invariant: 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.

Fixes the class, not the instance

All ten pan-center writers 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.

requestPanCenter() lives on RadioModel rather than MainWindow for one decisive reason: AtuPreTuneDialog and the automation path hold only a RadioModel*. A MainWindow-owned queue could not reach them, and those writers are dropped today too.

Observability

The sendCmd() suppression log is escalated qCDebugqCWarning. The backstop stays as defense-in-depth; we simply stop relying on it to silently eat user commands. Anything still reaching it is now a bug we want to see.

Residual behaviour (stated honestly)

Worst case — profile load, then a typed frequency within the hold: the pan trails the slice by a few seconds, showing truthful spectrum for its real span the entire time, with zero permanent artifacts, then recenters. Today: permanent black scars and a pan that never moves.

Gesture paths (zoom / drag / edge-pan) are more nuanced and I want to be precise rather than overclaim: SpectrumWidget owns its own view during a gesture (it mutates m_centerMhz and reprojects before emitting the request), so the view still tracks the cursor. During the hold the radio lags behind it, and the newly-exposed span has no data until the deferred command flushes — at which point it fills. The divergence is temporary and self-healing. Today it is permanent, because the command is dropped and the radio never catches up. I deliberately did not suppress the widget's self-move: that is the gesture's own affordance, and freezing it would trade one bug for a worse one.

A question I'd rather ask than guess at

The wire capture shows the radio ACKs and retunes tile streams in 79–142 ms, and the deferred-dimension path already attempts a flush at 1.5 s — yet kProfileLoadStateWriteHoldMs = 10000, which reads like padding rather than a measurement.

Is there a wire-observable profile-load-completion event (or is the per-pan settling deadline a real bound on radio-side rebuild)? If so the deferral collapses to sub-second and the residual lag disappears. I'm happy to follow up with that patch — but I didn't want to guess at the settle condition inside a mechanism that guards a corruption I can't reproduce, and I wasn't willing to trade away the zero-bytes-on-the-wire proof above to make it look event-driven.

Constitution principle honored

Principle II — The Radio Is Authoritative On Live State. "A user action is a request to the radio… the client never writes its remembered value back over the radio's."

Today the client does the worst possible pair of things: it applies the optimistic update and discards the request. The result is permanent client/radio divergence with no reconcile path — the radio never echoes a center it was never asked to take. This patch restores the principle exactly: local state advances only when the request actually reaches the radio.

Also Principle XI — Fixes Are Demonstrated: isProfileOwnedRadioStateWrite() is moved out of RadioModel.cpp's anonymous namespace into header-only ProfileLoadCommand.h (pure move, no behaviour change) so the classification contract is finally reachable from a test.

Scope

  • 8 files, +366 / −112.
  • No protocol change. No persistence change. No UX change on the normal (non-profile-load) path — outside the hold, every routed site emits the identical command it does today.
  • sendCmd()'s signature and guard: unchanged.

Test plan

  • Clean incremental ninja -C build -j 16 on Linux (Nobara, Qt 6.10.3, gcc 15.2.1) — AetherSDR links clean.
  • profile_load_command_test — extended with the classification contract: pan center= and coupled center=+bandwidth= are profile-owned (the dropped writes); xpixels/ypixels are the sole exemption; a single non-pixel field re-arms the guard so center can never ride in on a dimension write's coat-tails; slice tune survives the hold (the surviving half of the broken pair).
  • Regression sweep (mirrors [codex] Harden profile load recovery #3563's own validation set): profile_transfer_test, radio_status_ownership_test, slice_recreate_policy_test, transmit_model_test — all pass.
  • Full ctest run diffed against clean upstream/main: byte-identical failure sets — this branch introduces zero new build or test failures. (The pre-existing crdv_cleanroom_test sanitizer link failure and its dependents reproduce identically on unmodified main on this box.)
  • A/B on a live FLEX-6700-stable (has bug) vs this branch, with a tcpdump capture on tcp/4992 confirming zero additional bytes inside the hold window and the center command present after expiry. Repro: store a Global profile with a wide view → activate → within 10 s type a frequency + Enter.

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls (Principle V)
  • All meter UI uses MeterSmoother (Principle II) — n/a, no meter UI touched

Closes #4142


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

Ozy311 and others added 2 commits July 11, 2026 23:59
…#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>
@Ozy311

Ozy311 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #4224 — a rewrite of this patch on the current main (v26.7.2). Adversarial re-review of this branch found the center-only queue, the frozen-model dedupe, and the null-check pan guard each defective (the cross-band variant of the reported bug still fired here); #4224 replaces the mechanism with a field-keyed queue, effective-state dedupe with corrective-cancel, success-gated wire-before-model dispatch, and removal-time voids. Closing in favor of #4224.

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

@Ozy311 Ozy311 closed this Jul 13, 2026
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

1 participant