fix(spectrum): defer, never drop, profile-owned pan writes (#4142)#4201
Closed
Ozy311 wants to merge 2 commits into
Closed
fix(spectrum): defer, never drop, profile-owned pan writes (#4142)#4201Ozy311 wants to merge 2 commits into
Ozy311 wants to merge 2 commits into
Conversation
…#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>
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RadioModel::sendCmd()drops any command thatisProfileOwnedRadioStateWrite()classifies as profile-owned while the profile-load hold is armed — itreturn 0s before a TCP sequence number is allocated, so the command never reaches the wire. Everydisplay pan set <id> center=…is classified that way.Direct frequency entry emits an atomic pair:
slice tune <id> <freq>→ not classified → sent, ACKeddisplay pan set <panId> center=<freq>→ classified → silently droppedThe 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.
PanadapterStreamdecodes each VITA-49 tile's ownFrameLowFreq/BinBandwidth, andremapHistoryRowInto()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:
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
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 returnsfalsewithout advancing local model state.sendCmd()guards on, so "ifsendCmdwould drop it, we queue it" is an identity rather than an approximation — two separate clocks could skew and silently re-open the same hole.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.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 onRadioModelrather thanMainWindowfor one decisive reason:AtuPreTuneDialogand the automation path hold only aRadioModel*. A MainWindow-owned queue could not reach them, and those writers are dropped today too.Observability
The
sendCmd()suppression log is escalatedqCDebug→qCWarning. 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:
SpectrumWidgetowns its own view during a gesture (it mutatesm_centerMhzand 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 ofRadioModel.cpp's anonymous namespace into header-onlyProfileLoadCommand.h(pure move, no behaviour change) so the classification contract is finally reachable from a test.Scope
sendCmd()'s signature and guard: unchanged.Test plan
ninja -C build -j 16on Linux (Nobara, Qt 6.10.3, gcc 15.2.1) —AetherSDRlinks clean.profile_load_command_test— extended with the classification contract: pancenter=and coupledcenter=+bandwidth=are profile-owned (the dropped writes);xpixels/ypixelsare the sole exemption; a single non-pixel field re-arms the guard socentercan never ride in on a dimension write's coat-tails;slice tunesurvives the hold (the surviving half of the broken pair).profile_transfer_test,radio_status_ownership_test,slice_recreate_policy_test,transmit_model_test— all pass.ctestrun diffed against cleanupstream/main: byte-identical failure sets — this branch introduces zero new build or test failures. (The pre-existingcrdv_cleanroom_testsanitizer link failure and its dependents reproduce identically on unmodifiedmainon this box.)-stable(has bug) vs this branch, with atcpdumpcapture 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
Closes #4142
73, Ozy K6OZY
AI compute partnership: cloaked.agency — (model: claude-fable-5)