Skip to content

jaguar1/2/3: the Realtek station arm for IRadio::SetStationIdentity - #463

Merged
josephnef merged 1 commit into
OpenIPC:masterfrom
snokvist:pr/realtek-station-arm
Oct 2, 2026
Merged

josephnef merged 1 commit into
OpenIPC:masterfrom
snokvist:pr/realtek-station-arm

Conversation

@snokvist

@snokvist snokvist commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

jaguar1/2/3: the Realtek station arm for IRadio::SetStationIdentity

First library item of #461: SetStationIdentity / ClearStationIdentity on Jaguar1, Jaguar2 and Jaguar3, plus the on-air cell that measures both halves of station_mode_ok's bar on a Realtek die. Both halves passed with their controls on one RTL8812CU and one RTL8812BU, so station_mode_ok is true on the 8822C and 8822B dies only and stays false on every other Realtek die.

What changed

  • src/StationArm.h (new) - the per-device state behind the seam on the three generations that share the port-0 register map, over a station recipe added to src/AckResponder.h (arm_station / station_is / restore_station): gate closed, MACID = own, BSSID = the AP, net_type = Infra, all read back; Clear restores the exact pre-arm MACID, BSSID and net_type bits and reads them back. The receive filter is left alone (monitor RCR; a src/sta station filters in software, as the MT7612U one does).
  • Jaguar1 / Jaguar2 / Jaguar3 backends - SetStationIdentity / ClearStationIdentity overrides, each under the backend's existing port-0 lock. Where the MT7612U arm differs, the header says why:
    • configure, not check. Jaguar2/3 bring-up never programs MACID and net_type comes up NoLink, so a port left as bring-up made it does not answer for own; the arm has to write it (StationArm.h, "CONFIGURE, NOT CHECK").
    • refusable: on bad arguments - group, equal, or all-zero addresses, since is_unicast alone passes 00:00:00:00:00:00, which would program MACID = 0 (an existing arm stays in place, as on the MT7612U); until Init/InitWrite has made its last port-0 write; while a beacon or an ACK responder owns port 0 (the beacon's own record on Jaguar2/3, not net_type, including a beacon whose failed start left its enables set), and any readback mismatch - rolled back and verified, or left recorded so Clear can retry it.
    • later port-0 claimants are refused, not dropped: while a station is armed, SetAckResponder and StartBeacon return false, and on Jaguar2/3 ClearAckResponder leaves the port alone (its gate-only clear would close the station's net_type). IRadio leaves the rule backend-specific; the MT7612U drops its arm instead.
    • Clear verifies its rollback (true only when restored AND read back; true trivially when nothing was armed). Its gate close is a precondition: if it fails, the identity is not touched under a live port and the arm stays recorded for a retry.
    • the arm ends with the session: Stop() and the destructor clear it, because Jaguar2's teardown does not power the chip down and Jaguar1's power-down is optional - otherwise MACID = own / Infra goes on acknowledging after the process has gone. A re-Init/InitWrite of an armed object clears it before the bring-up; a clear that does not verify keeps the record, so ClearStationIdentity can retry it (Jaguar2/3 bring-up does not reprogram MACID or net_type). The beacon disable leaves net_type alone while a station holds it.
    • the arm-time retry-limit WARN the MT7612U prints, same condition and wording; on the 8814A, whose descriptor ignores tx.retry_limit (tx_retry_limit_ok false), it says the die sends every unicast once.
    • ordering: the port-0 writers are bring-up, the tail of Init/InitWrite (the BF beamformee identity into MACID, the configured ACK responder), the beacon and the ACK responder - no RX-loop start writes 0x0102/0x0610/0x0618. So each backend gates the arm on its own _station_ready: cleared at the top of Init/InitWrite, committed after that bring-up's last port-0 write, and left false by a bring-up that throws. It is not _brought_up, which goes true mid-bring-up because the tail's own setters (CCA, crystal cap) apply live only once it is set. Before or after StartRxLoop is the same.
  • Jaguar3 RX-thread locking: the CFO tracker and the BF apply took _reg_mu blocking on the RX thread, which in the Async ring is the libusb event thread. A _reg_mu holder doing synchronous USB I/O (the coex tick already, and now the station arm) then waits for that thread forever: IRadio's StartRxLoop lock rule, broken inside the library. Both now try_lock. The CFO tracker takes the lock before it steps, so a skipped tick does not feed its polarity detection a step that was never written. The BF apply logs a debug line when it skips, and when its register access fails; the next CBR retries. Fixed from reading the code; the same shape was caught in gdb in a caller on the station branch, but this in-library instance has not been reproduced on hardware.
  • Demo knob DEVOURER_STA_IDENTITY=<own|self>,<bssid> (+ DEVOURER_STA_CLEAR_AFTER_MS) on rxdemo, and on txdemo with DEVOURER_TX_WITH_RX=thread: calls the seam once the RX loop is up and emits sta.arm / sta.clear (examples/common/station_arm_env.h, docs/logging.md). It means the same on every backend: it calls the seam and reports the return value. An arm is refused (sta.arm why:"rx_not_running") when the demo's RX worker has failed or ended, and txdemo waits for its RX loop's first frame (3 s cap) rather than a fixed delay. txdemo sends nothing after a refused arm, and rxdemo's PCIe path refuses the knob rather than running unarmed.
  • tests/realtek_station_onair.sh - the on-air cell (below). An arm is scored only when its transmitter kept airing through the window (no gap over MAX_GAP_MS between its CCX reports or after the last one; the final tx.stats now carries t for that), submitted at least MIN_SUBMITTED frames (default: a quarter of the nominal SECS/GAP_US rate, 500), and the receiver was still running at the end of it. Reception in arm A is judged against the frames the peer reported, which aired, with the submitted-but-unreported count printed beside it. tests/station_arm_selftest.cpp - ctest station_arm, gated on any Jaguar1/2/3 option being built.
  • AdapterCaps::station_mode_ok is TRUE on the 8822C (Jaguar3) and 8822B (Jaguar2), scoped by variant. It is FALSE on the 8822E, the 8821C, every Jaguar1 die, Kestrel and the RTL8733B. The declaration carries the numbers and their limits; docs/realtek-station-arm.md holds the full bench record.

Why

#461: "SetStationIdentity returns the not-ported false on Jaguar1/2/3, Kestrel and the RTL8733B ... the seam needs its own measured arm per family, with the same two halves of the bar." On Realtek the ACK engine matches address 1 against MACID and net_type gates it, so the arm is a configuration, not the MT7612U's check, and the port is shared with two existing claimants.

What is measured

tests/realtek_station_onair.sh asks the transmitter in both halves: a devourer Realtek peer's per-frame CCX reports for frames sent TO the armed station, and the station's own CCX reports for frames it sends. Unlike the MT7612U cells, it arms through the seam itself. Rig: ch6, near field, retry limit 12, MCS3. One RTL8812CU (8822C) and one RTL8812BU (8822B) served as each other's peer, with an MT7612U on mt76x2u running hostapd as the AP. rtw88 was not blacklisted: the demos detached it, and every adapter was handed back. There are three records on this rig: the first on the arm's first version (A to G), a second that added arm H, and the current one on this head (fc7eb72, after both review rounds), scored by the stricter harness. Every run exited 0 with 5 passed, 0 failed, 0 inconclusive, every arm read live=1, and the records match arm for arm within a few frames and a few hundredths of a retry. The full first record is in docs/realtek-station-arm.md.

Current record (fc7eb72). Each cell is reports / submitted, ok%, mean retries, then rx_distinct where the arm counts reception:

arm 8812CU station 8812BU station
A armed, to own 1626/1668, 99.9%, 0.05, rx 1625 859/909, 100.0%, 0.29, rx 859
B armed, to nobody 866/1703, 0.0%, 12.00, rx 876 85/923, 0.0%, 12.00, rx 88
C DUT absent 866/1696, 0.0%, 12.00 87/918, 0.0%, 12.00
D unarmed 865/1704, 0.0%, 12.00, rx 1109 84/917, 0.0%, 12.00, rx 87
E armed then cleared 865/1702, 0.0%, 12.00, rx 1106 88/928, 0.0%, 12.00, rx 91
F uplink to the AP 2389/2439, 100.0%, 0.17 3183/3225, 100.0%, 0.30
G uplink to nobody 234/1404, 0.0%, 12.00 1656/2588, 0.0%, 12.00
H uplink to the AP, unarmed (reported, not scored) 2414/2464, 100.0%, 0.17 3194/3236, 100.0%, 0.33

The largest gap between reports was 11-74 ms on every arm, against the 2000 ms liveness limit. The maintainer's run on his own rig (8812CU and 8812BU, at 6737727) has the same shape, H included.

The arm is what the DOWN half needs, not the UP half. Arm H, the uplink with the station NOT armed, was ACKed 100% on both dies, at the same retries as armed F. So on Jaguar2/3 the AP acknowledges by address whether or not the station is armed. Unarmed (D) or cleared (E), the DUT ACKs nothing addressed to it. The flag rests on both halves being met while armed, which is how a station runs. H shows the uplink half holds without the arm too.

The adversarial side:

  • Small sample. One unit per die, two runs per arm on one rig (one for H), near field, one channel, one AP type (an MT7612U running hostapd), and unassociated throughout.

  • "Received" is not the discriminator. In arm A it is the DUT's rx.seq count of distinct frames from the peer. The controls received too: B, D and E show about 870–1110 frames on the CU and about 86–93 on the BU. The station runs promiscuous, so it receives frames not addressed to it, and while unarmed. It just does not ACK them. What the arm changes is the ACK.

  • Submitted exceeds reports on every arm.

    • Acknowledged arms (A, F, H): 40–50 frames.
    • B to E: about half the submissions go unreported on the CU, and about nine in ten on the BU.
    • G: 83–84% go unreported on the CU (234 of 1404 reported on this head) and 36–37% on the BU (1656 of 2588).

    That fits frames still queued in the chip when the window closes, since each unacknowledged frame airs 13 times before its report. It is not proven. In arm A, rx_distinct equals the report count on the BU and is one short of it on the CU.

  • The 8822E is not measured by this cell. Its flag stays false.

  • The station branch's earlier numbers are not used. It ran these dies through an associated client that is not in this PR, and recorded duplicate ratios: armed 2/24111 against unarmed 42122/14126 on the 8812CU, and 13/24115 against 35117/11744 on the 8812BU. That evidence was indirect, and its uplink half had no control. The flag rests on the cell above.

  • Jaguar1 has no cell here. On the station branch, the 8812AU's unarmed control did not discriminate (38 duplicates against 27), because bring-up programs the EFUSE MAC into MACID.

What it can't do

  • No managed receive filter: a Realtek station runs promiscuous, like the MT7612U one through IRadio.
  • Unassociated cells only: power save, TIM, cross-BSS duplicate detection and hardware key lookup are untested.
  • The arm reads BSSID back, so a die whose 0x0618 does not read back would refuse to arm.
  • Kestrel and the RTL8733B keep the not-ported default: the station branch has no arm for them.
  • Unmeasured with the new cell: the 8821C, 8814A, 8821A, the 8811AU cut, and the 8822E.

Follow-ups

  • Repeat runs, a second unit per die, a far-field placement and a non-MT7612U AP for the 8822C/8822B record.
  • Cells for the 8822E and the 8821C, which share the code.
  • A Jaguar1 cell, with EXPECT_UNARMED_SILENT=0 on the 8812 (its control is predicted not to discriminate).
  • The src/sta caller (Follow-ups left open by the station identity seam (#460) #461, third library item) - the associated end-to-end run the station branch's table came from.

Verification

  • ctest, -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON: 82/82, station_arm included; the same under -DDEVOURER_SANITIZE=address+undefined: 82/82, no reports.
  • ctest on the CI per-chip subsets (previous head) jaguar1-only, 8822b-only, 8821c-only, jaguar2-only, jaguar3-only, rtl8733b-only, kestrel-only and mt7612u-only: all passed. station_arm is registered only where a Jaguar backend is built.
  • bash -n and shellcheck clean on tests/realtek_station_onair.sh and tests/mt7612u_sta_lib.sh (comment-only change).
  • On air, on this head, both ways: 5 passed, 0 failed, 0 inconclusive each (the table above). All three adapters were handed back to their kernel drivers, with no hostapd or monitor interface left. Commands:
    • sudo DUT_PID=0xc812 DUT_SYSFS=5-1 PEER_PID=0xb812 PEER_SYSFS=8-1 AP_SYSFS=1-1 tests/realtek_station_onair.sh
    • the same with the DUT and PEER values swapped.

Review record

qodo's automatic pass (7 threads), each verified against the code and fixed:

  1. Stalled transmitters could pass: usable() now requires live=1 - no silence over MAX_GAP_MS between reports or after the last one.
  2. txdemo armed after a fixed 500 ms: it now waits for its RX loop's first frame and refuses the arm if the RX worker failed or ended.
  3. rxdemo's hop and sweep paths could arm after their worker's Init failed: the worker publishes its end and the arm is refused.
  4. The reception verdict divided by submitted frames: it now divides by reported frames and prints the unreported count.
  5. kill -0 passes on an exited, unreaped child: liveness reads the process state from /proc/PID/stat (Z is dead).
  6. The BF apply's catch was silent: it now logs the failure and that the next CBR retries.
  7. retire() dropped the snapshot when its clear failed: it now keeps the record so a later Clear retries. tests/station_arm_selftest.cpp covers both outcomes.

Maintainer review:

  • The arm could land inside the tail of Init/InitWrite. _brought_up goes true before the BF beamformee identity write into 0x0610 and the configured SetAckResponder. A SetStationIdentity from another thread in that window armed. The beamformee write then overwrote MACID while the station still read as armed, or the config arm was refused and Init threw. The arm now waits for _station_ready, which each Init/InitWrite clears at its start and commits only after its last port-0 write, on all three generations (src/jaguar{1,2,3}/RtlJaguar*Device.{cpp,h}). It is a separate flag rather than a later _brought_up, because the tail's own SetCcaMode, SetXtalCap and SetAmpduMode apply live only once _brought_up is true; committing that later would change what bring-up does. Not changed, and as before: Stop() clears an arm but leaves the flag set, so an arm after Stop() is accepted, as it was under _brought_up. The ORDERING clause in src/StationArm.h is reworded to match. No selftest reaches it: the flag lives in the backends, and only a device exercises them.
  • An all-zero own was accepted. station_args_ok (src/AckResponder.h) now refuses a zero address on either side. Selftest: zero own, zero BSSID, and a zero own against a live arm, which writes nothing and keeps the arm.
  • A transmitter that stalled after MIN_REPORTS could still score. Each arm now also needs MIN_SUBMITTED submissions (tests/realtek_station_onair.sh).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3

@snokvist
snokvist requested a review from josephnef October 1, 2026 18:13
@snokvist

snokvist commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef PR5 of the station series: the first library item of #461, the Realtek station arm behind the SetStationIdentity seam from #460.

It is one commit on 87dde96. The arm is ported to Jaguar1/2/3. station_mode_ok is true only on the 8822C and 8822B dies, where a new on-air cell (tests/realtek_station_onair.sh) met both halves of the bar in two runs each way:

  • DOWN: armed 100% ACKed and received, against 0% for nobody, the DUT absent, unarmed and cleared.
  • UP: 100% ACKed against 0% to nobody.

The new unarmed-uplink arm (H) answers a question #461 left open: the AP acknowledges the station's frames whether or not it is armed, so on Jaguar2/3 the arm is what the downlink half needs. Every other die stays false.

Before opening, two independent review passes ran, each finding checked against the tree. The limits are in the description: one unit per die, one rig, near field, and the AP is an MT7612U running hostapd. Jaguar1 has no measurement on this bench.

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

Copy link
Copy Markdown

PR Summary by Qodo

Add Realtek station identity arms for Jaguar1, Jaguar2, and Jaguar3

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Implement verified station identity arming and rollback across Jaguar1/2/3 without displacing
 other port-0 owners.
• Fix Jaguar3 RX-thread register-lock contention that could deadlock synchronous USB operations.
• Add demo controls and tests; mark station mode supported only on measured 8822B and 8822C dies.
Diagram

graph TD
  API["IRadio station seam"] --> Backends["Jaguar backends"] --> Guard{"Port available?"}
  Guard -->|yes| Arm["StationArm state"] --> Recipe["Station register recipe"] --> Registers["Port-0 registers"]
  Guard -->|no| Refusal["Refuse claim"]
Loading
High-Level Assessment

Keep the shared register recipe and snapshot state with backend-owned locking and ownership checks. Copying the transaction into each backend would make rollback behavior easier to diverge; moving all ownership into the shared helper would obscure backend-specific beacon and locking state.

Files changed (19) +2102 / -16

Enhancement (11) +917 / -13
station_arm_env.hShare demo station identity controls +165/-0

Share demo station identity controls

• Parses station and scheduled-clear settings, calls the IRadio seam with bounded retries, and emits outcome events.

examples/common/station_arm_env.h

main.cppWire station arming into rxdemo +37/-0

Wire station arming into rxdemo

• Rejects the unsupported PCIe path or malformed settings. Starts a managed worker to arm after RX begins and optionally clear before shutdown.

examples/rx/main.cpp

main.cppWire station arming into txdemo +53/-0

Wire station arming into txdemo

• Requires threaded RX, arms before transmission, sends nothing after refusal, and joins an optional scheduled-clear worker.

examples/tx/main.cpp

AckResponder.hAdd shared station port-0 register recipe +136/-0

Add shared station port-0 register recipe

• Adds snapshot, arm, readback, restore, and argument-validation helpers for MACID, BSSID, and net_type. Identity writes follow a gate close, and restore success depends on readback.

src/AckResponder.h

StationArm.hManage shared station arm state and rollback +197/-0

Manage shared station arm state and rollback

• Stores the original port identity, coordinates verified arm and clear operations, and retains failed rollbacks for retry. Also handles re-init retirement and retry-limit warnings.

src/StationArm.h

RtlJaguarDevice.cppImplement Jaguar1 station identity lifecycle +72/-0

Implement Jaguar1 station identity lifecycle

• Adds locked station seam overrides and refuses competing beacon or ACK-responder claims. Clears the arm on stop, destruction, and re-initialization; leaves station_mode_ok false pending measurement.

src/jaguar1/RtlJaguarDevice.cpp

RtlJaguarDevice.hDeclare Jaguar1 station ownership state +15/-0

Declare Jaguar1 station ownership state

• Adds shared StationArm state and station seam overrides under the existing port-0 mutex.

src/jaguar1/RtlJaguarDevice.h

RtlJaguar2Device.cppImplement Jaguar2 station arm and port arbitration +94/-2

Implement Jaguar2 station arm and port arbitration

• Adds locked station arming, verified clearing, claimant refusals, and lifecycle cleanup. Preserves station net_type during beacon disable and enables station_mode_ok only for 8822B.

src/jaguar2/RtlJaguar2Device.cpp

RtlJaguar2Device.hDeclare Jaguar2 station seam and state +12/-0

Declare Jaguar2 station seam and state

• Adds StationArm state guarded by the register mutex and declares station identity overrides.

src/jaguar2/RtlJaguar2Device.h

RtlJaguar3Device.cppImplement Jaguar3 station arm and avoid RX lock waits +124/-11

Implement Jaguar3 station arm and avoid RX lock waits

• Adds locked station arming, port arbitration, lifecycle cleanup, and 8822C-only capability reporting. Changes RX-thread CFO and beamforming register access to try-lock so synchronous USB callers cannot deadlock the event thread.

src/jaguar3/RtlJaguar3Device.cpp

RtlJaguar3Device.hDeclare Jaguar3 station seam and state +12/-0

Declare Jaguar3 station seam and state

• Adds StationArm state guarded by the register mutex and declares station identity overrides.

src/jaguar3/RtlJaguar3Device.h

Documentation (5) +134 / -3
CLAUDE.mdDocument station demo controls +5/-0

Document station demo controls

• Adds the station identity and scheduled-clear environment variables, their events, and the Realtek on-air cell to the developer guide.

CLAUDE.md

logging.mdDescribe station arm and clear events +2/-0

Describe station arm and clear events

• Documents the outcomes and fields emitted by the RX and TX demo station controls.

docs/logging.md

realtek-station-arm.mdRecord station-mode on-air evidence +93/-0

Record station-mode on-air evidence

• Records downlink and uplink results for 8822C and 8822B, their controls, and the limits of the measurement.

docs/realtek-station-arm.md

AdapterCaps.hDocument measured station-mode capability +31/-1

Document measured station-mode capability

• Records the evidence and scope behind enabling station_mode_ok on 8822B and 8822C while leaving unmeasured dies unsupported.

src/AdapterCaps.h

mt7612u_sta_lib.shNote shared station harness helpers +3/-2

Note shared station harness helpers

• Clarifies that the existing helper library also serves the new Realtek on-air cell.

tests/mt7612u_sta_lib.sh

Other (3) +1051 / -0
CMakeLists.txtRegister the Jaguar station self-test +13/-0

Register the Jaguar station self-test

• Builds and registers the headless station-arm test only when a Jaguar1, Jaguar2, or Jaguar3 backend is enabled.

CMakeLists.txt

realtek_station_onair.shMeasure Realtek station ACK behavior on air +595/-0

Measure Realtek station ACK behavior on air

• Adds downlink and uplink arms with absent, unarmed, cleared, and nobody-addressed controls. Scores transmitter CCX reports, checks receive and process liveness, and restores adapter ownership during cleanup.

tests/realtek_station_onair.sh

station_arm_selftest.cppTest station register transactions and failure recovery +443/-0

Test station register transactions and failure recovery

• Uses a fake transport to exercise arming, exact restore, refusals, re-arming, failed or throwing writes, readback, retryable rollback, and retry-limit warnings.

tests/station_arm_selftest.cpp

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

qodo-free-for-open-source-projects Bot commented Oct 1, 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. Stalled transmitters can pass rig checks ✓ Resolved
Description
usable() accepts an arm after only MIN_REPORTS transmit reports, without checking whether
submissions continued through the measurement window. With the default minimum of 50 reports over a
ten-second arm, an injector that sends briefly and then stalls can still reach the downlink
verdicts.
Code

tests/realtek_station_onair.sh[R416-417]

+  n=$(field "$1" reports)
+  [ "${n:-0}" -ge "$MIN_REPORTS" ]
Evidence
The new harness defaults to a ten-second window and a 50-report minimum, but its usability check
examines only the total report count. Its downlink verdicts then score percentages from any arms
that pass that check, without rejecting an injector that stalled after reaching the minimum.

Treat adapter speed as insufficient rig validation
tests/realtek_station_onair.sh[76-82]
tests/realtek_station_onair.sh[409-418]
tests/realtek_station_onair.sh[467-480]

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 new on-air harness can score an arm after a brief burst of reports even if the transmitter stalls for the rest of the window.
## Fix Focus Areas
- tests/realtek_station_onair.sh[225-264]
- tests/realtek_station_onair.sh[409-418]
## Recommended Fix
Record enough submission and timing information to verify that transmission continued during each measurement window. Mark arms that fall below a justified liveness threshold inconclusive before scoring their ACK percentages.

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



Remediation recommended

2. Transmitters arm without a live receiver ✓ Resolved
Description
txdemo starts its receive worker and calls station_arm_run after a fixed 500 ms delay, without
checking whether StartRxLoop finished startup or failed. If that worker exits after its exception
is logged, the backend can still accept the arm and transmission proceeds without the receive loop
needed for the station run.
Code

examples/tx/main.cpp[R1088-1090]

+    std::this_thread::sleep_for(std::chrono::milliseconds(500));
+    if (!devourer::station_arm_run(rtlDevice, *sta_req, *g_ev, logger,
+                                   sta_stop)) {
Evidence
The receive worker catches and logs startup exceptions without communicating failure; the new arm
path checks only the result of SetStationIdentity.

examples/tx/main.cpp[1058-1067]
examples/tx/main.cpp[1086-1099]
examples/common/station_arm_env.h[117-140]

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

## Issue description
A fixed delay does not establish that the transmit demo's receive worker is running before the station arm succeeds.
## Fix Focus Areas
- examples/tx/main.cpp[1058-1097]
## Recommended Fix
Have the receive worker report readiness and startup failure to the arm path. Refuse the station run if receive startup fails, rather than relying on a timer.

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


3. Receive runs can arm after startup fails ✓ Resolved
Description
rxdemo treats the end of its ten-second first-frame wait as permission to call station_arm_run,
without checking the receive worker's outcome. In the hop or sweep paths, a worker can log an Init
failure and exit while the station thread later reports a successful arm on a brought-up but
non-receiving device.
Code

examples/rx/main.cpp[R1902-1907]

+      for (uint32_t s = 0;
+           s < 10000 && !sta_stop.load() && g_rx_count.load() == 0; s += 50)
+        std::this_thread::sleep_for(std::chrono::milliseconds(50));
+      if (sta_stop.load())
+        return;
+      if (devourer::station_arm_run(dev, req, *g_ev, logger, sta_stop))
Evidence
The station thread has only a frame-count wait, while both special-mode workers catch and merely log
their initialization exceptions.

examples/rx/main.cpp[1897-1909]
examples/rx/main.cpp[2075-2085]
examples/rx/main.cpp[2248-2258]

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 station thread can attempt and report an arm after a receive worker has failed.
## Fix Focus Areas
- examples/rx/main.cpp[1897-1909]
- examples/rx/main.cpp[2075-2085]
- examples/rx/main.cpp[2248-2258]
## Recommended Fix
Publish receive-worker failure to the station thread and suppress the arm when startup failed. Preserve the silent-channel timeout only when receive startup is known to have succeeded.

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


4. Queued frames can fail the receive test ✓ Resolved
Description
realtek_station_onair.sh divides distinct received frames by tx.stats.submitted, although
submission does not establish that a frame aired before the window closed. When a transmitter has an
unsent backlog, the test can fail its receive threshold even if the device received every frame that
actually aired.
Code

tests/realtek_station_onair.sh[R474-476]

+    sub=$(field A submitted); rxd=$(field A rx_distinct)
+    if [ "${sub:-0}" -gt 0 ] &&
+       awk -v r="${rxd:-0}" -v s="$sub" -v m="$MIN_RX_PCT" 'BEGIN{exit !(100*r/s >= m)}'; then
Evidence
The script reads submitted from the final transmit stats and uses it as the receive denominator; the
bench record explicitly documents a submission-to-report gap at window close.

tests/realtek_station_onair.sh[238-261]
tests/realtek_station_onair.sh[474-480]
docs/realtek-station-arm.md[81-90]

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 downlink receive verdict counts queued submissions as frames the device had an opportunity to receive.
## Fix Focus Areas
- tests/realtek_station_onair.sh[225-263]
- tests/realtek_station_onair.sh[474-480]
## Recommended Fix
Use a justified on-air or completed-frame denominator for the receive verdict, and separately report submissions that remain unaccounted for at window close.

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


View review recommended (2)
5. A crashed receiver can pass a control ✓ Resolved
Description
down_arm uses kill -0 as its only post-window liveness check, then summarizes the receiver log
if that check succeeds. An exited but unreaped child can still pass that check, so partial data from
a receiver that died during the window can be scored, including as a zero-ACK control.
Code

tests/realtek_station_onair.sh[R342-344]

+  if [ -n "$dut" ] && ! kill -0 "$dut" 2>/dev/null; then
+    echo "$tag ABORTED the DUT died during the window: $(tail -1 "$OUT/dut_$tag.err" 2>/dev/null)" > "$res"
+    rm -f "$OUT/.pid_dut"; return 0
Evidence
The new liveness branch tests only PID existence, and the following path reaps the child and scores
its log; the usability test requires reports but no clean receiver completion.

tests/realtek_station_onair.sh[340-352]
tests/realtek_station_onair.sh[409-418]
tests/mt7612u_sta_lib.sh[149-171]

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

## Issue description
A PID-existence check cannot distinguish a running receiver from an exited, unreaped child.
## Fix Focus Areas
- tests/realtek_station_onair.sh[340-352]
## Recommended Fix
Check whether the receiver process has exited before scoring its log, and mark the arm inconclusive if it ended during the peer window.

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


6. Beamforming apply failures go unlogged ✓ Resolved
Description
The new apply_vmatrix handler catches every exception without logging the failed register
operation. When a transfer throws while processing a peer report, apply remains disabled for another
attempt but its failure has no diagnostic alongside the logged busy-lock case.
Code

src/jaguar3/RtlJaguar3Device.cpp[R444-445]

+              } catch (...) {
+              }
Evidence
The catch is empty, while the surrounding code explicitly logs a skipped apply when the lock is busy
and sets the applied flag only after the transfer returns.

src/jaguar3/RtlJaguar3Device.cpp[424-445]

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

## Issue description
A beamforming register-transfer failure is caught silently in the receive callback.
## Fix Focus Areas
- src/jaguar3/RtlJaguar3Device.cpp[433-445]
## Recommended Fix
Retain the callback exception boundary, but log the caught exception and identify that beamforming apply will be retried.

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



Informational

7. A failed clear before re-Init loses the undo ✓ Resolved
Description
StationArm::retire() calls forget() unconditionally, so when its clear() fails the pre-arm
snapshot is discarded and armed() reads false. On Jaguar2/3, whose bring-up never programs MACID
or net_type, the port can then keep MACID = own (and possibly Infra), while ClearStationIdentity
returns true trivially and a later arm either refuses on the leftover net_type or snapshots own as
the pre-arm identity.
Code

src/StationArm.h[R81-89]

+  void retire(RtlAdapter &dev, const Logger_t &log, const char *tag) {
+    if (armed()) {
+      try {
+        (void)clear(dev, log, tag);
+      } catch (...) {
+      }
+    }
+    forget();
+  }
Evidence
The StationArm.h header states that Jaguar2/3 bring-up never programs MACID. In the Jaguar3 MAC
init, the only REG_CR writes touch byte 0 (0x0100) and byte 3 (0x0103), never 0x0102. Nothing in
src/jaguar2 writes 0x0102 outside the beacon and ACK-responder paths. So the assumption in
retire() that the bring-up rewrites port 0 does not hold on these dies.

src/StationArm.h[13-18]
src/jaguar3/HalmacJaguar3MacInit.cpp[529-531]
src/jaguar3/HalmacJaguar3MacInit.cpp[605-605]
src/jaguar2/RtlJaguar2Device.cpp[519-525]

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

## Issue description
`StationArm::retire()` drops the restore snapshot even when `clear()` fails. On Jaguar2/3, bring-up does not rewrite MACID or net_type, so the chip can stay armed with no record left to restore it, and `ClearStationIdentity` then returns true without doing anything.
## Fix Focus Areas
- src/StationArm.h[81-89]
## Recommended Fix
Only call `forget()` when `clear()` returns true. Otherwise keep `_restore` and log an error, so a later `ClearStationIdentity` can retry. Alternatively, have the Jaguar2/3 bring-up explicitly reset `0x0102[1:0]` and restore MACID/BSSID from the snapshot before the record is dropped.

ⓘ 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 show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread tests/realtek_station_onair.sh Outdated
Comment thread examples/tx/main.cpp Outdated
Comment thread examples/rx/main.cpp Outdated
Comment thread tests/realtek_station_onair.sh Outdated
Comment thread tests/realtek_station_onair.sh Outdated
Comment thread src/jaguar3/RtlJaguar3Device.cpp
Comment thread src/StationArm.h

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed every hunk against the tree at 6737727 and ran the on-air cell on this rig. The arm, its refusals, the Clear rollback and the Jaguar3 try_lock change read correctly; the StationArm state machine matches the selftest and the documented contract. Two defects below, both small to fix. The hardware record reproduces yours.

Changes requested

1. _brought_up is set before the last port-0 writes of Init/InitWrite, so the "refused before bring-up" guard does not give the ordering StationArm.h claims.
src/jaguar2/RtlJaguar2Device.cpp:255 (also src/jaguar3/RtlJaguar3Device.cpp:140, src/jaguar1/RtlJaguarDevice.cpp:2079). After the flag goes true and outside _reg_mu, bring-up still writes the beamformee identity into 0x0610 (J2:546, J3:164) and arms the configured ACK responder (J2:566, J3:184, J1:134/1697). A SetStationIdentity from another thread in that window passes the guard and arms; then either kBfeeMac overwrites MACID while _station.armed() stays true (Clear later "verifies" against a snapshot that no longer describes the port), or the config SetAckResponder is refused because the station holds port 0 and Init throws "configured ACK responder could not be armed". rxdemo spawns its arm thread before dev->Init (examples/rx/main.cpp:1901 vs 2079/2251) and arms at the 10 s silent-channel cap regardless of bring-up state, so a slow bring-up hits this without any library caller. Scope, honestly: the window only has content when bf.beamformee_of or rx.ack_responder is configured; the default tail after the flag is apply_replay_wseq and the bb_dump. Fix shape: set _brought_up after the last port-0 write (J3's InitWrite already has the BroughtUpGuard pattern to commit late), or hold _reg_mu across the tail. The ORDERING clause in StationArm.h:46-48 and docs/realtek-station-arm.md should then be true as written.

2. station_args_ok accepts an all-zero own. src/AckResponder.h:418. is_unicast tests only the I/G bit, so 00:00:00:00:00:00 passes and arm_station programs MACID = 0. The same header documents why that is unsafe (has_safe_restore_mac, line 255: "stops TX scheduling on the Jaguar1 8812 path"), and DEVOURER_STA_IDENTITY lets a typo produce exactly that address, reported as a successful arm. Apply the zero check to own as well.

Hardware record (this rig, this head, one run each)

Rig as in the PR: ch6, near field, MCS3, retry limit 12, MT7612U hostapd AP, rtw88 blacklisted. 8812CU at 3-2.4, 8812BU at 4-2.3.3. Every adapter was handed back after each cell.

arm 8812CU station 8812BU station
A armed, to own 698/740, 100.0%, 0.13, rx 698 1664/1714, 100.0%, 0.30, rx 1664
B armed, to nobody 353/732, 0.0%, 12.00, rx 365 164/1218, 0.0%, 12.00, rx 166
C DUT absent 369/753, 0.0%, 12.00 163/1217, 0.0%, 12.00
D unarmed 364/742, 0.0%, 12.00, rx 577 163/1219, 0.0%, 12.00, rx 165
E armed then cleared 367/746, 0.0%, 12.00, rx 575 160/1216, 0.0%, 12.00, rx 163
F uplink to AP 3035/3085, 100.0%, 0.02 2080/2122, 100.0%, 0.02
G uplink to nobody 290/1572, 0.0%, 12.00 1078/2010, 0.0%, 12.00
H uplink unarmed (not scored) 3137/3187, 100.0%, 0.01 2180/2222, 100.0%, 0.02

Both directions: 5 passed, 0 failed, 0 inconclusive. Same shape as your table, including H: the uplink half holds unarmed on both dies.

8822E: INCONCLUSIVE on this rig, not a verdict on the arm. The 0bda:a81a dongle here (adapter.caps says RTL8822E, chip-id 0x17) ran the same cell with EXPECT_UNARMED_SILENT=0, 8812CU as peer: 0 passed, 3 failed. The arm itself succeeded on every arm (sta.arm ok=1, sta.clear ok=1 on E). But the UP half failed because this unit's TX is dead on ch6 regardless of the arm (F, G and H all bulk_send EP 8 FAIL rc=-7, 300+ each - the pre-existing 8812EU TX fault already on record for this unit), and in arm A the DUT delivered 0 distinct frames with a rtw_read(2c08) failure right after the arm, while B/D/E on the same unit delivered 160-1267. A plausible reading is that the first own-addressed frame made the MAC try to ACK through a dead TX path and stalled the chip, but one unit with a known TX fault cannot separate the arm from the unit. The flag stays false there, as the PR says; a second 8822E unit is the follow-up. ctest 80/80 on the PR head, station_arm included.

Not blocking

  • qodo-gate is red on its harness finding (a transmitter that stalls after MIN_REPORTS still scores). Fair as a nit; submitted is already in each res_* line, so a floor on submitted per arm would close it cheaply. Once 1 and 2 are in and the two dies re-run, the skip-qodo-gate label is the way through rather than more rounds.
  • The 10 s silent-channel arm in rxdemo (examples/rx/main.cpp:1901) is the same shape as Qodo's note on arming after a failed Init; closing item 1 in the library leaves that as a demo-only nicety.

SetStationIdentity / ClearStationIdentity on Jaguar1, Jaguar2 and Jaguar3
(OpenIPC#461, first library item), over a station recipe in AckResponder.h and the
per-device state in src/StationArm.h: gate closed, MACID = own, BSSID = the
AP, net_type = Infra, all read back; Clear restores the exact pre-arm
MACID/BSSID/net_type and reads it back.

How it differs from the MT7612U arm, stated at StationArm.h and each
backend's declaration:
- it configures rather than checks: Jaguar2/3 bring-up never programs
  MACID, and net_type comes up NoLink;
- later port-0 claimants are refused rather than dropping the arm.
  SetAckResponder and StartBeacon return false while a station is armed,
  and on Jaguar2/3 ClearAckResponder leaves the port alone;
- Stop() and the destructor clear the arm, because Jaguar2's teardown does
  not power the chip down, and a re-Init of an armed object clears it
  before the bring-up, keeping the record for a retry if that clear does
  not verify.
The station is also refused while a failed beacon start's enables are
still set, and the beacon disable leaves net_type alone while a station
holds it. Clear's gate close is a precondition: if it fails, the identity
is not touched and the arm stays recorded for a retry.

The arm waits for each backend's _station_ready, which is cleared at the
top of Init/InitWrite and committed only after that bring-up's last port-0
write (the BF beamformee identity, the configured ACK responder). It is
not _brought_up, which goes true mid-bring-up for the tail's own setters.
An all-zero own or BSSID is refused: is_unicast alone passes it, and it
would program MACID = 0.
The arm also prints the MT7612U's arm-time retry-limit WARN, and the 8814A
variant says that die airs every unicast once.

Jaguar3's CFO tracker and BF apply took _reg_mu blocking on the RX thread,
the libusb event thread in the Async ring. A _reg_mu holder doing sync USB
I/O (the coex tick, now the arm) would deadlock against them. Both now
try_lock. The CFO tracker takes the lock before it steps, so a skipped tick
cannot feed its polarity detection. Found by reading the code; not
reproduced on hardware.

rxdemo / txdemo (with DEVOURER_TX_WITH_RX=thread) gain
DEVOURER_STA_IDENTITY=<own|self>,<bssid> and DEVOURER_STA_CLEAR_AFTER_MS.
They emit sta.arm / sta.clear events. An arm is refused while the demo's
RX worker has failed or ended; txdemo checks the knob before any of its
threads start, waits for its RX loop's first frame, and sends nothing
after a refused arm.

tests/realtek_station_onair.sh measures both halves of station_mode_ok's
bar from the transmitter's own CCX reports:
- DOWN: a Realtek peer injects to the armed DUT, with controls for a
  destination nobody holds, the DUT absent, the DUT unarmed and the DUT
  armed then cleared, plus the DUT's rx.seq reception;
- UP: the armed DUT sends to a hostapd AP, against a destination nobody
  holds, plus an unarmed uplink arm (H) that is reported and not scored.
An arm is scored only when its transmitter kept airing through the window
(MAX_GAP_MS between CCX reports; the final tx.stats now carries t), it
submitted MIN_SUBMITTED frames, and its receiver was still running at the
end. Reception is judged against the
peer's reported frames.

station_mode_ok is TRUE on the 8822C and 8822B dies only, scoped by
variant. On one RTL8812CU and one RTL8812BU (each the other's peer, an
MT7612U/hostapd AP, ch6, near field, two records on this rig, the current
one on this head matching the first), the claim arms read as follows:
- A (armed, to own): 100.0% ACKed, 0.03 / 0.33 mean retries;
- F (uplink to the AP): 100.0%, 0.09 / 0.20.
Every control (nobody, DUT absent, unarmed, cleared, uplink to nobody)
read 0.0% at the 12-retry limit. Arm H (uplink, NOT armed) was ACKed
100% (0.08 / 0.20): the AP acknowledges by address, so the uplink half
holds without the arm. The arm is what the DOWN half needs (D and E at
0%). The flag rests on both halves met while armed.
Limits, in docs/realtek-station-arm.md:
- one unit per die, two runs on one rig, near field, one AP type;
- the station receives in the controls too, because it is promiscuous; the
  arm changes the ACK;
- submitted exceeds reports on every arm.
The 8822E, the 8821C and every Jaguar1 die stay false, unmeasured.
Kestrel and the RTL8733B keep the not-ported default.

ctest station_arm, gated on the Jaguar options.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
@snokvist
snokvist force-pushed the pr/realtek-station-arm branch from 6737727 to fc7eb72 Compare October 2, 2026 11:17
@snokvist

snokvist commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef thanks, and for running the cell on your rig. Both changes are in fc7eb72 (still one commit on 87dde96), plus the submitted floor.

1. The arm racing the Init/InitWrite tail. The arm now waits for a new flag, _station_ready, instead of _brought_up. Each Init/InitWrite clears it under the station lock at its start, in the same block as retire(), and commits it only after that bring-up's last port-0 write: the BF beamformee identity into 0x0610, the configured SetAckResponder, and the replay. A bring-up that throws never commits it. This is done on all three generations, and SetStationIdentity refuses with "refused until bring-up (Init/InitWrite) has finished".

I didn't move _brought_up itself, because the tail's own SetCcaMode (J1/J2/J3), SetXtalCap (J1/J3) and SetAmpduMode (J1/J2/J3) apply live only once it is true. Committing it after the tail would change what those calls do during bring-up, which is why J3's Init sets it before them and BroughtUpGuard sets it provisionally. The ORDERING clause in StationArm.h and the three backend headers now name the tail writers and the flag.

One thing I left as it was: Stop() clears an arm but leaves the flag set, so an arm after Stop() is accepted, exactly as it was under _brought_up. Say if you want that closed here as well; it is a one-liner per backend.

2. All-zero own. station_args_ok now refuses a zero address on either side; a zero BSSID can come from the same typo, and no AP has one. The selftest gains zero own, zero BSSID, and a zero own against a live arm, which writes nothing and keeps the arm.

Not blocking: the submitted floor. Each arm now also needs MIN_SUBMITTED submissions, a quarter of the nominal SECS/GAP_US rate, which is 500 at the defaults. The INCONCLUSIVE line names it when it bites.

Re-run on fc7eb72 here (ch6, near field, MCS3, retry limit 12, MT7612U hostapd AP; 8812CU at 5-1 and 8812BU at 8-1). Both ways: 5 passed, 0 failed, 0 inconclusive, live=1 on every arm, and the largest gap between reports was 74 ms. Full table in the description; the short version:

arm 8812CU station 8812BU station
A armed, to own 1626/1668, 99.9%, 0.05, rx 1625 859/909, 100.0%, 0.29, rx 859
B / C / D / E 0.0%, 12.00 0.0%, 12.00
F uplink to AP 2389/2439, 100.0%, 0.17 3183/3225, 100.0%, 0.30
G uplink to nobody 234/1404, 0.0%, 12.00 1656/2588, 0.0%, 12.00
H uplink unarmed (not scored) 2414/2464, 100.0%, 0.17 3194/3236, 100.0%, 0.33

ctest is 82/82 with -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON, and 82/82 under address+undefined with no reports. I replied to each of qodo's 7 threads (all fixed in this head) and resolved them.

Agreed on the 8822E: its flag stays false until a second unit runs the cell.

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed fc7eb72. Both items are closed the way I'd have closed them: _station_ready cleared under the station lock at the top of each Init/InitWrite next to retire(), committed after the configured ACK responder / AMPDU / replay tail, left false by a throwing bring-up, and station_args_ok refuses zero on either side. Keeping the snapshot when a retire-time clear does not verify is the right call for the reason the comment gives. ctest 80/80 here. Approving.

Hardware record on fc7eb72 (this rig, one run each way)

ch6, near field, MCS3, retry limit 12, MT7612U hostapd AP, SECS=20 (why below). Every adapter handed back.

arm 8812CU station 8812BU station
A armed, to own 2567/2609, 99.9%, 0.20, rx 2565 3526/3576, 100.0%, 0.02, rx 3526
B armed, to nobody 1362/2294, 0.0%, 12.00, rx 1375 350/1696, 0.0%, 12.00, rx 354
C DUT absent 1364/2296, 0.0%, 12.00 352/1696, 0.0%, 12.00
D unarmed 1358/2291, 0.0%, 12.00, rx 1760 348/1719, 0.0%, 12.00, rx 350
E armed then cleared 1361/2294, 0.0%, 12.00, rx 2114 349/1708, 0.0%, 12.00, rx 350
F uplink to AP 4984/5034, 100.0%, 0.03 4192/4234, 100.0%, 0.11
G uplink to nobody 488/2083, 0.0%, 12.00 2169/3101, 0.0%, 12.00
H uplink unarmed (not scored) 4986/5036, 100.0%, 0.02 4204/4246, 100.0%, 0.10

Both ways 5 passed, 0 failed, 0 inconclusive, live=1 on every arm. Same shape as the 6737727 run and as your table.

One harness note for the follow-ups issue, not blocking

The MIN_SUBMITTED floor is scaled from SECS, but the window includes the peer's bring-up. At the default SECS=10 the 8812CU-station cell came back INCONCLUSIVE here on every DOWN arm: the 8812BU peer's txdemo.init_write took 6.2 s and 8.7 s in two runs on this host (the 8812CU's takes 0.85 s), so it aired for 1.3-3.7 s and submitted 283-327 against the 500 floor, with live=1 and max_gap_ms under 35 the whole time. A healthy transmitter, scored as a stall. Suggest scaling the floor from the TX-active span (first tx.frame or txdemo.first_tx_submit to the final tx.stats t) rather than from SECS, or starting the window at first submit. Related: the AP sysfs preflight at the top of the UP half runs after the whole DOWN half; my first re-run spent 60 s on DOWN and then refused on an AP that had left the bus. A cheap check beside the AP_SYSFS non-empty test at the top would fail fast.

On the arm-after-Stop() question: I'd close it in the follow-ups rather than here, since it matches the pre-existing _brought_up behaviour and the destructor still clears.

@josephnef
josephnef merged commit dea68d4 into OpenIPC:master Oct 2, 2026
38 checks passed
josephnef pushed a commit that referenced this pull request Oct 3, 2026
## What changed

- **`tests/sta_client.cpp`** — a station client over `IRadio` +
`src/sta/`: scan, authenticate, associate, the WPA2-PSK four-way and
CCMP, and a TAP device for the host. It is the in-tree caller of
`IRadio::SetStationIdentity`:
- armed only when `AdapterCaps::station_mode_ok` is true; otherwise
refused at start-up with exit 2 (`DEVOURER_STA_ARM=0` runs unarmed);
- armed for the BSSID actually joined, after `StartRxLoop` and outside
the mutex the RX callback takes;
- `ClearStationIdentity` on the way out whenever an arm was attempted,
with the result printed (`restored (verified)` / `NOT VERIFIED`;
trivially true on MT7612U, whose arm writes nothing).
- Teardown: TAP reader stopped → RX loop stopped and joined → TAP fd
closed under the station mutex → final send (the leave's deauth) → clear
→ ledger. Bring-up, main-loop and receive-path exceptions are caught so
the run still leaves, clears and prints its ledger.
- The duplicate cache is reset per association, not per key: a Retry
copy of a rekey's message 3 is a duplicate, not a MIC failure.
- Unicast requests an ACK; `tx.retry_limit` defaults to 7 unless
`DEVOURER_TX_RETRY_LIMIT` is set, so `DEVOURER_TX_RETRY_LIMIT=0` is how
a run asks for the single-shot uplink the library warns about.
- The bring-up asks for RX with TX (`rx.enable_with_tx`). Jaguar3's
`InitWrite` keeps the RX path only when that is set, and enabling RX
after a TX-only bring-up is not reliable there. Without it, an 8822C
station could join nothing. The MT7612U and Jaguar2 backends don't read
the flag.
- Data plane: one `DupDetector` (one transmitter, the joined AP), a
non-Key EAPOL packet returned unconsumed by `on_decrypted_msdu` is
delivered to the host, a `ccmp_encrypted_len` overflow is refused, and
the FCS is trimmed by `RxAtrib.fcs_present`.
- **`tests/sta_client_selftest.inc`** + ctest **`sta_client_headless`**
(`StaClientSelftest`, Linux + OpenSSL, collected by the `selftests`
aggregate) — headless cells against a fixture that plays the
authenticator: scan selection and sweep, re-join policy, key selection
by key id, replay and duplicate windows, PTK/GTK rekeys, refusal of
plaintext/fragments/A-MSDU, the FCS trim, the ledger's identities.
- **`tests/mt7612u_sta_onair.sh`** — the MT7612U joins hostapd running
in a network namespace (so the ping crosses the air; the route is
asserted first). Cells `open`, `wpa2` (group + pairwise rekeys), `noarm`
(control) and `retry0` (the `tx.retry_limit == 0` warning). Exit 0 pass
/ 1 fail / 2 inconclusive / 3 interrupted. Uses
`tests/mt7612u_sta_lib.sh` for the private OUT, the run lock,
PID-recorded kills and the DUT hand-back.
- **`docs/station-client.md`**; `docs/station-core.md` and
`src/sta/CLAUDE.md` now name the client as the core's caller.

## Why

Nothing in-tree called `SetStationIdentity`. This harness exercises the
arm-then-measure path end to end — arm, associate, key, carry traffic,
clear — and is where the `tx.retry_limit == 0` warning gets
bench-checked. It also gives `DupDetector` and the MSDU<->Ethernet
helpers their first caller.

## What is measured

On-air (`tests/mt7612u_sta_onair.sh` on this head: MT7612U station,
hostapd on an RTL8812BU under the in-kernel rtw88 driver, ch6, near
field): **16 passed, 0 failed, 0 inconclusive**. Every adapter was
handed back, with no hostapd, monitor interface or namespace left.

| Cell | Result |
|---|---|
| `open` | associated; ping over the air 0% loss; ledger 22 plaintext, 0
encrypted received (19-23 across runs); armed for the joined BSSID on
attempt 1; clear ran |
| `wpa2` | four-way completed; the AP completed a group rekey and a
pairwise rekey; ping 0% loss before and after the rekeys; ledger 1
association, 3 rekeys answered, 2 PTK installs, MIC failures 0; armed;
clear ran; no `tx.retry_limit=0` warning (retry limit 7, the station
default) |
| `noarm` | no arm and no clear ran (scored); the link is reported, not
scored: four-way completed, ping 0% loss |
| `retry0` | the library's arm-time `tx.retry_limit=0` warning appeared
(scored); clear ran; the link is reported: four-way completed, ping 0%
loss with single-shot unicast |

The first cut of this commit ran the same 16/16 on the same rig before
the review fixes, and again after them, after the rebase onto #463,
after the qodo fixes and after the maintainer review (this head); the
`wpa2` cell also passed alone.

Interrupt check on this head: SIGINT to the harness one second after
`sta_client` started, i.e. inside its bring-up. The harness was gone 7 s
later, every adapter was back on its kernel driver, and no `sta_client`,
hostapd or netns was left. The maintainer's independent run on b33421b
(MT7612U and a T3U 8812BU under rtw88) was also 16/16.

Against it:
- One run per cell, one rig, one channel, near field, and one AP driver
(rtw88). The out-of-tree vendor `rtl88x2cu` driver cannot be the AP,
because its phy cannot change network namespace (`iw phy set netns`
returns -95). The cell refuses such an AP before taking the DUT (exit
2), and that was checked on this rig.
- `noarm` passing with a working link is what the MT7612U predicts,
since its arm writes no register. It does not show what the arm buys on
this chip.
- `retry0`'s link held at near field. A single-shot uplink is fragile by
construction, and the cell scores the warning, not the link.
- Sending the leave's deauth after the RX loop has stopped is checked on
the MT7612U only. By reading, it holds on Jaguar2/3 too: `StopRxLoop`
only sets a stop flag, the loop's exit stops the DIG/phydm workers and
nothing on the TX side, and `send_packet` there is a synchronous bounded
bulk-OUT.

## What it can't do

- The on-air cell is MT7612U-only (`sta_dut_take`) until a generic DUT
take/hand-back exists. The Realtek arm landed in #463 (8822C / 8822B
report `station_mode_ok` true), so the client arms those dies when
`DEVOURER_VID` / `DEVOURER_PID` select one, but no on-air cell here
covers it. A die that reports `station_mode_ok` false is refused cleanly
(exit 2); `DEVOURER_STA_ARM=0` runs it unarmed.
- The `noarm` control cannot separate "the arm made it work" on MT7612U:
there the arm writes no register (it checks `MT_MAC_ADDR` and
`MT_AUTO_RSP_EN`), so it scores only that no arm/clear ran and reports
the link.
- Software CCMP; WPA2-PSK/CCMP or open; no PMF, no fragment reassembly,
no A-MSDU, no roaming or background scan while associated. A pairwise
rekey can cost one received frame (802.11-2016 12.7.6.5).

## Follow-ups

- Realtek DUTs in the on-air cell (needs a generic DUT take/hand-back).
- A reconnect cell (AP stopped and restarted) — the policy is pinned
headlessly only.
- Software-CCMP throughput under load, and a long soak with the default
retry settings.

## Review record

Two Flash reviews and an Opus check on the first cut; all fixed in this
commit:
- shutdown order (TAP fd closed outside the mutex while the RX thread
could still write through it; the ledger identities not exact);
- no exception handling (an InitWrite throw aborted; a loop or RX-path
throw skipped leave, clear and ledger);
- the duplicate cache was reset at a PTK rekey (cell
`test_the_dup_cache_spans_a_rekey_not_an_association`, which fails both
with the reset at the rekey and with no reset);
- on-air cell: a station that never printed `sta_client up:` is
INCONCLUSIVE (rig/bring-up), not FAIL; a hung station is escalated to
SIGKILL and the DUT is not re-enumerated under it; the clear is scored
as having run (its MT7612U result is trivially true); the AP phy must
list `set_wiphy_netns`; traps installed before the DUT is taken; the AP
interface's UP state restored;
- the queue-identity cell no longer fakes `g_sent`; the ledger is
described as printed once `sta_client up:` has printed;
`sta_client_headless` named in the OpenSSL-absent configure messages.

After the rebase onto #463, a check against the merged Realtek arm and
one more Flash review of the delta:
- the bring-up now asks for RX with TX (`rx.enable_with_tx`), which a
Jaguar3 station needs;
- the TAP reader thread and the teardown `StopRxLoop()` are guarded like
the rest, so a throw there can no longer skip leave, clear and ledger;
- every guard also catches non-`std::exception` throws.
- qodo's pass on #464: non-blocking TAP, guarded thread starts, atomic
stop flag.

Maintainer review (josephnef, CHANGES_REQUESTED at b33421b), all fixed:
- a caught exception exited 0 and read as "raise SECS": `g_fault`, exit
3, `fault=1` in the ledger; the on-air cell scores exit 3 as FAIL with
the cause named;
- SIGINT/SIGTERM handlers now installed at the top of `main` (undoing an
inherited SIG_IGN); the cell's cleanup escalates INT -> KILL, and
`sta_pid_kill` (shared lib) polls before it reaps instead of a bare
blocking `wait`;
- the station retry-limit default is decided from the library's strict
parse of `DEVOURER_TX_RETRY_LIMIT`, not from the variable being set;
- a fatal TAP poll/read is a counted fault, not a silent end of the
reader;
- no backend reports a frame's RX channel, so a beacon without a DS
Parameter Set is not folded in during a retune or for 50 ms after it;
- `test_forged_mic_is_refused` now feeds the real AP's frame at the
forged PN and checks delivery and the replay window;
- the script header says `FW_DIR` must hold decompressed blobs.
- a pattern hunt for more of the same classes: RNG failure and a
not-verified clear are faults; teardown leave guarded; strict parse of
the duration and channel; a check that could not fail removed.

## Verification

- `cmake -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON`:
build clean, `ctest` 83/83 (with #463's `station_arm`).
- Same under `-DDEVOURER_SANITIZE=address+undefined`: `ctest` 83/83.
- CI subsets: MT7612U-only (`ctest` 72/72), Jaguar3-only (`ctest` 72/72,
`sta_client_headless` and `station_arm` pass).
- `bash -n` and `shellcheck -x` clean on `tests/mt7612u_sta_onair.sh`
and `tests/mt7612u_sta_lib.sh`.
- On air: see the table above. Command: `sudo DUT_SYSFS=<mt7612u>
AP_SYSFS=<rtw88 adapter> CH=6 tests/mt7612u_sta_onair.sh` (one cell:
append `open`, `wpa2`, `noarm` or `retry0`).

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

https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
josephnef pushed a commit that referenced this pull request Oct 3, 2026
… of #465 (#466)

Follow-ups and fixes collected from merged PRs: the maintainer's
non-blocking review notes, a probable cause for #461's one-entry loss,
part of the #465 harness defect sweep, and two txdemo teardown fixes we
found ourselves. Fifteen commits, one per item or per harness, so any
can be dropped or reordered on review, plus three follow-ups from the
qodo review of this PR.

Refs #461, Refs #465 (partial).

## What changed

### Maintainer review notes

| Commit | Change |
|---|---|
| `0fee106` | #452: `txs_parse_long` carried two comment blocks back to
back. The first one documents `txs_retry_limit_env`, and it now sits
there. |
| `4d862ff` | #453: TxBeaconGuard retries StopBeacon when an armed
beacon's disable is refused. After a successful StartBeacon, the
Jaguar2/3 StopBeacon returns false only for a refused disable, and
`_bcn_hw_touched` makes a retry re-run it. A clean false after a refused
StartBeacon still ends the loop. |
| `294a671` | #453: the dated, attributed bench paragraph in
`docs/jaguar3-tx-ring.md` becomes plain measurement.
`IRtlRadio::GetTxDmaStatus` now says bit 13 (`BIT_PAYLOAD_OVF_8822C`)
**can** latch at max duty on USB2, not that it does. |
| `ddd8467` | The older bring-up gates in
`src/mt7612u/tools/bringup.cpp` now call `mt_mac_stop()` when
`mt_mac_start()` fails. `mt_mac_start` sets `ENABLE_TX` before its WPDMA
poll, so a failed start could leave TX enabled. Each gate keeps its own
teardown order (`rx_teardown()` first where a ring was draining EP 4). |
| `2cc5330` | #463 review: Jaguar1/2/3 `Stop()` clears `_station_ready`
in the block that clears the arm, under the station lock. A
`SetStationIdentity` after `Stop()` is refused until the next bring-up.
|
| `907a5b5` | #463 review: `tests/realtek_station_onair.sh`'s submission
floor is scaled to the span the arm actually aired, not to SECS, which
also holds the peer's bring-up. The span runs from the first submit to
the final `tx.stats` (`fcdec63`, below). |
| `da8cd6d` | #463 review: the same script runs its whole AP guard in
the preflight, before the DOWN half spends its minute, and again at the
start of UP. |

### #461: the txs gate's lost status entry

| Commit | Change |
|---|---|
| `2e7a3de` | `gate_txs` claims an arm's first status entry back when a
stale `MT_TX_STAT_FIFO_EXT` word mislabelled it with the previous arm's
pktid. Details under Why. |

### #465 (partial): the harness defect sweep

| Commit | Harness | Change |
|---|---|---|
| `53477e0` | `mt7612u_ap_onair.sh` | An interrupt stops the run instead
of carrying on into the next cell. Before re-enumerating `AP_SYSFS`,
cleanup waits for its children to exit (10 s bound, zombie-aware via the
lib's `sta_pid_alive`), and skips the re-enumeration if it had to KILL
one. The stop cell FAILs when PHASE 2 never appeared, rather than
scoring "beacon gone". The ping-loss figure is quoted whole ("66.6667%",
not "6667%"). |
| `05bdaf7` | `mt7612u_sta_autoack.sh` | DUT hand-back only after its
process is confirmed gone, with a CLEANED guard so the EXIT pass after
an INT cannot undo that refusal. |
| `8a2ab14` | `mt7612u_sta_uplink.sh` | The same hand-back rule. The
DUT's `mt7612uprobe txs` runs under `timeout -s INT -k 10`, bounded by
the gate's own worst case. |
| `bf9c0dd` | `mt7612u_sta_identity.sh` | `AP_REENUM` is set before
hostapd starts, and hostapd is killed unconditionally. The BSSID gate is
bounded. The monitor vif is probed in the preflight. Exit codes are
0/1/2/3, with rig refusals as 2 and the INT/TERM trap as 3. |
| `d473027` | `mt7612u_sta_onair.sh` | The hostapd restart race. Before
each launch, `ap_up` waits (10 s bound, inside the netns) for the AP
netdev, forces it back to `type managed`, and brings it up. |

Not in this PR, from #465: the generic Realtek DUT take, the reconnect
cell and the CCMP soak. Those come in a separate PR.

### Our own finding: txdemo thread joins

| Commit | Change |
|---|---|
| `64ebce3` | `examples/tx/main.cpp` joins the optional IN drainers
(`DEVOURER_DRAIN_BULK_IN`, `DEVOURER_POLL_INTR_IN`) on every exit. |
| `6b31d5f` | It also joins the RX and USB event threads on every exit.
|

In both cases an early return used to destroy a still-joinable
`std::thread`, which calls `std::terminate`. Small scope guards now join
the threads in the normal teardown's order, before `session.close()`.
The legacy fork child is covered by `18f0a20`, below.

### qodo review of this PR

| Commit | Change |
|---|---|
| `fcdec63` | `realtek_station_onair.sh`: the aired span started at the
first `tx.report`, so a transmitter that started, stalled and then burst
50 reports in its last second passed both floors. txdemo's `tx.frame`
now carries `t` (the `tx.report` timebase; `docs/logging.md`). The span
runs from the first submit, and liveness also refuses a first report
more than `MAX_GAP_MS` after it (`lead_ms`). |
| `92dfa1d` | `mt7612u_ap_onair.sh`: if a between-cell cleanup could not
reset the AP (a process outlived TERM), the remaining cells are recorded
as NOT RUN and the run exits 2, instead of being scored on an unreset
adapter that may still be beaconing. |
| `18f0a20` | txdemo: the `DEVOURER_TX_WITH_RX` fork child leaves
through `std::_Exit` after flushing stdio, with `Init` in a try. No
destructor runs in it, so its copies of the IN-drainer threads no longer
terminate it. |

## Why

- **#461 (the txs entry loss).** The FIFO read is two USB control
transfers: EXT first, then the main word, which pops the entry.
- If an entry is filed between the two reads, the pop is paired with the
previous entry's EXT word.
- Inside an arm that is harmless, because every entry carries the same
pktid.
- On an arm's first entry it carries the previous arm's pktid. That
entry is counted late, the arm stays one short, and every per-frame wait
then times out. These are the ~6 fps N-1/N rows.
- `docs/mt7612u-tx-retry.md`'s own table rules out the alternative,
"status posted only on the next TX". At limit 0, receiver ON, arm f lags
with its late entry in its own row, after arm e settled and owed
nothing. Arm g after it shows no late entry.
- **#465.** Each item was a way for a harness to score a dead or wedged
device as a result, hang without bound, or re-enumerate an adapter under
a process still in its de-init.
- **The txdemo threads.** A joinable `std::thread` destructor terminates
the process, so an otherwise clean early exit aborted.

## Measured

- **#461, on the author's unit:** `gate_txs` with the stale-EXT claim
settled 16/16 arms at retry limits 5 and 0 (recorded on #461).
- The claim's current form is keyed to the last arm that actually sent a
frame, so an arm whose every submit failed is skipped. That refinement
has not been run on hardware.
- **The uplink harness** (`tests/mt7612u_sta_uplink.sh`) is INCONCLUSIVE
both on this branch (48/53 status entries) and on master (57/60). It
predates the branch.
- That loss is several entries per arm. It is **not** the race above,
which accounts for at most one entry per arm, and it is still being
investigated.
- **Submission floor (`907a5b5`), checked on synthetic JSONL through the
script's own `summarize`/`usable`:**

| Fixture | Old floor | New floor |
|---|---|---|
| Slow bring-up, 327 frames in 2.0 s | refused at 500 | usable, floor
101 |
| Start, stall, 60 reports in the last second | refused at 500 |
refused: lead_ms 8800 (live=0), and 60 < floor 490 |
| Rate stall, 110 frames over 10 s | — | refused, floor 500 |
| Hard stall | — | refused, live=0 |
| No `tx.frame` with `t` (an older txdemo) | — | refused, live=0 |

## What it can't do

- The stale-EXT claim recovers at most one entry per arm, and only when
the previous sending arm settled. A deficit larger than one entry is not
this race.
- **The INT/TERM exit code is not uniform across the harnesses:**
- realtek_station_onair, mt7612u_sta_onair and now mt7612u_sta_identity
exit 3, as their headers document;
  - autoack, uplink and ap_onair still exit 130.
  - Unifying them is left for later.

## Follow-ups

- Find the uplink harness's multi-entry status loss.
- Hardware-run the "last arm that sent" form of the txs claim.
- Unify the interrupt exit code across the station harnesses.
- From #465: the generic Realtek DUT take, the reconnect cell and the
CCMP soak (separate PR).

## Verification

- `ctest`: 83/83, both plain and under
`-DDEVOURER_SANITIZE=address+undefined`, built with
`-DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON`. The two
reference-submodule tests are skipped without `reference/`.
- `make -C src/mt7612u check` passes.
- `bash -n` (and `sh -n` for the `/bin/sh` scripts) is clean on every
touched script. `shellcheck -x` is clean on all of them except
`tests/mt7612u_ap_onair.sh`, whose remaining SC2046/SC2015/SC2012 notes
predate this branch.
- **Hardware, on this head (6b31d5f):** MT7612U at 1-1, RTL8812CU at
5-1, RTL8812BU at 8-1 (rtw88), ch6, near field. Every adapter was handed
back after each harness, and no hostapd, netns or monitor interface was
left.

| Harness | Result |
|---|---|
| realtek_station_onair | 5 passed, 0 failed, 0 inconclusive, both ways
(8812CU and 8812BU as the station) |
| mt7612u_sta_onair | 16/16 |
| mt7612u_sta_identity | rc 0 (STAID 12/12; STAACK reports its own A/B
as non-discriminating on this rig, and only arm C is scored, as before)
|
| mt7612u_ap_onair | 14/14 |
| mt7612u_sta_autoack | 3/3 |

- **The three qodo follow-ups (`fcdec63`, `92dfa1d`, `18f0a20`), re-run
on 18f0a20:** `realtek_station_onair` 5 passed, 0 failed, 0
inconclusive, both ways, under the floor that now starts at the first
submit. `mt7612u_ap_onair` 14/14. Every adapter was handed back.
- The same harnesses also ran on the pre-squash head, with the same
results. `mt7612u_ap_onair` showed one flaky open-cell ping run there,
which passed on three repeats. `mt7612u_sta_uplink` was INCONCLUSIVE
there and on master alike. That is the multi-entry loss under #461,
which this PR doesn't address.

- **Review record:** qodo-gate flagged three threads on 6b31d5f. All
three held, and are fixed in `fcdec63`, `92dfa1d` and `18f0a20`.

## Blast radius

- **Library:** the three Jaguar `Stop()`s only. Each clears
`_station_ready`, and only the `SetStationIdentity` gate reads it. Every
bring-up already clears it on entry and sets it again at the end.
- **Everything else** is `src/mt7612u/tools/` (the bring-up tool),
`examples/tx/` (txdemo), `tests/` and `docs/`.
- **One event-schema addition:** txdemo's `tx.frame` gains a trailing
`t`. Every in-tree consumer either matches on `"n"`/`"rc"` or parses the
JSON, so none of them changes.
- **`src/IRtlRadio.h` and `src/StationArm.h`:** comment-only.

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

https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3

---------

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.

2 participants