Skip to content

fix(hl2): update seam tests for the transmit tail. Principle XI. - #6126

Merged
ten9876 merged 1 commit into
mainfrom
fix/6122-hl2-seam-tail
Oct 3, 2026
Merged

ten9876 merged 1 commit into
mainfrom
fix/6122-hl2-seam-tail

Conversation

@ten9876

@ten9876 ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6122

Root cause

#6104 (fe1fec51) changed the HL2 so that after an unkey it holds the tune drive through the radio's transmit tail (Hl2Backend::kTxTailHoldMs, 70 ms). It restores RF drive only when the tail ends. This is a TX-safety change: the gateware keeps radiating its FIFO plus PTT hang after the MOX-off frame.

hl2_backend_seam_test still asserted the old contract, a restore at the unkey itself. So main's Full Suite failed four checks:

  • #4549: an unkey that bypasses setTune() still restores RF power
  • #4549: releasing TUNE restores RF power
  • #4549: the unkey applies the power set during the tune
  • #4912: health reports the register the wire carries

The product is right; the test was stale.

This reached main because #6104's local runs excluded hl2_backend_seam_test as socket-owning. That was a misclassification: the only match was disconnectRadio() against a connectRadio( grep pattern. The source opens no socket.

Change (test only)

tunePowerAndRestore now parks the tail and ends it through the test-access friend (parkTxTail, expireTxTail). It asserts both phases.

Immediately after the unkey, the tune drive is still on the register and on the wire. This holds for:

  • an unkey that bypasses setTune();
  • a TUNE release;
  • a release after an RF power change made during the tune.

When the tail ends, RF power is restored on the register and the wire, including the power set during the tune.

#4912: health reports the register the wire carries in both phases. A new check covers an RF power change made during the tail: the requested percent moves, while the register and the wire stay on the tune drive until the tail ends.

No product code changes.

Evidence

  • hl2_backend_seam_test passes.

  • Mutations: each one makes the test fail, and each file was restored and checked by sha256.

    Mutation Failing checks
    Restore at the unkey (tail deferral removed) the three "holds the tune drive through the tail" / "waits for the tail" checks
    Restore never applied when the tail ends the three "restores … after the tail" checks
  • Full socket-free ctest at -j8: 566 of 620 run. Every test whose source opens a socket was excluded after reading the source, not just matching the name. All pass except connection_panel_size_test, which already fails locally (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811).

    • All 73 socket-free hl2_* tests ran and passed. That includes hl2_backend_seam_test, hl2_tx_gate_test, hl2_pan_create_async_test and hl2_receiver_count_restart_paced_test.
    • The remaining hl2_* tests bind UDP and were not run: hl2_emergency_stop_test and hl2_receiver_count_restart_test.
    • The other tests feat(tx): apply TUNE power live while TUNE is keyed (HL2, Icom). Principle VI. #6104 touched also pass: transmit_seam_drop_notice_test and icom_backend_seam_test.
  • The four static gates and gen_seam_probe_table.py --check are clean.

🤖 Generated with Claude Code

#6104 made the HL2 hold its tune drive through the radio's transmit tail
(kTxTailHoldMs, 70 ms) after an unkey, restoring RF drive only when the tail
ends. hl2_backend_seam_test still asserted the restore at the unkey itself,
so main's Full Suite failed four checks (#6122). The test was wrongly
classified as socket-owning during #6104's local runs: its only match was
disconnectRadio() against a connectRadio( pattern.

tunePowerAndRestore now parks the tail and expires it through the test
access friend, and asserts both phases: the tune drive held on the register
and the wire right after the unkey, then RF power (including power set
during the tune) restored when the tail ends. #4912's health row is checked
against the wire in both phases, including a change made during the tail.
Restoring at the unkey, or never restoring, fails the test.

Refs #6122

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
@ten9876
ten9876 requested a review from a team as a code owner October 3, 2026 18:47
@ten9876
ten9876 merged commit 2394c23 into main Oct 3, 2026
7 checks passed
@ten9876
ten9876 deleted the fix/6122-hl2-seam-tail branch October 3, 2026 19:09
@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Admin-merged on the maintainer's approval (self-authored fix-forward for #6122). Head e3dda092f:

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

Issue fit

Yes. #6122 is a genuine full-suite failure on fe1fec5, and I confirmed that SHA is an ancestor of main (git merge-base --is-ancestor-equivalent: it is the parent of the two commits now on top), so this is a real red test on main and not a PR-branch artifact. The four failing assertions in #6122 map one-to-one onto the four check() strings this diff rewrites, and the fifth-wheel one (#4912: health reports the register the wire carries) is explained by state carry-over, which is the part most worth noting: on main, the stale third case left m_rfDriveOwed && m_txTailPending both set, so the following setTxPower(60) took Hl2Backend.cpp:5121's early return and the register stayed on the tune drive. The diff's added expireTxTail() before that block is what makes it pass again, not a changed assertion — that lines up exactly with the body's claim that the product is right and the test was stale.

I also traced the diff's framing against the product instead of taking it on trust: the deferral the test now asserts is Hl2Backend.cpp:5119-5122 (setTxPower early-returns while m_rfDriveOwed && m_txTailPending) plus finishTxTail() at 4959-4971 (m_rfDriveOwed && !m_tuning → applyDrive(m_rfPowerPercent)). Every new assertion is the direct consequence of those two, including the asymmetry noted inline. The body's "70 ms" is correct (kTxTailHoldMs = kUnkeyUnmuteHoldMs = 70, Hl2Backend.h:954,963).

The body's misclassification story also checks out: tests/hl2_backend_seam_test.cpp:228 (b.disconnectRadio()) is the file's only substring hit for connectRadio(, and the file opens no socket — no QTcpSocket/QUdpSocket/bind/listen/connectToHost anywhere in it. I went looking for a committed classifier that would repeat the mistake (tools/, docs/agents/tests-ci.md, AGENTS.md, CONTRIBUTING.md) and found no script or recipe carrying that pattern, so this reads as a one-off local grep rather than a repo artifact needing a fix here.

Socket-test register (per the disclosure rule in docs/agents/tests-ci.md:74-80): this PR adds and removes no socket-owning test. hl2_backend_seam_test binds nothing; its tests.cmake:1593-1596 block links Qt6::Network only for MetisClient's types.

Scope

File What it changes Claimed by title/body? Verdict
tests/hl2_backend_seam_test.cpp Three test-access helpers (parkTxTail, txTailPending, expireTxTail); tunePowerAndRestore split into pre-tail / post-tail phases; one new #4912-during-the-tail block Yes — "Change (test only)" In scope

One file, one function, no product code. Everything in the diff is explained by the issue. No new public surface, no deleted guard, no CHANGELOG.md entry, no settings or capability touch — nothing for the governance or preference audits to bite on (this changes no UI, default, or behavior).

Blockers

None.

Nits

  • The three helpers at lines 113-119 are byte-identical to tests/hl2_tx_gate_test.cpp:82,106,111-114. Not worth churn now, but the second copy is the point where a shared test header starts paying for itself.
  • Case A asserts wireDrive(b) == -1 and case B asserts wireDrive(b) == driveFor(10) for what the labels describe as the same claim. Both are right — see the inline comment — but it costs the next reader a trace. One clause of comment would fix it.
  • Nothing here exercises the timer edge (expireTxTail stops the timer and calls finishTxTail() directly), so a broken timeout → finishTxTail connect would leave this test green. That is covered in hl2_tx_gate_test.cpp (txTailArmed at :514 and a real 20 ms hold at :633), so it is a boundary rather than a gap — mentioning it only so the division of labour is on the record.

What I tried to break

  • "The test would pass against the unfixed code." It would not. Remove the deferral so the restore lands at the unkey and reg() == driveFor(10) fails in all three pre-tail checks; make finishTxTail() skip applyDrive and all three post-tail checks fail. That matches the body's mutation table, which I verified by reasoning against Hl2Backend.cpp:5119-5122 and 4959-4971 rather than by running it.
  • State left behind for the next case. Each case ends with expireTxTail, so m_txTailPending/m_rfDriveOwed are clear at the next setTune(true), and beginTxTail's if (!m_txTailTimer->isActive()) guard (:4946) can re-arm because the helper stops the timer first. The parked hold is the only writer of m_txTailHoldMs — the product never assigns it — so parking cannot be clobbered mid-test.
  • Cross-contamination from the parked hold or a leftover 70 ms timer. parkTxTail is scoped to tunePowerAndRestore's own local backend; other functions in this file that unkey (manualMoxCancelsCwHang) use their own backend, and m_txTailTimer is a child of it, so nothing survives the scope. No ordering dependency between test functions.
  • Other assertions #6104 should have broken but this PR does not touch. finishTxTail also defers applyBandFilter("unkey"); I grepped this file and the sibling HL2 tests for post-unkey band-filter and drive-restore assertions and found none that the tail would invalidate, consistent with #6122 listing exactly four failures.
  • Could not check: whether the test actually passes. hl2_backend_seam_test is deliberately absent from .github/ci-test-gate.txt, so the seven green checks on e3dda09 prove it compiles and nothing more — no CI job on this PR executes it. The "passes" and mutation evidence are the author's local runs, which I found internally consistent but did not reproduce; I have no build or test run here, and every finding above is reasoned from source. The real confirmation is the next full-suite run on main, where sticky #6122 (fp 32fb57419e1f) should close itself.

Recommendation

Approve with nits. Test-only, correctly scoped to a red-on-main test, and the new assertions are the honest consequence of #6104's deferral rather than the implementation asserted back to itself — the pre-tail phase is the half that would catch a regression to the old restore-at-unkey behavior, which is the right place to spend a check. Next step: merge and let the following full-suite run close #6122; the nits are comment-level and need not hold it.


🤖 aethersdr-agent · cost: $5.0231 · model: claude-opus-5

Comment on lines +113 to +119
static void parkTxTail(Hl2Backend& b) { b.m_txTailHoldMs = 60 * 60 * 1000; }
static bool txTailPending(const Hl2Backend& b) { return b.m_txTailPending; }
static void expireTxTail(Hl2Backend& b)
{
b.m_txTailTimer->stop();
b.finishTxTail();
}

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.

These three are byte-identical to tests/hl2_tx_gate_test.cpp:82,106,111-114 (same parked hour, same stop-then-finishTxTail() body). Fine as is — separate executables, so no ODR concern — but worth noting that the HL2 tail now has two copies of its test seam, and a shared tests/ header is the obvious home if a third appears.

Worth recording why the direct call is the right choice here: stopping the timer first means the check proves what finishTxTail() decides, not that the timer was stopped — same discipline as expireHang above. The cost is that the timeout → finishTxTail connect (Hl2Backend.cpp:348) is not exercised by this file; hl2_tx_gate_test.cpp:514,633 covers that with txTailArmed and a real 20 ms hold.


b.setTune(true, 10, authority.operation);
b.setTune(false, 10, authority.operation);
check(Access::txTailPending(b) && reg() == driveFor(10) && Access::wireDrive(b) == driveFor(10),

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.

Both this == driveFor(10) and case A's == -1 at line 348 carry the same label ("holds the tune drive through the tail"), but they assert it through different mechanics, and the difference is drain order rather than semantics: case A's preceding check already called wireDrive(), which consumes the queued banks, so -1 means "the unkey queued no new drive"; here nothing drained the bank setTune(true) queued, so the last drive on the wire is still the tune drive. Both are correct against MetisClient's queue-on-change behaviour — it just costs the next reader a trace to see why the same claim gets opposite expected values twelve lines apart. One clause would settle it, e.g. // nothing drained since setTune(true), so the tune bank is still the last one queued.

Comment on lines +382 to +389
// #4912 inside a tail: the request moves, the register and wire do not.
b.setTune(true, 10, authority.operation);
b.setTune(false, 10, authority.operation);
Access::wireDrive(b);
b.setTxPower(70);
check(health(b, "rfPowerPercent").toInt() == 70 && reg() == driveFor(10)
&& Access::wireDrive(b) == -1,
"#4912: during the tail, health reports the tune register the wire still carries");

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.

This is the strongest of the added checks, and worth saying why: it is the only one that pins setTxPower's tail guard at Hl2Backend.cpp:5121 (m_rfDriveOwed && m_txTailPending → return) as a distinct path from the m_tuning guard one line above. Delete just the tail guard and this block fails while every other check in the function still passes, since m_tuning is already clear by here — so it is not redundant with the mid-tune case at line 362.

It is also the check that fixes the fourth #6122 failure by side effect: on main the preceding setTxPower(60) hit this same guard with carried-over state, which is why #4912: health reports the register the wire carries failed without its assertion ever being wrong. Calling that out in the body would make the four-for-four mapping obvious to a reader diffing against the issue.

Ozy311 added a commit that referenced this pull request Oct 3, 2026
…not the backend's own LAN-only link (#6125)

## Summary

Fixes #6121. WAN/SmartLink receive edits reached a gate that checked
only FlexBackend's undialed LAN connection, so tuning, mode, filter and
AGC requests never reached the radio.

- Gate receive intents on `RadioModel::isConnected()`, which checks LAN,
WAN and backend connectivity. The WAN check also works without a LAN
connection object. If a vended LAN connection is down, a connected
backend can still establish connectivity; this includes the simulator's
direct backend path.
- Log a disconnected receive intent through `aether.protocol`. Do not
emit `commandDropped`, whose UI consumer reports an unsupported control
rather than a disconnected session.
- Cover both a backend without a LAN connection and the real FlexBackend
with its LAN connection undialed. Inject WAN connectivity without
opening a socket. The real Flex fixture checks exact
tune/mode/filter/AGC command strings through the injected slice command
sink, disconnected refusal, warning output, and absence of the
misleading UI signal.

No new protocol verb, capability, setting, dependency, or transmit path
is added.

## Constitution principle honored

Principle VIII — Evidence Over Assertion. The reporter bisected the
regression to #5907 (`142f1d63` versus `e48a242f`). Local mutation
checks independently demonstrate that the regression suite detects the
broken connectivity gates and diagnostics.

## Validation

- macOS arm64, pinned Qt 6.12.0: full app build and focused
`receive_intent_routing_test` pass; 50 assertions. After incorporating
main #6111, both the receive test and neighboring
`transmit_seam_drop_notice_test` pass (2/2 CTests).
- Four independently rebuilt mutants fail: backend-only receive gate;
LAN check masking a connected WAN link; restored unsupported-control
signal; removed disconnected warning. The correct implementation is
restored and rebuilt afterward.
- Agent automation bridge: isolated offscreen `DEMO-0001`, TX disabled.
Tune/mode/filter/AGC readbacks pass for LSB and USB; disconnected tuning
produces no unsupported-control status notice. Owned demo process
stopped after verification.
- Engine-boundary, touchpoint, capability-record and command-plane
checks pass. PR-only output matched the original merge base; the final
candidate also includes the upstream #6111 capability ratchet decrease
from 71 to 70.
- The submitter reports real-radio verification on a FLEX-6500 firmware
3.9.18 over WAN and a separate FLEX-6500 firmware 4.2.20 over LAN. This
review did not repeat hardware or RF testing.
- Full CTest suite was not rerun in this review. The submitter's earlier
620/621 result predates #6126, which repairs the unrelated HL2 seam-test
expectations now included from main.

## Checklist

- [x] Commits are signed
- [x] No new flat-key AppSettings calls
- [x] Clean-room implementation
- [x] No meter UI change or CHANGELOG edit
- [x] Regression tests use injected state/command capture, with no
network peer

Original implementation generated with Claude Code. Additional review
and regression coverage by Codex.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Ozy <Ozy311@users.noreply.github.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 3, 2026
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>
ten9876 added a commit that referenced this pull request Oct 4, 2026
…n disk. Principle VIII. (#6136)

## Problem

The weekly **ASan + UBSan** sanitizer lane has failed in its Build step
for about three weeks with `final link failed: No space left on device`,
so it has tested nothing in that time. About 250 of the test executables
statically link the whole of `libaethercore`. In a Debug ASan build each
of those averages about 200 MB, and together they outgrow the runner's
~83 GB of free disk. The runner's `/` and `/mnt` are the same 145 GB
disk, so building on `/mnt` doesn't help. Trimming debug info (`-g1`
plus compressed debug sections) still measured 127 GB locally.

## Fix

- **`CMakeLists.txt`:** a new option, `AETHER_SHARED_CORE` (default
**OFF**), builds `libaethercore` as a shared library with
position-independent code, because the vendored static libraries end up
inside it. On Windows it's a configure error. Release, ordinary and TSan
builds keep the static core unchanged.
- **`.github/workflows/sanitizers.yml`:** the ASan entry passes
`shared_core: "ON"`, and TSan passes `"OFF"`. The Build step prints `df
-h /` before and after the build, including when the build fails (it
keeps the build status and exits with it after the second reading).
- **`.github/workflows/sanitizers.yml`, reporting:** every report step
was gated on ctest's exit code, so a Configure or Build failure skipped
them all, with no artifact and no tracking issue. That is how the lane
went dark. A new step runs only when Configure or Build failed. It
writes a report naming the step, with a stable per-step fingerprint, and
the artifact and tracking-issue steps also run on that path.
- **`.github/workflows/static-checks.yml`:** the "Sanitizer option
configures" job gains a fifth case: `-DAETHER_SHARED_CORE=ON` must
generate a `libaethercore.so` rule, and the default configure must still
generate `libaethercore.a`. That's the per-PR check of the option; the
sanitizer lane itself only runs weekly.

## Validation

**Locally** (Linux, Debug ASan + UBSan):

| | static core | shared core |
|---|---|---|
| Build tree | 127 GB even at `-g1` + zlib | **17 GB** |
| Average test executable | ~200 MB | **13 MB** (`libaethercore.so` 668
MB once) |
| Test targets that build and link | — | all 591 |

Running the full suite under ASan gave **the same failures with the
shared core as with the static core**, so linking doesn't change
results.

**On the runner** (validation run
[37141490698](https://github.com/aethersdr/AetherSDR/actions/runs/37141490698),
dispatched from this branch):
- **Disk:** `/` went from 62 GB used / 83 GB free to **81 GB used / 64
GB free** after the build.
- **Tests:** the suite ran to completion. **618 of 623 tests passed.**
All 5 failures are existing issues in the code under test:
- `offline_health_registry_test`: an ASan stack-use-after-scope in the
test itself (`offline_health_registry_test.cpp:95`).
- `rx_applet_squelch_reconciliation_test` and
`vfo_dsp_launcher_width_test`: a UBSan downcast in
`VfoWidget::eventFilter` (`VfoWidget.cpp:6766`) during `~VfoWidget`.
- `hl2_backend_seam_test`: main was red when the run started; #6126 has
since fixed it.
  - `weather_radar_loading_test`: a timeout, which also fails locally.
- **TSan job:** it failed at Configure with `Qt 6.8.3 found, but
AetherSDR requires Qt 6.12`. That's the stale `:tsan` image, fixed
separately by #6123, and unrelated to this change (TSan keeps
`shared_core: "OFF"`).

**Checks:** the new static-checks case, run locally, passes on this
head. It fails with its `::error::` when the ON branch is forced to
STATIC, and also when the default is forced to SHARED. The Build step
exits 1 when the build fails and 0 otherwise, checked under `bash
--posix`.

The real check is the next scheduled sanitizer run on `main` after
merge: it should reach Run tests under sanitizer instead of dying at
Build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

[full-suite] test suite failing on main (fp 32fb57419e1f)

1 participant