sensing: a busy-airtime window the caller defines - #437
Conversation
GetChannelBusy reads whatever window the shared NHM read happened to arm:
~2 ms, once. That is a sample, not a measurement of the caller's dwell, and
on bursty traffic it is bimodal rather than merely noisy. Measured on an
RTL8822BU against a 50 ms-on/450 ms-off interferer, one read per 300 ms read
ZERO in 55 of 71 windows and ~63% in the rest, while 300-400 frames were
decoded in nearly every one of them. A ranker that reads 0% as "emptiest"
does not lose information there, it gets a wrong answer.
CLM's period is its own field and reaches 65535 ticks of 4 us, so the
hardware can integrate a whole dwell. ArmChannelBusy(window_us) on IRadio
does that: arm where the counters are reset, read at the end. MediaTek's
timers already integrate between reads, so there arming is resetting them
and the mark. Both families then report busy airtime over the CALLER's
window - not an identical measurement (one is hardware-timed and counts
receive-side deferral, the other is the host-measured gap and counts own
TX), which is what source/window_us/own_tx_in_window are for.
tests/busy_window_probe.sh, 8812AU sensor, 6 samples on a ~9%-duty
interferer: armed 240 ms window mean 10% spread 19 points; sampled 2 ms
mean 24% spread 73 points. The sampled column is not just wider - it caught
a burst twice in six reads and called a ~9% channel 24%. Under a steady
load both agree (71%, spread 0 vs 6), which is why the bursty arm is the
one that decides anything.
A window only describes the dwell if nothing else touched it, and all three
spoilers are measured rather than assumed:
- An NHM read mid-window. The damage is register-map-dependent: the JGR3
map DESTROYS the window (326 of 62500 ticks where a clean read gave
~38000), the 11AC map survives it but reads 3-4 points HIGH. Enforced on
both, because GetRxQuality() takes that NHM read - a consumer polling
link quality inside its own survey dwell springs the trap without ever
touching the busy API. The probe has an arm for exactly that call.
- A retune mid-window: the counter runs across it and reports the blend of
two channels (44-47% on a Jaguar3 where the channel was 61%). A Jaguar3
bandwidth change counts too, and so does a MediaTek retune.
- A read before the window elapsed: the result register latches the
PREVIOUS window and reads are non-destructive, so it is a stale number
wearing a fresh timestamp.
Each yields an INVALID reading carrying its reason (ChannelBusy::spoil), so
a spoiled window is never a plausible number.
The arm, the read and the family's own NHM read are serialised on one CCX
mutex, taken under the family register lock where there is one (with_ccx).
Without that, a telemetry thread's GetRxEnergy(true) could note "nothing
armed", then re-arm the engine AFTER a concurrent arm committed - a
destroyed window reading back valid, which is the failure this whole change
exists to prevent. Bring-up forgets an armed window, and a no-op retune no
longer spoils one.
Own transmission is carried, not corrected: CLM counts receive-side
deferral only and a radio is deaf while its own PA is up, so a transmitting
sensor reads LOW (70.9% -> 24-26% on a Jaguar1, 60.9% -> 18.4-18.6% on a
Jaguar3), while the MediaTek timers count own TX and read HIGH. The two
families are biased in opposite directions, so own_tx_in_window/own_tx_frames
are facts on the reading rather than a correction inside it.
Also measured, and reflected in caps: Jaguar1 CLM (70.6-70.9% under a load
a MediaTek independently measured, 0.1-1.0% quiet) and MT7612U channel
timers (64.0-64.3% / 0.0% / 8.1-9.5% bursty). busy_airtime_measured is now
true on both. Two MediaTek fixes came out of measuring it: with RX down its
idle timer still advances, so busy+idle was non-zero and the ratio came out
a plausible 0% - the fabricated zero this contract exists to prevent, now
refused - and its arm stamps the interval mark instead of zeroing it, which
had made the first read after an arm report window_us=0. Its arm also has
its own narrow entry point rather than link_stats_start(), which would have
reset the 1 Hz telemetry's MIB counters once per dwell.
Validation: tests/busy_window_selftest.cpp with mocked masked-write
semantics - the clamp, the CLM-only arm sequence asserted as a SEQUENCE (no
NHM trigger edge, starting from the state a previous read_nhm leaves), the
period-bounded ratio, every refusal including the stale-latch re-arm, spoil
precedence, the JGR3 map, and own-TX accounting including a counter that
went backwards. Two mutations confirm it bites: arming NHM alongside CLM
fails the sequence check, ignoring the ready bit fails the stale and early
cases. 67/67 ctest, ASan+UBSan clean.
tests/busy_window_probe.sh drives ten on-air arms through the public
contract only and gates on values, not just validity: the floor must be
quiet, the load must separate from it, a spoiled window must refuse with
its reason, a re-armed window must not return the latched previous result,
and under a bursty load the armed spread must beat the sampled one. One arm
is the locking itself - a second thread hammering GetRxQuality() for the
whole window, where every reading must be refused rather than come back as
a short window wearing a valid flag. PASS on an 8812AU sensor with an
MT7612U flooder, steady and bursty; a MediaTek sensor runs the quiet arms
and SKIPS the loaded ones, because its bring-up does not complete on an
already-saturated channel.
Not here: Kestrel and the RTL8733B return 0 from the arm and keep the
sampled path; scoring laws are untouched; the saturation wrap near 100% is
unmeasured (the bench flood tops out ~71%), which is why the period is
capped at 240 ms rather than the hardware's 262.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
PR Summary by QodoAdd caller-defined busy-airtime measurement windows
AI Description
Diagram
High-Level Assessment
Files changed (25)
|
Code Review by Qodo
1.
|
Qodo raised thirteen findings on #437 and three more review passes of my own found seven others. The ones that could hand a consumer a wrong number, all on the MediaTek path, which had an armed-window flag but none of the state a reading needs: - No deadline, so an early read returned a valid short window where the Realtek path refuses one. The requested length is recorded at arm and the C layer's interval mark measures against it. - The armed flag was cleared in one critical section and the timers read in another; a retune landing in the gap found nothing to spoil and the post-retune remainder came back valid. One locked section now, and _rx_active is re-checked inside it — the old check-then-lock let StopRxLoop finish in between, and the C layer answers regardless, which is the fabricated zero the gate exists to prevent. - own_tx_in_window/own_tx_frames were never populated there although these timers count own transmission as busy. A TX baseline is taken at arm. - An armed window survived an RX stop/start that re-zeroes the hardware timers under it, and a zero-length request returned 0 — the "cannot arm" answer — while arming anyway. The MediaTek decision moved out of the backend into a pure devourer::busy_from_ch_time_window() next to the conversion it qualifies, so those rules are now covered headlessly (the backend itself needs a radio): every refusal, the elapsed boundary both ways, own-TX including a counter that went backwards, and an armed window over dead counters. A self-deadlock of my own making: Jaguar1's FastRetune/FastSetBandwidth held busy_window_mutex() across the tune, and their declined fast paths call SetMonitorChannel(), which takes that same non-recursive mutex. One rule, two shapes now — where a family has a register lock that spans the change the note sits inside it and with_ccx's ordering does the rest; Jaguar1 has none, so the note is scoped and the single-control-thread contract carries it. Jaguar2's FastRetune also kept a leftover pre-lock note that took the CCX mutex BEFORE _reg_mu, the inversion with_ccx is ordered against. Deleted. Two claims of mine were simply false and are now either true or gone: - I described the disturbed-counter hazard as a 1 Hz telemetry poll stealing the shared timers. Nothing in Mt7612uRadio polls mt7612u_link_stats(); only a C-API caller that uses both does (tools/bringup.cpp). The guard stays, because such a caller really can take the counts, but it says so accurately — and it no longer breaks the public C signature: mt7612u_ch_time() keeps its four parameters and the flag has its own accessor, with both new symbols added to the api_link surface guard. - The saturation test claimed to cover the clamp while passing without it: at a tick count just over the period, rounding lands on 100 either way. It overshoots far enough to kill the mutant now (an unclamped build reports 102). Also: the neutral ArmChannelBusy contract said the return value is the caller's denominator, which the MediaTek path contradicts by returning the request unchanged — the contract now describes both shapes and points at ChannelBusy::window_us as the one authoritative window. Spoil precedence agrees across families (an interruption outranks "not elapsed", because the short interval is a symptom of it). The probe emits JSON Lines through the shared EventSink instead of a bespoke text format, adopts the libusb context, and its frame literal no longer relies on implicit zeros. The harness picks the family numerically rather than by the spelling of the VID, runs the load-free spoil arms on both families, and three of its assertions could pass vacuously — an empty extractor result, a stale arm whose records never appeared, and a spread comparison that a handful of unlucky 2 ms samples can satisfy by being uniformly wrong. Each fails loudly now. The root CLAUDE.md keeps a pointer rather than a second copy of a contract documented on IRadio. 69/69 ctest, ASan+UBSan clean on both selftests. On air: the Realtek harness passes steady and bursty on an 8812AU (armed mean 9% spread 19 against the sampled read's 75, which missed the load in 5 of 6 windows), and the MediaTek sensor now passes the quiet floor plus the retune and early-read refusals. The ready-bit assumption every Realtek refusal rests on is checked on air by the early and stale arms, 6/6 each, and that is recorded in docs/rx-spectrum-sensing.md rather than asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
|
Review round pushed as The four that could hand a consumer a wrong number were all on the MediaTek path, which had an armed-window flag but none of the state a reading needs — no deadline, a lock gap between clearing the flag and reading the timers, no own-TX reporting, and an armed window surviving an RX restart. That decision now lives in a pure Two things worth calling out as corrections rather than fixes:
Findings 5 and 6 (doc placement, contract duplicated) are taken as written — the root file keeps a pointer, and the contract lives once on the 69/69 ctest, ASan+UBSan clean. On air the Realtek harness passes steady and bursty on an 8812AU, and the MediaTek sensor now passes the quiet floor plus the retune and early-read refusals. Its loaded arms are still skipped by the harness — that adapter's bring-up does not complete on an already-saturated channel, so those numbers come from a manual run with the sensor started first, and the script says so rather than passing quietly. |
The probe's no-destructor exit used POSIX _exit through <unistd.h>, which MSVC has no header for (C1083 in the windows-latest job). std::_Exit from <cstdlib> has the same semantics and is what examples/sense already uses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
josephnef
left a comment
There was a problem hiding this comment.
Review: on-air on the final code, plus a harness trap and two contract gaps
On air, this bench, head c749eb7 — tests/busy_window_probe.sh with a T3U (8822BU) txdemo flooder on ch165, so all three sensors saw the same load. These are the dies the PR says its final code was not re-verified on.
| sensor | harness | armed 240 ms spread | sampled spread |
|---|---|---|---|
| Jaguar3 RTL8812CU | PASS | 63 pt (harness trap, below) | 2 pt |
| Jaguar2 RTL8822BU | PASS | 1 pt | 4 pt |
| Jaguar1 RTL8821AU | PASS | 0 pt | 4 pt |
Every spoil arm refused with its reason on all three (interrupted / retuned / not-elapsed), the stale re-arm never returned the latched value, the cross-thread GetRxQuality() race arm was refused 5/5, and txsess stayed valid with own_tx set. The 8821AU is a die the PR did not measure; it behaves like the 8812AU. Build + 69/69 ctest locally.
The Jaguar3 spread is a harness trap, not the sensor
The first armed windows after bring-up read a valid 0% on a 64% channel (samples 1–2: 0, 30, then 63/63/63), and the bursty Jaguar3 run failed load separates from floor: 3 < 5 for the same reason. Chased through five scripted arms:
- not chip idle time (15 s idle before
InitWrite→ clean from the first 50 ms window); - not the CLM-only arm sequence (a priming
read_nhmlooked like it fixed it, but so did simply running second); - reproducible 5/5 as "first sensor bring-up within ~5 s of the flooder launch" (seven 50 ms windows at 0, then 59–66) and 0/5 with the flooder left running;
- sensor first, flooder second: the flooder's first
bulk_sendlands at window #37, the sensor sees load only from window #99–100 — the 8822BU flooder does not air at level for ~4 s after its first submit.
start_flood sleeps 4 s, the Jaguar3 brings up in ~0.6 s, so its first arm lands exactly on that edge; the MT7612U flooder on the author's bench starts instantly, so this never showed there. Gate start_flood on a sampled read that shows load rather than a fixed sleep. Why the 8822BU takes ~4 s to radiate at level after its first submit is its own question (thermal/pwr-track first tick? DIG/EDCCA onset?) and worth its own issue.
Blocking
- MSVC red —
tests/busy_window_probe.cppincluded<unistd.h>for_exit(C1083). Pushed the fix:std::_Exit, the tree's precedent inexamples/sense. - A not-elapsed read consumes the window.
ClmWindow::readclearsarmed_before the ready check, so the natural retry falls through to the sampled path and comes backvalid=true, window_us=2000— and on the JGR3 map thatread_nhmre-arm destroys the hardware window still counting.IRadio.hsays an early read "comes back INVALID", never that the window is gone. Keep it armed onNotElapsed(the MediaTek path has the same consume-on-any-verdict shape: "consumed, whatever the verdict").
Should fix
- No bring-up gate on the Realtek arm.
with_ccxwrites BB registers and returns true unconditionally; every family already has_brought_up(the CCA gates check it). An arm beforeInitreachesbusy_window_reset()vanishes silently. - Jaguar1 retune note is scoped before the tune with the lock released.
send_packetrunsFastRetuneon the TX thread for a radiotapCHANNELhop, so an arm landing between the note and the tune reads back a valid two-channel blend. Cheapest close: note after the tune as well. - MT7612U
StopRxLoopclears_rx_activeafter the unlocked ring teardown (up to ~2 s), soGetChannelBusy's under-lock recheck passes with MAC RX already off → the valid ~0% the gate exists to prevent. Clear it (or a quiesced flag) in the same_muscope as the quiesce. mt7612u_link_stats_start's new comment says "its own disturbance flag is cleared below"; nothing clears it there (onlych_time_armand the accessor do).busy_from_ch_time_window: an armed, elapsed, undisturbed window over 0/0 timers returnsvalid=false, spoil=None— the "no sensor" shape the header says an armed window must never produce; reportInterruptedlike the failed-read branch does.
Docs (current-state rule)
docs/rx-spectrum-sensing.md:331still says "Jaguar1 is unmeasured" ten lines after "Jaguar1 is measured now"; the TX-session section is corrected by an annotation 150 lines later instead of being rewritten.src/rtl8733b/Rtl8733bDevice.cpp:1044still claims "no CCX/NHM register set" — the PR body's own 8733B measurement refutes that; "not ported" is the true statement.- "now measured" / "is now true" phrasing in
RtlJaguarDevice.cpp,Mt7612uRadio.cpp,src/mt7612u/CLAUDE.md— git is the changelog. - Low:
own_tx_*keys onTxStats.submitted, so Jaguar1's queued async URBs (≤256) can air inside a window unflagged.
…iew found Review round on the busy window, with the on-air re-run on the final code (Jaguar3 8812CU, Jaguar2 8822BU, Jaguar1 8821AU; steady and bursty). - A read before the window elapsed no longer consumes it. Consuming it sent the natural retry down the sampled path, which re-arms the engine for 2 ms and reports that as a valid reading, and on the JGR3 map destroys the window still counting. On the MediaTek the refusal is decided from the host-side arm time BEFORE the timers are read, because those are read-and-clear and a read that reached them would take the counts. - with_ccx lends nothing before bring-up on all three Realtek families. - Jaguar1 notes the retune on both sides of the tune: it has no register lock spanning it, and send_packet's radiotap CHANNEL hop reaches FastRetune from the TX thread, so an arm between the note and the tune read back a valid two-channel blend. - MT7612U GetChannelBusy/ArmChannelBusy also refuse on _rx_stop: StopRxLoop clears _rx_active only after the unlocked ring teardown, and in between the MAC answered with a plausible 0%. - mt7612u_link_stats_start clears the disturbed flag its comment claimed it cleared; an armed, elapsed MediaTek window over dead timers reports a lost window rather than "no sensor". - The harness waits for the flooder to air at level before the loaded arms: a Jaguar2 txdemo does not radiate at level for ~4 s after its first submit (sensor-first measurement), and the fixed 4 s sleep put the Jaguar3's first arm on that edge, where it read a VALID 0%. - Docs and caps comments read as current state: Jaguar1 measured on both dies, the RTL8733B has a CLM engine that is not ported rather than absent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Pushed
69/69 ctest. The 8822BU flooder onset that caused the earlier zeros is filed as #438. Remaining from the review and deliberately not changed here: |
OpenIPC#437's caps note recorded that this die has a working CLM engine devourer reports as absent. It does. Ported and measured. The register block is in this tree: hal/hal8733b_tables.c:792 onward, the shipped 8733B phy_reg init table, programs 0x1e40, 0x1e44, 0x1e48, 0x1e5c and 0x1e60 at bring-up — the CCX block at the JGR3 addresses. The vendor agrees: at the pinned reference/rtl8733bu-20230626, hal/phydm/phydm_pre_define.h:513 lists ODM_RTL8733B in PHYDM_IC_SUPPORT_IFS_CLM and :523-525 define PHYDM_IC_JGR3_SERIES_SUPPORT when RTL8733B_SUPPORT is set. That init table is also the only other writer of those registers — nothing in src/rtl8733b/ touches them at runtime — so the masked read-modify-writes cannot corrupt another subsystem, and arm_clm_only never touches 0x1e5c where the table leaves a non-zero value. with_ccx() lends the engine nhm_regs_jgr3() under _reg_mu then the CCX mutex, the way Jaguar2/3 do. No new machinery. BUSY AIRTIME WITHOUT A PHYDM COUNTER BLOCK. arm_clm_only/read_clm_only take no IGI argument — busy airtime is a hardware tick count, not a histogram referenced to the receiver's own floor the way NHM's thresholds are — so CLM ports cleanly to a backend that implements no GetRxEnergy. This is the first backend where the two caps split: busy_airtime_ok true, rx_energy_ok false. The sampled path still reports NO READING here; the armed window is the only way a number comes out. AdapterCaps.h's busy_airtime_ok definition is amended to say so — it promised that GetChannelBusy answers, which on this die is true only after ArmChannelBusy. An out-of-tree consumer that sampled on the strength of that flag must now handle "no reading" from a backend whose flag is true. KNOWN GAP, pre-existing, with one affected consumer. src/sensing/SenseWindow.h picks its source by whether the IRtlRadio* is non-null rather than by rx_energy_ok, so on this backend it takes the phydm branch, gets the all-invalid base GetRxEnergy and never calls GetChannelBusy(). That is the wrong discriminator AdapterCaps.h opens by warning about. examples/chanscout hits it: main.cpp:323 builds an IRtlRadio* by dynamic_cast, which succeeds on this die, and hands it to a DwellExecutor at :342 — so a scout here reports neither CLM nor NHM. (An earlier draft of this message and of the PR body claimed nothing constructs SenseWindow outside tests. That was false; chanscout does.) Gating that branch on the capability is necessary but NOT sufficient: an unarmed GetChannelBusy() falls back to busy_from_rx_energy(GetRxEnergy(true)) and yields nothing here, and nothing in src/sensing/ ever calls ArmChannelBusy — the only callers in the tree are tests/busy_window_probe.cpp. A complete fix is the gate plus an arm, and arming needs an observation window that no layer between ScanPlanConfig::dwell_ms and SenseWindow carries. It would also change behaviour on four other backends, where an arm interacts with the NHM read that spoils the same engine — which is what tests/dwell_exec_selftest.cpp exists to pin. That belongs in src/sensing/ with its own tests, not in a backend port. Documented in docs/rx-spectrum-sensing.md and src/rtl8733b/CLAUDE.md. MEASURED, tests/busy_window_probe.sh, RTL8733BU sensor against an MT7612U flooder on ch165: quiet 0.0% steady load 69%, spread 0 over 6 windows bursty 50/450 ms mean 10%, true duty ~9% retune mid-window refused, spoil=retuned read before elapsed refused, spoil=not-elapsed re-arm then read never returns the previous latched value sensor transmitting valid, own_tx flagged A Jaguar3 8812CU on that same flooder read 69% too, so the two device paths onto the JGR3 map agree on one load. busy_airtime_measured is set on that evidence, per the house rule. The Interrupted spoiler is unreachable on this die by construction: with no NHM read there is nothing that can re-arm the shared engine mid-window. The other two fire normally. The CCA gate cannot bias the reading either — SetCcaMode throws on this backend, so a CCA-disabled session is unreachable rather than merely unlikely. LOCKING AND LIFECYCLE. Both retune notes are scoped. FastRetune calls SetMonitorChannel on its declined path while already holding _reg_mu — recursive, so that part is fine — and the CCX mutex is NOT recursive, so holding it across the tune would self-deadlock there. One note per site suffices because _reg_mu spans the whole tune and with_ccx takes _reg_mu first, so a concurrent arm cannot interleave: the Jaguar2/3 situation, not Jaguar1's. The Init/InitWrite resets sit INSIDE _reg_mu for the same reason. with_ccx reads _phy_ready under _reg_mu rather than before it, which the Jaguar2/3 overrides do not: it is a plain bool written under that lock, so the earlier read was a data race and a TOCTOU against a concurrent Stop(). Stop() forgets the window too, which is not obvious: a stop is not a retune, but clearing _phy_ready does not protect an armed window either, because SetMonitorChannel and FastRetune both call bring_up_to_phy(), which sets that flag true again. Without the reset, a window armed before a stop revives on the brought-up chip and the retune note hands back a Retuned spoil earned by a session that no longer exists. Jaguar1/2/3 share this hole and have it worse — none of their Stop() implementations resets the window, and none clears the flag their with_ccx gates on — noted in the chip guide so the asymmetry is not read as an 8733B quirk. HARNESS. busy_window_probe.sh gated its NHM and sampled arms on the sensor's USB VID, which calls this chip Realtek and then runs arms it cannot pass. It now gates on rx_energy_ok from the probe's own caps record — the capability, not the identity, which is the discriminator AdapterCaps.h argues for. A caps value it cannot parse is a loud failure, not a silent skip: defaulting to "no GetRxEnergy" would switch off the sampled arm, all three NHM arms and the bursty comparison while still printing PASS. Each skip branch now states how many arms it bypasses (the NHM branch skips three, not one; the MediaTek exit seven, not one) and the bursty comparison is counted as a check rather than an arm, so the verdict's number means "arms that did not run". Also fixed: expect_all printed "ok" unconditionally, so a failing arm reported its FAILs and then said ok underneath them. Verified: ctest 69/69 (67 run, 2 MediaTek generator tests skipped), ASan/UBSan clean on both selftests, and five on-air runs — RTL8733BU steady (PASS, 4 arms skipped) and bursty (PASS, 4 arms + 1 check), Jaguar3 8812CU PASS with every arm running and zero skips (so the gate discriminates rather than disabling), MT7612U sensor against an 8812CU flooder PASS on the quiet arms (7 arms skipped), and a mutation renaming the caps field fails loudly where it would previously have printed PASS. The Stop() reset is compile- and reasoning-verified rather than re-measured: its triggering sequence, a Stop followed by a retune with no re-Init, is out of contract and there is no mock seam for the device class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
OpenIPC#437's caps note recorded that this die has a working CLM engine devourer reports as absent. It does. Ported and measured. The register block is in this tree: hal/hal8733b_tables.c:792 onward, the shipped 8733B phy_reg init table, programs 0x1e40, 0x1e44, 0x1e48, 0x1e5c and 0x1e60 at bring-up — the CCX block at the JGR3 addresses. The vendor agrees: at the pinned reference/rtl8733bu-20230626, hal/phydm/phydm_pre_define.h:513 lists ODM_RTL8733B in PHYDM_IC_SUPPORT_IFS_CLM and :523-525 define PHYDM_IC_JGR3_SERIES_SUPPORT when RTL8733B_SUPPORT is set. That init table is also the only other writer of those registers — nothing in src/rtl8733b/ touches them at runtime — so the masked read-modify-writes cannot corrupt another subsystem, and arm_clm_only never touches 0x1e5c where the table leaves a non-zero value. with_ccx() lends the engine nhm_regs_jgr3() under _reg_mu then the CCX mutex, the way Jaguar2/3 do. No new machinery. BUSY AIRTIME WITHOUT A PHYDM COUNTER BLOCK. arm_clm_only/read_clm_only take no IGI argument — busy airtime is a hardware tick count, not a histogram referenced to the receiver's own floor the way NHM's thresholds are — so CLM ports cleanly to a backend that implements no GetRxEnergy. This is the first backend where the two caps split: busy_airtime_ok true, rx_energy_ok false. The sampled path still reports NO READING here; the armed window is the only way a number comes out. AdapterCaps.h's busy_airtime_ok definition is amended to say so — it promised that GetChannelBusy answers, which on this die is true only after ArmChannelBusy. An out-of-tree consumer that sampled on the strength of that flag must now handle "no reading" from a backend whose flag is true. KNOWN GAP, pre-existing, with one affected consumer. src/sensing/SenseWindow.h picks its source by whether the IRtlRadio* is non-null rather than by rx_energy_ok, so on this backend it takes the phydm branch, gets the all-invalid base GetRxEnergy and never calls GetChannelBusy(). That is the wrong discriminator AdapterCaps.h opens by warning about. examples/chanscout hits it: main.cpp:323 builds an IRtlRadio* by dynamic_cast, which succeeds on this die, and hands it to a DwellExecutor at :342 — so a scout here reports neither CLM nor NHM. (An earlier draft of this message and of the PR body claimed nothing constructs SenseWindow outside tests. That was false; chanscout does.) Gating that branch on the capability is necessary but NOT sufficient: an unarmed GetChannelBusy() falls back to busy_from_rx_energy(GetRxEnergy(true)) and yields nothing here, and nothing in src/sensing/ ever calls ArmChannelBusy — the only callers in the tree are tests/busy_window_probe.cpp. A complete fix is the gate plus an arm, and arming needs an observation window that no layer between ScanPlanConfig::dwell_ms and SenseWindow carries. It would also change behaviour on four other backends, where an arm interacts with the NHM read that spoils the same engine — which is what tests/dwell_exec_selftest.cpp exists to pin. That belongs in src/sensing/ with its own tests, not in a backend port. Documented in docs/rx-spectrum-sensing.md and src/rtl8733b/CLAUDE.md. MEASURED, tests/busy_window_probe.sh, RTL8733BU sensor against an MT7612U flooder on ch165: quiet 0.0% steady load 69%, spread 0 over 6 windows bursty 50/450 ms mean 10%, true duty ~9% retune mid-window refused, spoil=retuned read before elapsed refused, spoil=not-elapsed re-arm then read never returns the previous latched value sensor transmitting valid, own_tx flagged A Jaguar3 8812CU on that same flooder read 69% too, so the two device paths onto the JGR3 map agree on one load. busy_airtime_measured is set on that evidence, per the house rule. The Interrupted spoiler is unreachable on this die by construction: with no NHM read there is nothing that can re-arm the shared engine mid-window. The other two fire normally. The CCA gate cannot bias the reading either — SetCcaMode throws on this backend, so a CCA-disabled session is unreachable rather than merely unlikely. LOCKING AND LIFECYCLE. Both retune notes are scoped. FastRetune calls SetMonitorChannel on its declined path while already holding _reg_mu — recursive, so that part is fine — and the CCX mutex is NOT recursive, so holding it across the tune would self-deadlock there. One note per site suffices because _reg_mu spans the whole tune and with_ccx takes _reg_mu first, so a concurrent arm cannot interleave: the Jaguar2/3 situation, not Jaguar1's. The Init/InitWrite resets sit INSIDE _reg_mu for the same reason. with_ccx reads _phy_ready under _reg_mu rather than before it, which the Jaguar2/3 overrides do not: it is a plain bool written under that lock, so the earlier read was a data race and a TOCTOU against a concurrent Stop(). Stop() forgets the window too, which is not obvious: a stop is not a retune, but clearing _phy_ready does not protect an armed window either, because SetMonitorChannel and FastRetune both call bring_up_to_phy(), which sets that flag true again. Without the reset, a window armed before a stop revives on the brought-up chip and the retune note hands back a Retuned spoil earned by a session that no longer exists. Jaguar1/2/3 share this hole and have it worse — none of their Stop() implementations resets the window, and none clears the flag their with_ccx gates on — noted in the chip guide so the asymmetry is not read as an 8733B quirk. HARNESS. busy_window_probe.sh gated its NHM and sampled arms on the sensor's USB VID, which calls this chip Realtek and then runs arms it cannot pass. It now gates on rx_energy_ok from the probe's own caps record — the capability, not the identity, which is the discriminator AdapterCaps.h argues for. A caps value it cannot parse is a loud failure, not a silent skip: defaulting to "no GetRxEnergy" would switch off the sampled arm, all three NHM arms and the bursty comparison while still printing PASS. Each skip branch now states how many arms it bypasses (the NHM branch skips three, not one; the MediaTek exit seven, not one) and the bursty comparison is counted as a check rather than an arm, so the verdict's number means "arms that did not run". Also fixed: expect_all printed "ok" unconditionally, so a failing arm reported its FAILs and then said ok underneath them. Verified: ctest 69/69 (67 run, 2 MediaTek generator tests skipped), ASan/UBSan clean on both selftests, and five on-air runs — RTL8733BU steady (PASS, 4 arms skipped) and bursty (PASS, 4 arms + 1 check), Jaguar3 8812CU PASS with every arm running and zero skips (so the gate discriminates rather than disabling), MT7612U sensor against an 8812CU flooder PASS on the quiet arms (7 arms skipped), and a mutation renaming the caps field fails loudly where it would previously have printed PASS. The Stop() reset is compile- and reasoning-verified rather than re-measured: its triggering sequence, a Stop followed by a retune with no re-Init, is out of contract and there is no mock seam for the device class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
…acts (#439) Follow-up to #437. **No production code** — two regression tests and three documentation facts that PR overtook. ## The two rules it pins #437's busy window has two asymmetries that decide whether a spoiled reading refuses or lies. Both are currently held by comments only: I mutated this tree and the suite passes with either one inverted. **1. `ClmWindow::read` consumes a SPOILED window but keeps a NOT-ELAPSED one.** Deleting the `armed_ = false` from the spoil branch — the tidy "consume once, at the top" simplification — passes every existing check in `busy_window_selftest.cpp`. Consuming is what hands the caller back to the sampled path. `IRtlRadio::GetChannelBusy` takes the armed branch on `armed()`, and `spoil_` is sticky (only `arm()` clears it), so a spoiled window left armed strands every later call there returning invalid-Retuned — the sampled fallback unreachable for every unrelated consumer until something re-arms or resets. Not a wrong number: a sensor that stops answering. **2. `busy_from_ch_time_window` reports a lost window only when one was ARMED.** Dropping the `w.armed &&` passes every existing check in `channel_busy_selftest.cpp`, while telling every sampled caller its window was lost when it never armed one. "No sensor here" and "your window died" are the two facts the `spoil` field exists to separate. A third, smaller gap closes with them: the existing retune block asserts on `w.last_spoil()`, so a branch reporting every spoil as `Interrupted` passes the whole suite. What a consumer acts on is `ChannelBusy::spoil`. Each mutation is caught by exactly one new assertion: | mutation | fails | |---|---| | never consume a spoiled window | `spoiled: window consumed` | | hardcode the reason to `Interrupted` | `spoiled: with its reason` (got 1 want 2) | | drop `w.armed &&` | `and no window to have lost` | I deleted a fourth assertion I had written — it could not fail while the line above it passed, because `read()` returns at the `!armed_` guard before touching the mock. ## The three stale facts - **`AdapterCaps.h`** names Jaguar1 and the MT7612U as the false-as-unmeasured cases for `busy_airtime_measured`. #437 measured both and flipped them — all four backends that set `busy_airtime_ok` now set `measured` too. It also cited `tests/ccx_clm_probe.sh`, which separates a *carrier* from a quiet channel on the sampled read; the armed window's harness is `tests/busy_window_probe.sh`. This is the worst place in the tree to be stale, since it is where the flags are defined. - **`docs/rx-spectrum-sensing.md:130`** still labels the MT7612U busy-airtime leg "unvalidated", which the same page contradicts two sections later and the backend contradicts with `busy_airtime_measured = true`. ## Verification 69/69 ctest, ASan/UBSan clean on both selftests. As a **no-change control** — this commit touches no production code, so the run re-verifies #437's — `tests/busy_window_probe.sh` PASS on an 8812AU sensor against an MT7612U flooder on ch165: | arm | armed 240 ms | sampled | |---|---|---| | steady | 71%, spread 0 | spread 8 | | bursty 50/450 | mean 9% (true duty ~9%), spread 19 | missed the load 5/6, spread 70 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RTL8733B has a working CCX CLM engine. #437's caps note recorded that and left the port as a follow-up; this is it. ## Where the register block comes from In this tree: `hal/hal8733b_tables.c:792` onward — the shipped 8733B phy_reg init table — programs `0x1e40`, `0x1e44`, `0x1e48`, `0x1e5c` and `0x1e60` at bring-up. That is the CCX block at the JGR3 addresses, on this die, checkable without leaving the repo. The vendor agrees: at the pinned `reference/rtl8733bu-20230626`, `hal/phydm/phydm_pre_define.h:513` lists `ODM_RTL8733B` in `PHYDM_IC_SUPPORT_IFS_CLM`, and `:523-525` define `PHYDM_IC_JGR3_SERIES_SUPPORT` when `RTL8733B_SUPPORT` is set. That init table is also the **only other writer** of those registers — nothing in `src/rtl8733b/` touches them at runtime — so the masked read-modify-writes cannot corrupt another subsystem's state, and `arm_clm_only` never touches `0x1e5c`, where the table leaves a non-zero value. `with_ccx()` lends the engine `nhm_regs_jgr3()` under `_reg_mu` then the CCX mutex, the way Jaguar2/3 do. No new machinery. ## Busy airtime without a phydm counter block `arm_clm_only`/`read_clm_only` take no IGI argument — busy airtime is a hardware tick count, not a histogram referenced to the receiver's own noise floor the way NHM's thresholds are. So CLM ports cleanly to a backend that implements no `GetRxEnergy`. **This is the first backend where the two caps split**: `busy_airtime_ok` true, `rx_energy_ok` false. The sampled path still reports *no reading* here; an armed window is the only way a number comes out. `AdapterCaps.h`'s `busy_airtime_ok` definition is amended accordingly — it promised that `GetChannelBusy` answers, which on this die is true only after `ArmChannelBusy`. ## Measured `tests/busy_window_probe.sh`, RTL8733BU sensor against an MT7612U flooder on ch165: | arm | result | |---|---| | quiet | 0.0% | | steady load | **69%**, spread 0 over 6 windows | | bursty 50/450 ms | mean 9-10%, true duty ~9% | | retune mid-window | refused, `spoil=retuned` | | read before elapsed | refused, `spoil=not-elapsed` | | re-arm then read | never returns the previous latched value | | sensor transmitting | valid, `own_tx` flagged | A Jaguar3 8812CU on the same flooder read **69%** too, so the two device paths onto the JGR3 map agree on one load. `busy_airtime_measured` is set on that evidence, per the house rule. The `Interrupted` spoiler is unreachable on this die by construction: with no NHM read, nothing can re-arm the shared engine mid-window. The CCA gate cannot bias the reading either — `SetCcaMode` throws on this backend, so a CCA-disabled session is unreachable rather than merely unlikely. ## Known gap, pre-existing, with one affected consumer `src/sensing/SenseWindow.h` picks its source by whether the `IRtlRadio*` is non-null rather than by `rx_energy_ok`, so on this backend it takes the phydm branch, gets the all-invalid base `GetRxEnergy`, and never calls `GetChannelBusy()`. That is the same wrong discriminator `AdapterCaps.h` opens by warning about. **`examples/chanscout` hits it.** `main.cpp:323` builds an `IRtlRadio*` by `dynamic_cast`, which succeeds on this die, and hands it to a `DwellExecutor` at `:342` — so a scout on an RTL8733B reports neither CLM nor NHM. *(An earlier revision of this description claimed nothing constructs `SenseWindow` outside tests. That was false, and it was the premise this section rested on. Corrected.)* Gating that branch on the capability is **necessary but not sufficient**: an unarmed `GetChannelBusy()` falls back to `busy_from_rx_energy(GetRxEnergy(true))` and yields nothing here, and nothing in `src/sensing/` ever calls `ArmChannelBusy` — the only callers in the tree are `tests/busy_window_probe.cpp`. A complete fix is the gate **plus an arm**, and arming needs an observation window that no layer between `ScanPlanConfig::dwell_ms` and `SenseWindow` carries. It would also change behaviour on four other backends, where an arm interacts with the NHM read that spoils the same engine — which is what `tests/dwell_exec_selftest.cpp` exists to pin. So this is a real gap with a real consumer, and I do not think it belongs in a backend port: the fix is a `src/sensing/` design change needing its own tests and its own review. The defect is pre-existing — on master the same die already produces the same nothing — and leaving `busy_airtime_ok` false to mask it would be a lie about the silicon in a header whose whole point is that the flag describes hardware. Documented in `docs/rx-spectrum-sensing.md` and `src/rtl8733b/CLAUDE.md`. Say the word if you would rather have it here. **One thing for out-of-tree consumers:** `busy_airtime_ok`'s definition changes meaning slightly — it now says the backend *has* an engine and explicitly does not promise an unarmed `GetChannelBusy()` answers. Anything that sampled on the strength of that flag must handle "no reading" from a backend whose flag is true. ## Locking Both retune notes are scoped. `FastRetune` calls `SetMonitorChannel` on its declined path while already holding `_reg_mu` — recursive, so that part is fine — and the CCX mutex is **not** recursive, so holding it across the tune would self-deadlock there. One note per site suffices because `_reg_mu` spans the whole tune and `with_ccx` takes `_reg_mu` first, so a concurrent arm cannot interleave: the Jaguar2/3 situation, not Jaguar1's. The `Init`/`InitWrite` window resets sit **inside** `_reg_mu` for the same reason — ahead of it, an arm landing between the released CCX lock and `_reg_mu` would survive the bring-up the reset exists to forget. `with_ccx` reads `_phy_ready` under `_reg_mu` rather than before it, which the Jaguar2/3 overrides do not: it is a plain bool written under that lock, so the earlier ordering was a data race and a TOCTOU against a concurrent `Stop()`. ## Harness `busy_window_probe.sh` gated its NHM and sampled arms on the sensor's USB VID, which calls this chip Realtek and then runs three arms it cannot pass. It now gates on `rx_energy_ok` from the probe's own caps record — the capability, not the identity, which is the discriminator `AdapterCaps.h` argues for. A caps value it cannot parse is a **loud failure, not a silent skip**: defaulting to "no GetRxEnergy" would switch off the sampled arm, all three NHM arms and the bursty comparison while still printing PASS. The verdict line now carries the skip count for the same reason. Also fixed: `expect_all` printed "ok" unconditionally, so a failing arm reported its FAILs and then said ok underneath them. ## Verification ctest 69/69 (67 run, 2 MediaTek generator tests skipped), ASan/UBSan clean on both selftests, and five on-air runs: | run | result | |---|---| | RTL8733BU steady | PASS — 69%, spread 0, 4 arms skipped | | RTL8733BU bursty 50/450 | PASS — mean 10% vs ~9% true duty, 4 arms + 1 check skipped | | Jaguar3 8812CU | PASS — every arm ran, **zero skips**, 69% | | MT7612U sensor vs 8812CU flooder | PASS — quiet arms, 7 arms skipped | | caps-field rename mutation | **fails loudly** where it previously printed PASS | The `Stop()` window reset is compile- and reasoning-verified rather than re-measured: its triggering sequence — a `Stop` followed by a retune with no re-`Init` — is out of contract, and there is no mock seam for the device class. Lock order and re-entrancy for it were checked against every `busy_window_mutex()` site in the tree. Note: #439 edits the adjacent `busy_airtime_measured` paragraph in the same `AdapterCaps.h` comment block. Whichever lands second needs a trivial rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Joseph <162703152+josephnef@users.noreply.github.com>
Why
GetChannelBusy()reads whatever window the shared NHM read happened to arm:~2 ms, once. For a consumer that ranks channels from a survey dwell, that is a
sample of the dwell, not a measurement of it — and on bursty traffic it is
bimodal rather than merely noisy.
Measured on an RTL8822BU against a 50 ms-on/450 ms-off interferer (true duty
~9%), one read per 300 ms dwell:
Frames were decoded in nearly every one of those 71 windows, so the channel was
never free — the sample missed the burst 78% of the time. A ranker that reads
0% as "emptiest" does not lose information there, it gets a wrong answer, and
examples/chanscouttakes exactly one such read per dwell.CLM's period is its own field (the low half of the period dword) and reaches
65535 ticks of 4 us, so the hardware can integrate a whole dwell. The MediaTek
timers already integrate between reads. Neither was reachable through the
contract.
What
IRadio::ArmChannelBusy(window_us)— arm a window where you reset yourcounters, read it with
GetChannelBusy()when your dwell ends. It returns thewindow actually armed, because the hardware clamps it. Nothing in-tree
calls it yet, so every existing consumer keeps the sampled path unchanged.
arm_clm_only/read_clm_onlyinsrc/NhmReader.h): NHM's half of the shared period dword and its trigger bitare not touched, because
nhm_envis referenced to live IGI and a longerhistogram window would make it less stable, not more.
mt7612u_ch_time_arm(). Deliberately notmt7612u_link_stats_start(): thatalso clears the MIB block and the link-stats interval, which belong to the
1 Hz telemetry caller, and arming per dwell through it would corrupt every
rate that tick reports.
devourer::ClmWindow(src/BusyWindow.h) owns the state machine with nohardware access of its own, so every path is reachable from a selftest.
A window is only about the dwell if nothing else touched it. Three spoilers,
all measured, each yielding an INVALID reading that carries its reason
(
ChannelBusy::spoil) instead of a plausible number:The first row is why the rule is enforced on every family rather than only
where it fails loudly:
GetRxQuality()takes that NHM read, so a consumerpolling link quality inside its own survey dwell springs the trap without ever
touching the busy API. There is an on-air arm for exactly that call.
The arm, the read and the family's own NHM read are serialised on one CCX
mutex, taken under the family register lock where one exists (
with_ccx()).Without it, a telemetry thread's
GetRxEnergy(true)could note "nothing armed"and then re-arm the engine after a concurrent arm committed — a destroyed
window reading back valid, which is the failure this change exists to prevent.
with_ccxtakes the locks rather than handing out accessors because the arm isfour register writes that must not interleave.
Own transmission is carried, not corrected. CLM counts receive-side
deferral only and a radio is deaf while its own PA is up, so a transmitting
sensor reads LOW; the MediaTek timers count own TX and read HIGH. Same loaded
channel, sensor silent vs transmitting:
The two families are biased in opposite directions, so
own_tx_in_window/own_tx_framesare facts on the reading rather than a fudge inside it. Thisalso sharpens the TX-session section in
docs/rx-spectrum-sensing.md: CLM isalive there, but biased low.
Also measured, and reflected in caps: Jaguar1 CLM (70.6-70.9% under a load
a MediaTek adapter independently measured, 0.1-1.0% quiet) and the MT7612U
timers (64.0-64.3% / 0.0% quiet / 8.1-9.5% bursty).
busy_airtime_measuredistrue on both now. Measuring the MediaTek also turned up two defects in its
existing path: with RX down its idle timer still advances, so busy+idle was
non-zero and the ratio came out a valid 0% — the fabricated zero this
contract exists to prevent, and for a ranker 0% means "pick me" — and its
arming zeroed the interval mark, so the first read after an arm reported
window_us=0, a percentage with no denominator. Both fixed; a MediaTek retunenow spoils an armed window too.
Validation
Headless —
tests/busy_window_selftest.cppwith mocked masked-writesemantics: the clamp, the CLM-only arm asserted as a sequence (no NHM
trigger edge, starting from the state a previous
read_nhmleaves behind, sincea check on the final register image cannot see a 0→1→0 pulse), the
period-bounded ratio, every refusal including the stale-latch re-arm, spoil
precedence, the JGR3 map, a zero window, and own-TX accounting including a
counter that went backwards and one that jumped past the 32-bit field. Two
mutations confirm it bites: arming NHM alongside CLM fails the sequence check,
and ignoring the ready bit fails the stale and early cases. 69/69 ctest,
ASan+UBSan clean.
On air —
tests/busy_window_probe.sh, ten arms through the public contractonly, gating on values rather than validity: the floor must be quiet, the load
must separate from it, each spoiler must refuse with its reason, a re-armed
window must not return the latched previous result, and under a bursty load the
armed spread must beat the sampled one. 8812AU sensor, MT7612U flooder:
One arm is the locking itself: a second thread hammering
GetRxQuality()forthe whole window, where every reading must be refused rather than come back as
a short window wearing a valid flag. It is, 4/4 and 6/6.
Three results that cut against the feature
the window's 0. The window earns its place on bursty interferers and on
single-dwell decisions; a consumer that can average many sweeps of a stable
channel gains little.
that also carries an uplink still reads ~3x low on Realtek. The honest use
is to refuse such a sample, which is why the flag exists — but that is a
consumer policy this PR does not write.
produce one: a flooder with no inter-frame gap holds this bench at ~71%, and
shortening the window to 1 ms only samples the gaps more finely (30 reads
spanned 67-80%, none at 100%). The
rpt == periodcase is covered headlesslyonly. The armed period is capped at 60000 ticks (240 ms) rather than the
hardware's 65535 so the ambiguity is unreachable instead of handled — the
count is bounded by the period (measured: arm 500 ticks, read 270 ms later,
get 304-364 on all three Realtek families), so a period below the field's
range cannot wrap into a small number that would read as a quiet channel.
Also worth setting expectations: the final code was re-verified on air on the
Jaguar1 and the MT7612U, which are the adapters on this bench today. The
Jaguar2 and Jaguar3 numbers quoted above come from the same register sequence
carried by an experimental build of it; the JGR3 truncation and the 11AC
inflation are why the refusal is unconditional rather than per-family, so a
re-run on those dies can only turn a refusal into a reading, never the reverse.
Deliberately not here
ChannelScore,MigGateand the hopsetoccupancy weights do not read the busy figure, and whether CLM belongs in
cell_occupancyneeds its own re-validation of the migration matrix.examples/chanscoutstill takes the sampled path, sothis is provably no behaviour change for every existing consumer; converting
it is a separate change with its own on-air comparison.
(On the RTL8733B that is a placeholder, not a verdict — see below.)
Follow-ups
GetAdapterCapscurrentlyreports as absent. Driven on the JGR3 map it read 62.4-63.3% under a ~63%
load, 1.6-2.1% off-channel under the same load, and 0.0% quiet, while
rx.energycomes back all-null despite 28k frames decoded. The vendor phydmlists the 8733B as JGR3 (
phydm_pre_define.h:432,phydm_ccx.c:333,1319).Its FA/IGI registers are 8733B-specific and remain unmeasured, so the port is
its own PR.
MT_ED_CCA_TIMER(0x1140) tracks load(~63.5% against 64.1% busy) and is the
energy_pctthat sensing: a vendor-neutral channel-busy contract, and the survey dwell as a library primitive #432 left invalid.The first read after arming is stale garbage and must be discarded.
IRadio::Initdoes not return on that backend (it drives RX inline, so asingle-threaded consumer cannot use it), and its MCU command times out
repeatedly under saturating RX.
chanscoutshould fold, now that theper-dwell number is a measurement.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
Review round
Thirteen findings from the bot, plus seven from three further passes of my own. All in the new code; the ones that could hand a consumer a wrong number were on the MediaTek path, which had an armed-window flag but none of the state a reading needs.
_rx_activere-checked inside it: the old check-then-lock letStopRxLoopfinish in the gap while the C layer still answered, which is exactly the fabricated zero the gate exists to prevent.own_tx_*was never populated there, although those timers count own transmission as busy.The MediaTek decision moved out of the backend into a pure
busy_from_ch_time_window()beside the conversion it qualifies, so those rules are covered headlessly now: every refusal, the elapsed boundary both ways, own-TX including a counter that went backwards, and an armed window over dead counters.A self-deadlock of my own making: Jaguar1's
FastRetune/FastSetBandwidthheld the CCX mutex across the tune, and their declined fast paths callSetMonitorChannel(), which takes that same non-recursive mutex. One rule, two shapes now — where a family has a register lock that spans the change the note sits inside it andwith_ccx's ordering does the rest; Jaguar1 has none, so the note is scoped and the single-control-thread contract carries it. Jaguar2 also kept a leftover pre-lock note that took the CCX mutex before_reg_mu, the inversionwith_ccxis ordered against.Two claims of mine were false. Worth stating plainly:
Mt7612uRadiopollsmt7612u_link_stats()— only a C-API caller that uses both does, whichtools/bringup.cppis. The guard stays, because such a caller really can take the counts, but it says so accurately, and it no longer breaks the public C signature:mt7612u_ch_time()keeps its four parameters, the flag has its own accessor, and both new symbols are in theapi_linksurface guard.Also: the neutral
ArmChannelBusycontract said its return is the caller's denominator, which the MediaTek path contradicts by returning the request unchanged — the contract describes both shapes now and points atChannelBusy::window_usas the one authoritative window. Spoil precedence agrees across families. The probe emits JSON Lines through the sharedEventSink, adopts the libusb context, and its frame literal no longer leans on implicit zeros. The harness picks the family numerically rather than by the spelling of the VID, runs the load-free spoil arms on both families, and three of its assertions could pass vacuously — an empty extractor result, a stale arm whose records never appeared, and a spread comparison a handful of uniformly-wrong samples could satisfy. Each fails loudly now.Still open and stated rather than fixed: the MediaTek loaded arms are skipped by the harness, because that adapter's bring-up does not complete on an already-saturated channel, so its loaded numbers come from a manual run with the sensor started first.