Repository navigation
fix(hl2): drop spectrum and meter output from a closed receiver's DSP. Principle VIII. - #6113
Conversation
…e VIII. A spectrum frame queued by a receiver's DSP outlives disconnect() and deleteLater(), and a receiver reopened before delivery takes the same UI number, so the frame published as the new receiver's. The forward now checks its producer is still the receiver's DSP, as the audioReady forward beside it already does. hl2_pan_create_async_test queues a frame from the old DSP, closes and reopens UI 1, then delivers it. Refs #6096 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…VIII. The meter forward had the same gap as the spectrum one: a sample queued by a closed receiver's DSP reached the reopened receiver's S-meter smoother under the recycled UI number. It now checks its producer, as the spectrum and audio forwards do. Refs #6096 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
There was a problem hiding this comment.
1. Issue fit
Partially — and the body is honest about which part. #6096 asks for two things: a structural publishSpectrumFrame() choke point on IRadioBackend mirroring publishLegacyAudio(), and verification of the RtlSdr and HL2 spectrum forwards as live Rule-6 holes. This PR does neither. What it fixes is the adjacent defect that #6107's comment explicitly flagged and left open — a queued frame/meter sample from a retired DSP publishing under a recycled UI number — and the body says "Refs #6096", not "fixes", and states plainly that "Rule 6 is not involved." That framing survives scrutiny: the DSP emits on the thread that emits linkDown, so connectedness genuinely is not the failure mode here; UI-number recycling is. #6096 stays open with the base-class gate and the RtlSdr survey unaddressed (the RTL side landed separately in c7513e1).
The recycle bug has no issue of its own, but GOVERNANCE.md does not require one for a bug fix with a stated root cause, and the root cause here is stated precisely.
2. Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/backends/hl2/Hl2Backend.cpp |
spectrumReady and meterUpdate forwards capture producer = QPointer<Hl2RxDsp>(dsp) and gain the producer-identity guard audioReady already had; the block comment is rewritten to state the rule for all three |
Yes | In scope |
tests/hl2_pan_create_async_test.cpp |
New aStaleSpectrumFrameDoesNotPublishOnTheReuser(bool) section run twice, plus Access::sliceMeterName, a KeepAlive event filter, and four includes |
Yes | In scope |
Everything in the diff is explained by the stated fix. Two files, two commits, one per forward. No CHANGELOG entry, no build-config churn, no UI or default changed — nothing for §6 to classify.
The one - line worth calling out, since §3 asks for it: the diff deletes a comment that names a symptom —
"Unlike audioReady below, there is no producer check: a frame queued by a closed receiver's DSP publishes if its UI number was reopened before delivery."
That removal is correct. The comment described the defect, the defect is now fixed, and the new test pins it. Not a regression.
Completeness. grep -n "connect(dsp, &Hl2RxDsp::" on the head checkout returns exactly three sites (Hl2Backend.cpp:843, :865, :887) and Hl2RxDsp.h:508-511 declares exactly three signals. All three now carry the identical guard. No sibling forward left behind.
Socket audit. No socket test added or modified. bringUp() asserts Access::transportIdle(backend), the new section drives createPanadapter/removePanadapter directly, and tests.cmake:1460-1463 links only Qt6::Core Qt6::Network Qt6::Test against an already-registered target that joins AETHER_SETTINGS_CONSUMERS (tests.cmake:7202) and isolates via TestSettingsProfile. Nothing to flag.
3. Blockers
None.
4. Nits (non-blocking)
- The
!producerterm is load-bearing in a way the body undersells. The mutation table says dropping it "passes, as expected… when the receiver has a DSP, a null producer already fails the identity test. The term is kept so all three forwards have the same shape." It does more than that: it closes ABA. If the retiredHl2RxDspis destroyed and a replacement lands at the same address and is stored asreceiver->dsp, the raw pointer compare passes wrongly and only the clearedQPointerrejects. A mutation test can't reach that state deterministically, which is exactly why the term deserves a better justification than "same shape." (There is also thereceiver->dsp == nullptr+ concurrently-cleared-producercase, but I tracedremovePanadapter,buildReceiversand the mid-build failure path atHl2Backend.cpp:2596and found no event-loop spin betweendsp = nullptrand reassignment, so I don't believe that one is reachable.) Inline comment on the spectrum guard. hl2_pan_create_async_testdoes not gate a merge.grep -n ctest .github/workflows/ci.ymlshows the only-Rfilters are the digital-voice set,cross_needle_meter_test,mac_nr_filter_testandasr_gpu_probe_test. This target compiles in the Linux build job and is never executed in CI. The body's "CTest: 2/2 selected, 2/2 passed" is a local run, which is fine and disclosed — just don't read the green badge as evidence the new section passed. Nothing for the author to change; noting it so the maintainer weights the evidence correctly.
5. What I tried to break
- Does the bug exist at all, i.e. does
disconnect(this)+deleteLater()cancel an already-posted call?removePanadapter(Hl2Backend.cpp:1283-1289) does both, but Qt'sQMetaCallEvent::placeMetaCallinvokes the stored slot object with no connection-liveness re-check, and the receiver (this, the backend) is still alive so nothing callsremovePostedEvents. The premise holds. Reasoned from Qt semantics, not observed — I have no build or test run. - Would the test pass against unfixed
main? No. The old guard isif (!m_ids.byUi(ui)) return;, and afterremovePanadapter(1)+createPanadapter()byUi(1)resolves to the reuser, so the stale frame publishes →framesOnUi1 == 2→ the check fails. Run 2 (KeepAliveswallowingDeferredDelete) is the one that actually exercisesreceiver->dsp != producer.data()rather than theQPointer, and the filter is correctlymoveToThread'd onto the chain's thread beforeinstallEventFilter. The test is not asserting the implementation back to itself. - Is the meter assertion's exactness real or decorative? Real.
WdspSMeter.h:119-142: areset()smoother has!m_have, sofeedAtstores the raw value and publishes immediately. Had the stale sample reached it,m_havewould be true, the next value would be blended atkAttackAlpha/kDecayAlphaand throttled for 100 ms, so< 1e-6againstoldDbm + (kReuserDbfs - kOldDbfs)would fail. The reuser'sReceiver seed;increatePanadapteris default-constructed, so its smoother is genuinely fresh. - Does the stricter guard drop a legitimate frame? I walked DDC renumbering:
m_rx.erase+m_ids.removeshift indices while leaving UI numbers alone, and the lambda capturesui(notddc), so a survivor's queued frame still resolves throughbyUi→rx(ddcIndex)→ the samedsp.createPanadaptersetsr->dspsynchronously viaopenReceiverDspbefore the async WDSP build, so the reuser is never transientlydsp == nullptrin a way that would also reject its own frames. - Is the cross-thread
QPointerread a race? The lambda runs on the backend thread while the chain is destroyed onm_ioThread. The null check reads an atomically-refcounted weak-pointer field, andproducer.data()is only compared, never dereferenced, so there is no UAF even on an unlucky interleaving — and worst case it fails closed (drops a frame). This is also byte-for-byte the guardaudioReadyhas carried onmain, so the PR introduces no new pattern. - Collateral on existing tests.
hl2_backend_seam_test.cpp:412'smeterUpdatespy drives the TX telemetry path (forwardPowerWindowPeakWhileKeyed), not a receiver DSP; the otherspectrumReady/meterUpdateconnects inhl2_rxdsp_test,hl2_pan_averaging_test,hl2_adc_sampling_seam_testandspectrum_sequence_gap_testattach to standaloneHl2RxDspobjects with no backend in the loop. Unaffected. - Could not check: I could not reproduce the mutation table, the "138/138 checks, stable over 5 runs", or the static-gate counts. All findings and clearances above are reasoned from the head checkout at
/tmp/aetherclaude/pr-6113, not from a build or a radio.
6. Recommendation
Approve with nits. The defect is real and precisely diagnosed, the fix is the pattern audioReady already established rather than a new invention, all three forwards are now identical so there is no sibling left broken, and the test fails against the unfixed guard in both runs — including the ABA-adjacent path that only run 2 reaches. The PR body is unusually honest: it marks the one uncovered guard term, says plainly there is no hardware or bridge proof, and does not claim to close #6096. My only substantive ask is a comment tweak explaining that !producer closes ABA rather than merely matching shape; the CI-gating note needs no action. Next step: a maintainer confirms #6096 stays open for the publishSpectrumFrame() choke point, and merges this on the unit test as the stated proof.
Thanks for the mutation table — it made the test's claims checkable without a build, which is exactly what a headless reviewer needs.
🤖 aethersdr-agent · cost: $4.2875 · model: claude-opus-5
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
|
Admin-merged on the maintainer's approval (self-authored PR, so a GitHub approval isn't possible). Head
Main's Full Suite on this squash commit is expected to show those four known failures until the #6122 fix lands. |
Picks up #6113 (HL2 closed-receiver DSP gate) and #6112 (AppImage). Clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
|
The new section of Cause: The guard itself is fine: the stale frame and meter checks pass in all 60. Comparing against the Not checked: Linux (CI's Full Suite is green), Windows, any commit after 73, ON8ST |
Brings the branch onto main at d9de044 so CI runs against it. The merge is textually clean. Main's HL2 changes since the branch point (aethersdr#6104, aethersdr#6113, aethersdr#6126) are in TX keying and tune, openReceiverDsp, capabilities and the TX reset in pushInitialState(); none writes Receiver::mode, agcMode or the held AGC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le VIII. (#6134) Refs #6113, #6008. on8st reported both failures on #6115 and #6113. on8st sees 614 of 616 tests pass on macOS (Apple silicon, Qt 6.12.0, clean `main` at `d9de044c`). The two failures are `hl2_pan_create_async_test` and `peripheral_auth_dialog_test`. Linux CI passes both. **I have not verified this on macOS; there is no Mac here.** I reproduced both failures on Linux by forcing the macOS conditions, as described below. ## 1. `hl2_pan_create_async_test`: "the reuser has its own chain" (test bug; the product guard is correct) **Cause.** The check compared the reopened receiver's DSP with the retired one by raw address: `newDsp != oldDsp`. In the "deleted" run, the retired chain is freed before `createPanadapter()` builds the new one. The macOS allocator then returns the same 536-byte block. on8st printed the addresses and saw `old == new` in 30 of 30 runs. In the "still alive" run the old chain is still allocated, so the addresses differ. **Linux reproduction.** I wrote an `LD_PRELOAD` shim (not committed) that keeps the most recently freed block of a given size and returns it to the next request of that size, whatever thread makes it. That is what a size-class allocator does. With `REUSE_LO=REUSE_HI=536` (`sizeof(Hl2RxDsp)`), unpatched `main` fails 3 of 3 runs with exactly on8st's result: only `[FAIL] the reuser has its own chain`, in the "deleted" run only. **The product guard holds when an address is reused.** In those same runs the stale frame and stale meter checks pass, as on8st saw in 60 of 60 runs. The forwards capture `producer = QPointer<Hl2RxDsp>(dsp)`. A `QPointer` follows the QObject's shared reference-count block, not its address. When the old DSP is destroyed, `producer` becomes null. `producer.data()` then returns `nullptr`, not the old address, so a new object at that address cannot pass `receiver->dsp != producer.data()`. **Fix (test only).** Compare with `oldGuard.data()`, the test's existing `QPointer`, instead of the raw address. The retired chain has been deleted in both runs by this point; run 2 deletes it in the KeepAlive block. The check therefore means: a chain exists behind UI 1, and the retired one is gone. Its label now says that. Run 2 still exercises the identity term through the stale frame and stale meter checks, which are unchanged. **Validation (Linux, GCC, Qt 6.12).** Plain build: 0 failures in 5 of 5 runs. With the reuse shim: 0 failures in 3 of 3 runs, where unpatched `main` failed 3 of 3. ## 2. `peripheral_auth_dialog_test`: "Clear code or Status is clipped at the default dialog size" (depends on the host's serial ports) on8st saw a layout that needs about 680 px in a 640 px viewport. On Linux the same page needs 502 px in a 644 px viewport. **Likely cause.** The Peripherals page has three serial-port combos: ACOM, SPE and LP-100A. Each is filled from `QSerialPortInfo::availablePorts()` before it is first shown, with items labelled `"<portName> — <description>"`. The combos keep Qt's default `AdjustToContentsOnFirstShow`, so each one's minimum width is its longest label. That puts the host's port names into the page's minimum width. CI runners only have short names like `ttyS0 — `. A Mac has `cu.*` and `tty.*` ports such as `cu.Bluetooth-Incoming-Port` or `cu.debug-console`, plus any USB-serial radio with a long description. **Linux reproduction.** I injected one label into the serial combos and measured the page (probe code, not committed). Production does not need the stylesheet nudge my probe used: it fills the combos before the first size hint is computed. | widest label | combo minimum | page minimum | viewport 644 | |---|---|---|---| | `ttyACM0 — ESP32S3 DEV` (this machine) | 176 | 502 | fits | | `cu.Bluetooth-Incoming-Port — ` | 202 | 528 | fits | | `cu.usbserial-A10KC1QZ — FT232R USB UART` | 288 | 614 | fits | | `cu.usbmodem1101 — IC-7300 USB Serial Port Interface` | 340 | 666 | **fails**: horizontal scroll max 22, test exits on this check | **What this PR changes (test only).** When the fit check fails, the test now prints: - the dialog size, the viewport width, the page minimum and the horizontal scroll maximum; - each serial combo's minimum width and its widest item. The test still fails in that case. I have not seen on8st's port list, so the next macOS run should confirm or rule out the cause. **Not in this PR: a product fix, which needs a decision.** If the diagnostic confirms the cause, the real app does the same on that Mac: the Peripherals tab scrolls sideways at the default size, and Clear and Status move off screen. The likely fix is three lines on those combos: - `setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon)` - `setMinimumContentsLength(~18)` The closed combo would elide long labels instead of widening the grid. That is a UX choice, so it waits for the maintainer. ## Request @on8st, could you run both tests on this PR's head? ``` ctest -R '^(hl2_pan_create_async_test|peripheral_auth_dialog_test)$' --output-on-failure ``` If `peripheral_auth_dialog_test` still fails, please paste the lines under "clipped at the default dialog size". 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…6119) **Maintainer ruling (2026-10-03):** the default is accepted; the AGC-off level persists per receiver in this PR, capability-shaped, in the radio's settings document, with no schema bump; stale DIGU/DIGL band-stack bookmarks are follow-up #6142. Refs #5629 DIGU and DIGL open with AGC off on the Hermes-Lite 2, and the AGC-off level that then sets their gain is remembered per receiver. **Limits first.** 1. **The default changes for every Hermes-Lite 2.** Ruled: accepted. Selecting DIGU or DIGL turns AGC off on that receiver, and a receiver that comes up in DIGU or DIGL does too. Only the `hl2` family is touched. ANAN-G2 runs the same AGC and keeps the operator's mode; RTL-SDR offers no data mode. Neither was measured or changed. 2. **DIGU/DIGL are still up to 29 dB quieter at the default level; what is new is that the operator's correction now sticks.** With AGC off the gain is the AGC-off level (#6050): 10 dB at the default level of 10, against an AGC ceiling of 39 dB at the default threshold. The 29 dB is the difference of the two settings, not a measurement. The default level is unchanged. An operator who raises the level (the AGC-T slider while AGC is off) gets it back on the next launch, on that receiver. What is not covered: - **No schema bump** (ruled). The field is optional and `kSchemaVersion` stays 2, as it did when the AGC pair was added (#4911). An older build that captures over the document drops the array, and the level is 10 again after a downgrade. - **A receiver is identified by its index.** Closing the first of two receivers moves the second to index 0, and the next capture stores its level there. - **The gate reuses two existing declarations.** #6050 added no capability of its own for the off level; `hasAgcThreshold` is documented as "threshold/off level". The field is read and written when the backend declares the `Agc` settings domain and `hasAgcThreshold`. No new boolean (the capability-record count is frozen at 70). 3. **Follow-up #6142, not in this PR: a band-stack bookmark still carries its own AGC.** A bookmark saved in DIGU/DIGL before this change stores the AGC of that time, medium on a stock profile, and recalling it puts medium back (`RadioModel::recallBandStackReceiveDsp`). A bookmark saved after it stores off. Read from the source, not run. 4. **The reason for the default is an offline measurement, and this PR adds no new one.** The decode figures are #5629's: WSJT-X's FT8 decoder fed through the shipped WDSP chain, no radio, no real band, no static crashes. Nothing was received on hardware for this PR. 5. **§18.5 is corrected in place.** ## What changed **The default** (`src/core/backends/hl2/Hl2Backend.cpp`): | event | AGC on that receiver | |---|---| | mode changes from a non-data mode to DIGU or DIGL | the current mode is held, AGC goes to off | | connect restores the receiver into DIGU or DIGL | the remembered mode is held, AGC goes to off; a later link-up after EP6 silence does not repeat it | | the remembered AGC is seeded on a receiver already in DIGU or DIGL | the seeded mode is held, AGC stays off | | a receiver is created from the first one while that is in DIGU or DIGL | it copies the mode, the off and the held mode | | DIGU ↔ DIGL | unchanged | | the same data mode is selected again | unchanged, so an operator's own AGC choice survives | | mode changes from DIGU/DIGL to a non-data mode | the held mode is restored | | the operator changes the AGC mode while in DIGU/DIGL | that is their choice: it is kept on leaving, and nothing is restored | | a threshold change while in DIGU/DIGL | AGC stays off, and the held mode is still what gets remembered | One function, `Hl2Backend::followModeAgc`, holds the rule; every site that writes a receiver's mode or seeds its AGC calls it. **The remembered AGC pair stays the operator's.** It is one flat setting for the radio. The data mode's "off" never reaches it: `setSliceAgc()`, the seed and the capture all record the held mode instead. **The AGC-off level, per receiver** (the ruling): - **Where it is stored.** In the radio's `OperatingState` document in `radio_settings`, scope `(hl2, <MAC>)`, as one array: `"agcOffLevels": [37, 52]`, one 0..100 entry per receiver index, `-1` for a receiver without one. No flat `AppSettings` key. - **The pattern.** `docs/agents/settings.md` names one pipeline for state the radio cannot hold: a typed field in `RestoredRadioState`, written and read only by `RadioStateMemory`, gated by the backend's declaration. The AGC pair uses it, so the level sits beside the pair as `RestoredRadioState::agcOffLevels`. The other shape in that document, the per-domain `ext` sub-objects, holds the per-band maps of `RfGain` and `TxSetpoints` only. - **Capability-shaped.** `RadioStateMemory` loads and stores the array only for a backend that declares the `Agc` domain and `hasAgcThreshold`. There is no family check. - **Capture.** `Hl2Backend::requestSliceAgc()` records the level for that receiver and asks for a capture; `currentOperatingState()` reports the list. - **Restore.** `applyRestoredState()` validates each entry (outside 0..100 is dropped, not clamped) and `seedReceiverAgc()` gives each receiver its own entry at connect, where the AGC pair is seeded. `pushInitialState()` and `finishReceiverDspBuild()` already push the receiver's level to WDSP (#6050). - **A receiver opened later** (`createPanadapter()`) takes the entry for its index, else the default. A receiver that is closed keeps its entry. - **No entry, or no array:** the default of 10. A document written before this change loads as before. - **No migration.** `settings.md` asks for one when a key is renamed or restructured; this is a new optional field. - **Other families do nothing with it.** Flex, Sim and Icom declare no settings domains. ANAN-G2 declares `hasAgcThreshold` but not the `Agc` domain, and RTL-SDR declares neither, so `RadioStateMemory` hands them an empty list and writes no array for them. No backend other than `Hl2Backend` reads the field. **Docs and comments:** - `docs/HERMES.md` §13: item 30 is marked done with the measured result; item 26 no longer lists the `Wideband` tap as open; item 28 records that the HL2's tap-kinds answer is `Demod` only. - `docs/HERMES.md` §18.5: the `tap` paragraph states the measured mechanism, the level step of limit 2, that the level is remembered per receiver, and that the default is HL2-only. - `docs/HERMES.md` §5 (AGC): a paragraph on the per-receiver AGC-off level: field, document, gate, seed, validation. - `docs/architecture/radio-capabilities-map.md`: the `clientSettingsDomains` row names the field, its gate and which families ignore it. - `src/core/backends/hl2/Hl2RxDsp.h` and `src/core/dsp/WdspChannel.h`: the `blockForOutput` comments no longer claim a burst feed is deterministic. ## Tests All without a radio. | test | what it pins | |---|---| | `hl2_tci_signaling_test`, `testDigitalModeDefaultAgc` | the mode-change rows of the table, "the data mode's off is not captured", and the capture fallback before any AGC was set | | `hl2_tci_signaling_test`, `testAgcOffLevelCapture` | without a socket: a restored `{20, 61}`, receiver 0 set to 37 → one capture request and the capture `{37, 61}` | | `hl2_state_restore_test` (default) | restore into DIGU; a second link-up after an operator AGC choice; the seed on a receiver already in DIGL | | `hl2_state_restore_test` (off level) | level 37 on receiver 0 and 52 on receiver 1 → `currentOperatingState()` → `RadioStateMemory::store` → a new backend → `RadioStateMemory::load` → `applyRestoredState()` → connect: each receiver publishes its own level, **each WDSP channel reports the matching fixed gain** (`dspChains()`, what the channel accepted), and the next capture is unchanged. Also: a change asks for a capture; a radio swap does not carry the levels across; a document without the array gives 10 on both receivers and 10 dB in the channel; an out-of-range entry falls to the default and its neighbour holds | | `radio_state_memory_test` | the array round-trips with a hole; it is one array in the `OperatingState` document; a backend without `hasAgcThreshold` is handed none and still gets the AGC pair; a backend without the `Agc` domain is handed none; a store without the capability writes no array; a document without the field loads; a state carrying only levels is not "nothing stored" | | `hl2_pan_create_async_test` | a receiver created beside a DIGU one opens on off and returns to the first receiver's held AGC; a receiver opened later takes the level remembered for its index | **Fail-before, the off level.** The new tests on `bbddff97`'s source, plus the one struct field they need to compile (a throwaway commit, full suite): ten checks red. ``` [FAIL] an AGC-off level change asks for a capture [FAIL] the capture carries each receiver's AGC-off level [FAIL] a capture before the connect keeps the remembered AGC-off levels [FAIL] a new backend restores each receiver's own AGC-off level [FAIL] and each WDSP channel accepted the restored fixed gain [FAIL] the restored AGC-off levels are captured again unchanged [FAIL] an out-of-range AGC-off level falls to the default; its neighbour holds [FAIL] the per-receiver AGC-off levels round-trip, holes included [FAIL] they are one array in the radio's OperatingState document [FAIL] a new receiver opens on the AGC-off level remembered for its index ``` Two mutations of the head, each in a full-suite run: | mutation | red checks | |---|---| | `seedReceiverAgc()` does not write the receiver's level (the restore) | 4, all in `hl2_state_restore_test`: the restore, the WDSP gain, the second capture, the out-of-range case | | `currentOperatingState()` does not report the levels (the capture) | 5, all in `hl2_state_restore_test`: the capture, the capture before connect, and the three restore checks that depend on it | **Fail-before, the default** (earlier heads): the mode-change checks were red on main when the PR was opened (5 of 12); restore into DIGU, the seed in DIGL and the pan seed were red on `540e149e`'s source. **Full suite at the head** (macOS, Apple Silicon, Qt 6.12.0, RelWithDebInfo): 612 of 615 at `-j8`, the same three on the serial rerun. None is from this PR: - `hl2_pan_create_async_test`, one check, "the reuser has its own chain" (#6113). It fails on clean main at this branch's merge base `d9de044c`, and passes in the tree merged with main at `5fae2f86`. - `peripheral_auth_dialog_test` (#6008). It fails on clean main at `d9de044c` and in the merged tree. - `vkamp_connection_test`, one check, "out-of-range antenna ports never reach the wire": the #5254 race in the test. It passed in the run of the same sources one commit earlier (the last commit is docs only). The tree merged with main at `5fae2f86` (a throwaway merge, not pushed): 616 of 617, `peripheral_auth_dialog_test` only. `git merge-tree` against main at `52b6f7ad` is clean; that tree was not built. **Static checks,** every step of the "Static checks" job of `static-checks.yml` run locally at the head: all pass, including the capability-record convention (70 booleans, unchanged) and the QSettings ratchet. No `src/gui` file is touched, so the a11y step has nothing to scan. The "Sanitizer option configures" job was not re-run for this head; no CMake file changed. ## Not verified - **On a radio.** No HL2 was connected and nothing was decoded. - **Across a real restart of the app.** The tests go through `RadioStateMemory` and the settings store in one process, with a new backend object. `RadioModel`'s debounced capture and its flush on disconnect and on quit are the existing path for every field and were not driven for this one. - **The level drop.** The 29 dB in limit 2 is arithmetic on two settings. - **The AGC mode push.** The tests pin the AGC mode the backend holds and publishes; that WDSP receives the mode is not asserted. The fixed gain is asserted from the channel. - **More than two receivers, and closing a receiver,** for the off level. The index rule in limit 2 is read from the source. - **Band-stack recall** (limit 3) is read from the source, not run. - **Static crashes and other asynchronous interferers**, which #5629 named as the one regime where AGC off could behave differently. - **Windows** locally. macOS by the author; Linux in the maintainer pass (merged with main at `a8cfd9901`: the socket-free suite, 507 of 508, the one failure the known local `connection_panel_size_test`, #5811). Windows only through CI. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Refs #6096
What was wrong
Hl2Backendforwards three signals from each receiver's DSP to the seam under that receiver's UI number:spectrumReady→spectrumFrameReady,audioReady→ slice audio, andmeterUpdate→ the slice S-meter. Only the audio forward checked which DSP sent the signal. The spectrum and meter forwards checked onlym_ids.byUi(ui), which tells you the UI number exists but not who produced the value.The DSP emits on the I/O thread and the forwards run on the backend's thread, so every frame and meter sample is a queued call. Closing a receiver calls
disconnect(this)anddeleteLater()on its DSP. Neither cancels a call that is already queued.createPanadapter()gives out the lowest free UI number, so a receiver reopened before delivery gets the closed one's number. The closed DSP's output then passed thebyUicheck:SMeterSmoother. A fresh smoother publishes its first reading immediately, so the stale value showed on the new receiver's S-meter. It also started the smoother's 100 ms throttle and became part of the smoothed value, so the receiver's own first reading was held back and blended with it.Rule 6 is not involved: the DSP emits on the thread that emits
linkDown, so nothing is delivered afterdisconnected(). That ordering argument is unchanged and the comment still states it.Fix
src/core/backends/hl2/Hl2Backend.cpp: the spectrum and meter forwards now captureproducer = QPointer<Hl2RxDsp>(dsp)and use the same guard asaudioReady:if (!producer || !receiver || receiver->dsp != producer.data()) return;The comment at the spectrum forward states the rule for all three forwards: a value is published only while its producer is still the DSP behind its UI number.
Validation
hl2_pan_create_async_test(socket-free; it already closes and reopens UI 1 without a radio) gets a new section that runs twice. It attachesSeamThreadAffinityProbeto every seam signal, then:DeferredDelete, so it is still alive. Run 2 tests thedsp != producerterm: in run 1 theQPointeralone would catch the stale values.Red before the fix: on unfixed
mainthe stale frame was published.Mutation checks, one forward at a time (each restored, sha256 compared to the fixed file, rebuilt green):
!ids(old code)!r(old code)!producerdroppedhl2_pan_create_async_test138/138 checks, stable over 5 runs.backend_seam_affinity_testpasses. CTest: 2/2 selected, 2/2 passed. No socket-binding HL2 test was run.Static gates, merge base
c7513e12vs head, identical results on both:check_engine_boundary --strict(102 findings, 0 new),gen_touchpoint_manifest --check,check_capability_records --strict(71 at the frozen 71),check_command_plane --strict(401 = baseline),gen_seam_probe_table --check.No proof through the agent automation bridge. The demo sim cannot build an HL2 backend, so the unit test is the proof. This was not tested on HL2 hardware.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g