Skip to content

sensing: a busy window must not outlive its hardware session - #442

Open
snokvist wants to merge 2 commits into
OpenIPC:masterfrom
snokvist:feat/busy-window-stop-lifecycle
Open

snokvist wants to merge 2 commits into
OpenIPC:masterfrom
snokvist:feat/busy-window-stop-lifecycle

Conversation

@snokvist

Copy link
Copy Markdown
Collaborator

Follow-up to #440. That PR gave the RTL8733B a busy_window_reset() in
Stop() and argued the other Realtek backends shared the hole. They did.
This measures it on each die, closes it, and adds the on-air arm that proves
both halves.

The bug

An armed window survives Stop(), and the next retune's note then stamps it
Retuned — a spoil reason earned by a hardware session that no longer
exists. The reading is invalid either way, so nothing reports a wrong
number; what it reports is a wrong reason, which is the one thing the
spoil field exists to get right.

The mechanism differs by backend, and the version in #440's commit message
was only right for one of them:

  • RTL8733BSetMonitorChannel re-runs bring_up_to_phy(), which sets
    the flag with_ccx gates on back to true.
  • Jaguar1/2/3 — the retune does no bring-up at all. There the window stays
    reachable because nothing ever clears _brought_up.

Measured, per die

tests/busy_window_probe.sh --mode revive (arm → Stop() → retune → read),
each backend with its own reset removed and then restored:

die without the reset with it
RTL8812AU (jaguar1) spoil=retuned spoil=none
RTL8822BU (jaguar2) spoil=retuned spoil=none
RTL8812CU (jaguar3) spoil=retuned spoil=none
RTL8733BU spoil=retuned spoil=none

The assertion is on the reason, not the reading — and the Jaguar2 is why

My first cut asserted the post-Stop() read was invalid. That is true on
three dies and false on the Jaguar2, whose Stop() only joins its runtime
threads and leaves the chip live, so its sampled path answers with a valid
2 ms window. That assertion would have failed a correct backend.

What holds everywhere is that no spoil reason survives the session that ended.
Verified through the harness on the Jaguar2 specifically: with the reset
removed the arm fails on the spoil check alone (3/3 samples not spoil=none), which is the only signal available on that die.

What the resets do not close

On the Jaguars, unlike the RTL8733B — whose Stop() holds its recursive
register lock across the whole body, which with_ccx takes first — there is
no such span, so a concurrent ArmChannelBusy can still land after the reset
and during teardown. ArmChannelBusy is single-control-thread by contract
(IRadio.h). Closing it properly means clearing _brought_up in Stop(),
which gates other paths and is a behaviour change of its own;
src/rtl8733b/CLAUDE.md records it as the half that stays open.

Locking

Every reset takes the CCX mutex alone, in a scope that acquires no other lock
and calls no device method. Jaguar3's sits outside _reg_mu rather than
inside it as the RTL8733B's does, deliberately: Stop() joins the coex
thread, which takes _reg_mu, so holding it across the join would deadlock —
the same shape as an earlier self-inflicted deadlock in this feature's
history. Jaguar1 has no family-wide register lock (its _port0_mu is narrower
and never taken under the CCX lock). No path takes the CCX mutex and then a
register lock.

The arm refuses where it cannot run

  • MediaTekStop() closes the device and nulls the handle, leaving no
    retune path. Measured: the probe wedges.
  • SENSOR_RX=1 — the Realtek Init runs on a detached thread that
    Stop() would be torn down underneath. That is the use-after-free this
    probe's own cleanup path exists to avoid, and the sequence
    RtlJaguar3Device::InitWrite refuses outright.

Both gate on the sensor VID and the RX flag rather than a capability, because
there is no cap for "Stop() is survivable" — that is a lifecycle fact, not a
feature one.

Also in this change

  • expect_all accepted a partial record set, so a probe that died after one
    rep passed every arm. It now requires REPS records, as expect_spoil does.
  • The harness header listed ten arms and claimed nine. It now lists and counts
    eleven, and says the list is by role rather than run order.
  • The MediaTek skip total is 8 (7 at the early exit plus the revive arm),
    correcting the 7 stated in sensing: port the CCX CLM busy window to the RTL8733B #440.

Verification

ctest 69/69, ASan/UBSan clean on both selftests, and on air:

  • full harness PASS — RTL8733BU sensor vs RTL8822BU flooder, 63% under load
  • full harness PASS — Jaguar2 sensor vs RTL8733BU flooder, 61%
  • the revive arm passing on all three dies present, with the documented
    validity split
  • the RX guard refusing rather than wedging

Pre-existing and unrelated, verified at fc66aa9 without these changes:
the txsess arm hangs on a Jaguar1 sensor, and an RTL8812AU txdemo exits
after ~482 frames, so that die cannot serve as the harness flooder. Worth
knowing because SENSOR_PID defaults to the 8812AU.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j

OpenIPC#440 gave the RTL8733B a busy_window_reset() in Stop() and argued the other
Realtek backends shared the hole. They did. This measures it and closes it,
and adds the on-air arm that proves both halves.

THE BUG. An armed window survives Stop(), and the next retune's note then
stamps it Retuned — a spoil reason earned by a hardware session that no
longer exists. The mechanism differs by backend, and the version in OpenIPC#440's
commit message was only right for one of them: on the RTL8733B
SetMonitorChannel re-runs bring_up_to_phy(), which sets the flag with_ccx
gates on back to true. On Jaguar1/2/3 the retune does no bring-up at all —
there the window stays reachable because nothing ever clears _brought_up.

MEASURED, tests/busy_window_probe.sh --mode revive (arm, Stop(), retune,
read), each die with its own reset removed and then restored:

  RTL8812AU  (jaguar1)   spoil=retuned -> none
  RTL8822BU  (jaguar2)   spoil=retuned -> none
  RTL8812CU  (jaguar3)   spoil=retuned -> none
  RTL8733BU              spoil=retuned -> none

THE ASSERTION IS ON THE REASON, NOT THE READING, and the Jaguar2 is why. A
first cut asserted the post-Stop read was invalid; that is true on three dies
and FALSE on the Jaguar2, whose Stop() only joins its runtime threads and
leaves the chip live, so its sampled path answers with a 2 ms window. That
assertion would have failed a correct backend. What holds everywhere is that
no spoil reason survives the session that ended. Verified through the harness
on the Jaguar2 specifically: with the reset removed the arm fails on the
spoil check alone (3/3 not spoil=none), which is the only signal available on
that die.

WHAT THE RESETS DO NOT CLOSE, on the Jaguars. Unlike the RTL8733B — whose
Stop() holds its recursive register lock across the whole body, which
with_ccx takes first — there is no such span on Jaguar1/2/3, so a concurrent
ArmChannelBusy can still land after the reset and during teardown.
ArmChannelBusy is single-control-thread by contract (IRadio.h). Closing it
properly means clearing _brought_up in Stop(), which gates other paths and is
a behaviour change of its own; src/rtl8733b/CLAUDE.md records it as the half
that stays open.

LOCKING. Every reset takes the CCX mutex alone in a scope that acquires no
other lock and calls no device method. Jaguar3's sits OUTSIDE _reg_mu rather
than inside it as the RTL8733B's does, and deliberately: Stop() joins the
coex thread, which takes _reg_mu, so holding it across the join would
deadlock — the same shape as an earlier round's self-inflicted self-deadlock.
Jaguar1 has no family-wide register lock (its _port0_mu is narrower and never
taken under the CCX lock). No path takes the CCX mutex and then a register
lock.

THE ARM REFUSES WHERE IT CANNOT RUN. MediaTek's Stop() closes the device and
nulls the handle, leaving no retune path (measured: the probe wedges). With
SENSOR_RX=1 the Realtek Init runs on a detached thread that Stop() would be
torn down underneath — the use-after-free this probe's own cleanup path
exists to avoid, and the sequence RtlJaguar3Device::InitWrite refuses
outright. Both are gated on the sensor VID and the RX flag rather than a
capability, because there is no cap for "Stop() is survivable"; that is a
lifecycle fact, not a feature one.

Also in this change: expect_all accepted a partial record set, so a probe
that died after one rep passed every arm — it now requires REPS records, as
expect_spoil does. The harness header listed ten arms and claimed nine; it
now lists and counts eleven, and says the list is by role rather than run
order. The MediaTek skip total is 8 (7 at the early exit plus the revive
arm), correcting the 7 stated in OpenIPC#440.

Verified: ctest 69/69, ASan/UBSan clean on both selftests, and on air —
full harness PASS with an RTL8733BU sensor against an RTL8822BU flooder
(63% under load) and with the Jaguar2 as sensor against the RTL8733BU
(61%); the revive arm passing on all three dies present; the RX guard
refusing rather than wedging.

Pre-existing and unrelated, verified at fc66aa9 without these changes: the
txsess arm hangs on a Jaguar1 sensor, and an RTL8812AU txdemo exits after
~482 frames so that die cannot serve as the harness flooder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Reset Jaguar busy windows when hardware sessions stop

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Reset armed busy windows when Jaguar hardware sessions stop.
• Add revive probing to prevent stale spoil reasons crossing hardware sessions.
• Harden sample-count checks and document the remaining Jaguar lifecycle gap.
Diagram

sequenceDiagram
    participant H as Probe Harness
    participant B as Jaguar Backend
    participant W as Busy Window
    participant R as Radio Hardware
    H->>B: Arm window
    B->>W: Store armed state
    H->>B: Stop session
    B->>W: Reset under mutex
    B->>R: End hardware session
    H->>B: Retune channel
    B->>R: Configure channel
    B->>W: Note retune
    H->>B: Read busy state
    B->>W: Query window
    W-->>B: Spoil none
    B-->>H: Backend-specific validity
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Clear Jaguar bring-up state
  • ➕ Prevents post-Stop arms from reaching torn-down hardware.
  • ➕ Closes the documented concurrent arm race more completely.
  • ➖ Changes behavior for every path gated by _brought_up.
  • ➖ Requires broader backend-specific lifecycle validation, especially for Jaguar2.
2. Track hardware session generations
  • ➕ Automatically invalidates windows created by earlier sessions.
  • ➕ Makes lifecycle ownership explicit instead of relying on resets at every teardown path.
  • ➖ Requires invasive changes to busy-window state and all session transitions.
  • ➖ Adds complexity disproportionate to the stale-reason bug.

Recommendation: Keep the scoped per-backend resets in this PR. They directly fix the measured stale spoil reason while respecting each backend's lock ordering and teardown behavior. Address _brought_up lifecycle semantics separately because clearing it would be a broader behavioral change.

Files changed (6) +187 / -9

Bug fix (3) +62 / -0
RtlJaguarDevice.cppReset Jaguar1 busy windows at session stop +20/-0

Reset Jaguar1 busy windows at session stop

• 'Stop()' now clears the armed busy-window state while holding the CCX mutex. This prevents a later retune from assigning a stale 'retuned' spoil reason to a window from the previous hardware session.

src/jaguar1/RtlJaguarDevice.cpp

RtlJaguar2Device.cppReset Jaguar2 busy windows before stopping runtime threads +22/-0

Reset Jaguar2 busy windows before stopping runtime threads

• 'Stop()' now forgets the active busy window under the CCX mutex before joining runtime threads. The reset preserves Jaguar2's live sampled-read behavior while ensuring no prior-session spoil reason survives.

src/jaguar2/RtlJaguar2Device.cpp

RtlJaguar3Device.cppReset Jaguar3 busy windows with safe lock ordering +20/-0

Reset Jaguar3 busy windows with safe lock ordering

• 'Stop()' clears busy-window state before stopping the coexistence thread. The CCX lock scope remains outside '_reg_mu' to avoid deadlocking with the joined thread.

src/jaguar3/RtlJaguar3Device.cpp

Tests (2) +116 / -3
busy_window_probe.cppAdd revive mode for cross-session busy-window testing +49/-1

Add revive mode for cross-session busy-window testing

• Adds a 'revive' probe sequence that arms a window, stops the device, retunes, and reads the result. It rejects RX-loop usage to avoid unsafe detached-thread teardown and deliberately leaves result validity backend-dependent.

tests/busy_window_probe.cpp

busy_window_probe.shAssert revive spoil reasons and complete sample sets +67/-2

Assert revive spoil reasons and complete sample sets

• Adds the Realtek-only revive arm and verifies every returned spoil reason is 'none' without asserting validity. The harness now rejects partial record sets and corrects its documented arm count and skip accounting.

tests/busy_window_probe.sh

Documentation (1) +9 / -6
CLAUDE.mdDocument Jaguar lifecycle fix and remaining gap +9/-6

Document Jaguar lifecycle fix and remaining gap

• The lifecycle notes now record that Jaguar1/2/3 reset pre-Stop windows. They also document that post-Stop arms remain possible because Jaguar backends do not clear '_brought_up'.

src/rtl8733b/CLAUDE.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 20, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Jaguar guidance sits in the wrong guide ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
src/rtl8733b/CLAUDE.md adds Jaguar1/2/3 lifecycle, gating, and teardown guidance even though
dedicated src/jaguar1, src/jaguar2, and src/jaguar3 guides exist. Jaguar maintainers loading
their generation's nearest guide will miss the remaining post-stop arm hazard, while readers of the
RTL8733B guide must distinguish unrelated backend behavior.
Code

src/rtl8733b/CLAUDE.md[R483-486]

+Jaguar1/2/3 had the same hole and are fixed in the same change — measured on
+each die with the reset removed, an arm/Stop/retune/read reports
+`spoil=retuned`. One half of the asymmetry remains on them and is NOT fixed
+here: their `with_ccx` gates on `_brought_up`, which no `Stop()` clears, so an
Evidence
Compliance rule 1 requires new subsystem- and generation-specific guidance to live in the nearest
applicable nested CLAUDE.md. The cited addition places detailed Jaguar lifecycle behavior in the
RTL8733B-specific guide despite dedicated Jaguar guides being present.

CLAUDE.md: Place Documentation in the Narrowest Applicable CLAUDE.md: CLAUDE.md: Place Documentation in the Narrowest Applicable CLAUDE.md
src/rtl8733b/CLAUDE.md[483-491]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The RTL8733B guide now contains lifecycle and teardown guidance specific to the three Jaguar generations, despite each generation having its own nested guide.
## Fix Focus Areas
- src/rtl8733b/CLAUDE.md[483-491]
- src/jaguar1/CLAUDE.md[88-111]
- src/jaguar2/CLAUDE.md[73-94]
- src/jaguar3/CLAUDE.md[107-138]
## Recommended Fix
Move each generation's post-stop busy-window behavior and remaining arm hazard into its corresponding Jaguar guide. Retain only a concise cross-reference in the RTL8733B guide if the comparison is necessary to explain RTL8733B behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/rtl8733b/CLAUDE.md Outdated
Review round on OpenIPC#442. The rule this PR adds was written into one backend's
chip guide and three copies of a code comment; none of that is where a reader
looks for it.

THE CONTRACT NOW HAS A DECLARATION. IRadio::ArmChannelBusy lists the three
spoilers and said nothing about the session ending underneath a window;
IRtlRadio::busy_window_reset described itself as a bring-up concern. Both now
carry the lifetime rule, which is the only place a fifth backend would find
it. The review that prompted this asked only for the Jaguar text to move — it
would have moved out of the RTL8733B guide into three others and still had no
home above them.

PER-GENERATION FACTS MOVED TO THE PER-GENERATION GUIDES, worded per die
rather than copied: Jaguar1's reset sits above the teardown_power_down=0
early return and takes the CCX lock alone (no family-wide register lock
exists on that generation); Jaguar2's Stop() only joins its runtime threads,
so the chip stays live and its sampled path still answers; Jaguar3's reset is
outside _reg_mu because Stop() joins a thread that takes it. The measured
four-die matrix moves to docs/rx-spectrum-sensing.md, which the root guide
designates for measured numbers and the generation matrix.

The three Jaguar Stop() comments each asserted RTL8733B internals to explain
their own behaviour; each is now self-contained.

TWO ERRORS OF MINE THAT THIS ROUND CAUGHT, both worth naming because the
first was at the declaration site:

  - The new IRadio.h text said a surviving window "stays reachable across the
    next bring-up". That is the RTL8733B's mechanism and only its own — on
    Jaguar1/2/3 the retune runs no bring-up at all and the window survives
    because nothing clears _brought_up. This PR's own commit message says so
    two paragraphs down. Corrected in both the header and the doc.
  - The new doc section was inserted between the spoiler table and the two
    paragraphs that reference its rows by position ("The third row...", "The
    first row..."), silently re-pointing both at the wrong table. Moved below
    them.

Also: the RTL8733B guide read as contradicting itself, saying in consecutive
paragraphs that clearing _phy_ready does not protect a window and that it
does — true of a window armed before a Stop and an arm issued after one
respectively, now hinged. It also said the Jaguars lack "both" halves of the
protection; they lack either. And the RTL8733BU row of the new matrix
implied its "no reading" came from teardown depth, when that die has no
sampled path at all.

Repaired three mangled comment lines (a blank ' *' welded onto the end of the
preceding line) introduced by 2f95498 in the Jaguar Stop() blocks.

Comments and docs only; no behaviour change. Verified: ctest 69/69,
ASan/UBSan clean on both selftests, and the on-air harness PASS on an
RTL8733BU sensor against an RTL8822BU flooder, 63% under load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j
@snokvist

Copy link
Copy Markdown
Collaborator Author

Addressed in 200a880, and the finding was the right thread to pull.

The placement finding is correct — Jaguar1/2/3 lifecycle facts were in src/rtl8733b/CLAUDE.md while all three generations have their own guides. Moved, and worded per die rather than copied: Jaguar1's reset sits above the teardown_power_down=0 early return and takes the CCX lock alone (no family-wide register lock exists there); Jaguar2's Stop() only joins its runtime threads so the chip stays live; Jaguar3's reset is outside _reg_mu because Stop() joins a thread that takes it. The measured four-die matrix went to docs/rx-spectrum-sensing.md, which the root guide designates for measured numbers.

But moving it alone would have left the rule homeless. IRadio::ArmChannelBusy lists the three spoilers and said nothing about the session ending under a window, and IRtlRadio::busy_window_reset described itself as a bring-up concern. So the contract existed only in one chip guide and three copies of a code comment. Both declarations now carry it — that is where a fifth backend would actually look.

Also fixed while in there: the three Jaguar Stop() comments each asserted RTL8733B internals to explain their own behaviour; each is now self-contained.

Two errors of mine that this round caught, the first at the declaration site:

  • My new IRadio.h text said a surviving window "stays reachable across the next bring-up". That is the RTL8733B's mechanism and only its own — on Jaguar1/2/3 the retune runs no bring-up at all, and this PR's own commit message says so. Corrected in the header and the doc.
  • My new doc section landed between the spoiler table and the two paragraphs that reference its rows by position ("The third row...", "The first row..."), silently re-pointing both at the wrong table. Moved below them.

And three smaller ones: the RTL8733B guide read as contradicting itself about _phy_ready (true of a window armed before a Stop vs an arm issued after one — now hinged); it said the Jaguars lack "both" halves of the protection when they lack either; and the RTL8733BU row implied its "no reading" came from teardown depth when that die has no sampled path at all.

Comments and docs only, no behaviour change. ctest 69/69, ASan/UBSan clean, and the on-air harness still PASSes on an RTL8733BU sensor against an RTL8822BU flooder at 63% under load.

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.

1 participant