Skip to content

tests: station client over IRadio + src/sta, and its on-air cell - #464

Merged
josephnef merged 1 commit into
OpenIPC:masterfrom
snokvist:pr/sta-client
Oct 3, 2026
Merged

josephnef merged 1 commit into
OpenIPC:masterfrom
snokvist:pr/sta-client

Conversation

@snokvist

@snokvist snokvist commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

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 jaguar1/2/3: the Realtek station arm for IRadio::SetStationIdentity #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 tests: station client over IRadio + src/sta, and its on-air cell #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 jaguar1/2/3: the Realtek station arm for IRadio::SetStationIdentity #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.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3

@snokvist

snokvist commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef PR6 of the station series, #461's third library item: the src/sta caller. It is a station client that joins a real AP through IRadio over the src/sta core, arms SetStationIdentity for the BSSID it joined, and clears it on every exit.

It is one commit on dea68d4, so the Realtek arm from #463 is already underneath it. Everything new sits under tests/ and docs/, plus the CMake wiring and a note in src/sta/CLAUDE.md; no library code changes.

On air (tests/mt7612u_sta_onair.sh): MT7612U station, hostapd on an RTL8812BU under rtw88, ch6. 16 passed, 0 failed, 0 inconclusive. The cells are:

  • open;
  • wpa2, with a group and a pairwise rekey;
  • noarm, a control;
  • retry0, which bench-checks the tx.retry_limit == 0 warning: it appears at arm time when the limit is 0, and not at the client's default of 7.

Two things to know before running it:

  • The AP adapter has to be on an in-kernel driver, because hostapd runs in a network namespace so the ping crosses the air. An out-of-tree rtl88x2cu phy cannot change namespace, and the cell refuses it before taking the DUT.
  • On the MT7612U the arm writes no register. So noarm scores only that no arm or clear ran, and the clear is scored as having run; its "verified" result is trivially true on this chip. That is stated in the cell output and in the description.

On the Realtek side: sta_client arms an 8822C or 8822B through the #463 arm. The on-air cell is MT7612U-only until there is a generic DUT take and hand-back; that is listed as a follow-up. Checking against #463 found one real issue, now fixed: the client did not ask for RX with TX, and a Jaguar3 InitWrite drops the RX path without that request.

Review before opening: two Flash reviews and an Opus check on the first cut, then one Flash review of the delta after the rebase. The findings and fixes are in the description's review record.

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

Copy link
Copy Markdown

PR Summary by Qodo

Add an IRadio station client with headless and on-air tests

🧪 Tests ✨ Enhancement 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add an in-tree station client that joins open or WPA2 networks and carries host traffic through
 TAP.
• Test scanning, rekeys, replay protection, and teardown headlessly and against a hostapd AP.
• Document station identity arming, operational limits, and how to run the client.
Diagram

graph TD
  Host["Host TAP"] -->|Ethernet frames| Client["Station client"] -->|radio operations| Radio["IRadio backends"] -->|802.11 frames| AP["hostapd AP"]
  Client -->|protocol calls| Core["Station core"]
  Fixture["Headless fixture"] -->|drives receive path| Client
  OnAir["On-air cell"] -->|runs client| Client
  OnAir -->|configures AP| AP
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Extract a reusable station coordinator
  • ➕ Separates scan, reconnect, and data-plane policy from the executable.
  • ➕ Allows tests to link against a conventional module instead of including harness internals.
  • ➖ Adds an abstraction before another caller needs it.
  • ➖ Risks blurring the existing device-free station core's boundary.

Recommendation: Keep the integration in the test client for this bench-focused PR: it exercises the existing pure core without adding radio or TAP dependencies to it. Extract a coordinator if another application needs the same policy.

Files changed (8) +3616 / -12

Enhancement (1) +1177 / -0
sta_client.cppIntegrate the station core with IRadio and a TAP data plane +1177/-0

Integrate the station core with IRadio and a TAP data plane

• Adds scanning, association supervision, WPA2-PSK and CCMP traffic handling, replay and duplicate checks, and a diagnostic ledger. Gates station identity arming on adapter capability, enables RX during bring-up, and orders teardown to stop readers, send the leave frame, clear identity, and report results.

tests/sta_client.cpp

Documentation (4) +104 / -9
station-client.mdDocument the station client and its test cells +91/-0

Document the station client and its test cells

• Explains identity arming, transmit defaults, invocation, headless and on-air checks, and supported network features. Clarifies MT7612U-only on-air coverage and the meaning of the clear result.

docs/station-client.md

station-core.mdIdentify the station core's new in-tree caller +5/-3

Identify the station core's new in-tree caller

• Replaces the statement that duplicate detection and Ethernet conversion have no caller with a pointer to the station client. Retains the boundary between the device-free core and its data-plane integrator.

docs/station-core.md

CLAUDE.mdMap station helpers to their client and tests +6/-4

Map station helpers to their client and tests

• Names sta_client as the consumer of the duplicate detector and Ethernet helpers. Adds the new headless cell to the station core's test map.

src/sta/CLAUDE.md

mt7612u_sta_lib.shList the on-air station cell as a shared-library user +2/-2

List the on-air station cell as a shared-library user

• Extends the shared station harness library's introductory comment to include the new on-air script. Its helper behavior is unchanged.

tests/mt7612u_sta_lib.sh

Other (3) +2335 / -3
CMakeLists.txtBuild and register the Linux station client self-test +25/-3

Build and register the Linux station client self-test

• Builds the OpenSSL-linked sta_client executable on Linux and registers its headless mode with CTest. OpenSSL-absent diagnostics now name the missing test.

CMakeLists.txt

mt7612u_sta_onair.shExercise station association and traffic against hostapd +515/-0

Exercise station association and traffic against hostapd

• Adds open, WPA2 with rekeys, unarmed-control, and zero-retry cells using a hostapd AP in a network namespace. Checks that traffic is routed through TAP, scores AP and station evidence, and restores the rig through guarded cleanup.

tests/mt7612u_sta_onair.sh

sta_client_selftest.incDrive the client with a headless authenticator fixture +1795/-0

Drive the client with a headless authenticator fixture

• Feeds constructed authenticator frames through the client's receive path and checks scan selection, reconnection, traffic conversion, key selection, rekeys, replay windows, malformed-frame refusal, and ledger accounting. Runs through sta_client --self-test without a radio or root access.

tests/sta_client_selftest.inc

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

qodo-free-for-open-source-projects Bot commented Oct 2, 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


Remediation recommended

1. A stalled TAP write can block shutdown ✓ Resolved
Description
tap_up() writes to a blocking TAP descriptor from the RX callback while holding g_mu. If that
write stalls, teardown waits for the RX loop to stop before closing the descriptor, so the station
cannot finish its leave, identity clear, or ledger.
Code

tests/sta_client.cpp[R220-225]

+  static const auto t0 = std::chrono::steady_clock::now();
+  return (uint32_t)std::chrono::duration_cast<std::chrono::milliseconds>(
+             std::chrono::steady_clock::now() - t0)
+      .count();
+}
+
Evidence
The TAP is opened without O_NONBLOCK, rx_frame() holds g_mu while reaching tap_up(), and
shutdown stops and joins RX before closing the TAP descriptor.

tests/sta_client.cpp[275-278]
tests/sta_client.cpp[214-225]
tests/sta_client.cpp[634-640]
tests/sta_client.cpp[1100-1117]

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 blocking TAP write runs inside the RX callback, while teardown waits for that callback to finish before closing the TAP descriptor.
## Fix Focus Areas
- tests/sta_client.cpp[220-225]
- tests/sta_client.cpp[634-640]
- tests/sta_client.cpp[1100-1117]
## Recommended Fix
Make TAP delivery nonblocking and handle backpressure by dropping or queueing frames without blocking the RX callback. Ensure teardown can stop the RX loop independently of host-network delivery.

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


2. A TAP startup failure skips cleanup ✓ Resolved
Description
tap_rd = std::thread(...) can throw after rx has already started, with no enclosing cleanup for
that construction failure. Unwinding then destroys the still-joinable rx thread and terminates the
process before the station identity is cleared or the ledger is printed.
Code

tests/sta_client.cpp[R962-966]

+                           "opened - refusing to run without it\n", t);
+      return 1;
+    }
+  }
+
Evidence
The RX thread is created first; TAP-thread construction is outside the lambda's exception handlers,
and the only RX join is in the later normal teardown path.

tests/sta_client.cpp[951-965]
tests/sta_client.cpp[1100-1113]
tests/sta_client.cpp[1130-1165]

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

## Issue description
TAP-reader thread construction can fail while the RX thread is joinable, causing termination during stack unwinding instead of station cleanup.
## Fix Focus Areas
- tests/sta_client.cpp[951-965]
- tests/sta_client.cpp[1100-1165]
## Recommended Fix
Guard TAP-thread construction with a cleanup path that stops and joins RX, closes the TAP descriptor, and performs any attempted station-identity clear before returning an error.

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


3. Receive errors may not stop the station ✓ Resolved
Description
g_stop is a volatile sig_atomic_t, but the RX and TAP threads write it while the main thread
reads it without atomic synchronization. When either worker reports an exception, the resulting data
race means the main loop cannot reliably observe the request to leave and clear the station identity
promptly.
Code

tests/sta_client.cpp[R214-215]

+volatile std::sig_atomic_t g_stop = 0;
+extern "C" void on_signal(int) { g_stop = 1; }
Evidence
The flag is declared volatile rather than atomic; receive and TAP exception handlers write it from
worker threads, while the main loop reads it to decide whether to begin teardown.

tests/sta_client.cpp[210-215]
tests/sta_client.cpp[683-692]
tests/sta_client.cpp[969-1006]
tests/sta_client.cpp[1034-1034]

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

## Issue description
Worker threads and the main loop access the stop flag concurrently, but its signal-safe type does not make those cross-thread accesses atomic.
## Fix Focus Areas
- tests/sta_client.cpp[210-215]
- tests/sta_client.cpp[683-692]
- tests/sta_client.cpp[969-1006]
- tests/sta_client.cpp[1034-1034]
## Recommended Fix
Use synchronized cross-thread stop state, such as a lock-free atomic flag with signal-safe handling, and have both worker exception paths and the signal handler request the same orderly teardown.

ⓘ 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 copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread tests/sta_client.cpp
Comment thread tests/sta_client.cpp
Comment thread tests/sta_client.cpp Outdated

@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 b33421b hunk by hunk against src/IRadio.h, src/sta/*, src/mt7612u/Mt7612uRadio.cpp and the env/usb helpers, and reproduced the on-air cell independently on this rig.

Hardware

tests/mt7612u_sta_onair.sh (MT7612U at 10-1.3, hostapd on a T3U 8812BU under rtw88 at 10-2, ch6, near field): 16 passed, 0 failed, 0 inconclusive, every adapter handed back (rtw88 unloaded, MT7612U back on mt76x2u, no netns/TAP/hostapd left).

cell result
open associated, ping 0% loss, ledger plaintext 21 / encrypted 0, armed attempt 1/5, clear ran
wpa2 four-way, group + pairwise rekey, ping 0% before/after, associations=1 answered=3 PTK=2 MIC=0, armed, clear ran, no retry_limit=0 warning
noarm no arm / no clear ran; link 0% loss
retry0 arm-time warning fired before the armed line; clear ran; link 0% loss

Rig note: a first run was 4× INCONCLUSIVE because this host ships mt7662*.bin.zst; the harness scored it correctly as rig/bring-up and the library's error message says what to do. Worth one line in the script header (FW_DIR must hold decompressed blobs).

Build: -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON, ctest 83/83.

Blocking (the harness misreports its own outcome)

  1. A caught throw exits 0 and is scored INCONCLUSIVE "raise SECS". Every guard (RX loop thread, on_rx, main loop, TAP reader start) sets g_stop = 1 and main then returns 0. The cell's rule is "after up:, status 0 = ran out of SECS → INCONCLUSIVE; anything else = FAIL", so a receive-path or RX-loop exception is reported as a timeout and the real cause is lost. Keep a g_fault flag beside g_stop and return nonzero from it (or have the cell grep the log for threw).

  2. SIGINT is installed after bring-up, and the station is a background job of a non-interactive script. bash starts async jobs with SIGINT ignored; the handler at sta_client.cpp:1062 only takes over after InitWrite, the TAP open and both thread starts. cleanup() ends the station with sta_pid_kill sta INT, and sta_pid_kill does a bare wait before its 10 s poll. Ctrl-C the script while the station is still in firmware load / InitWrite and the INT is ignored: the trap path blocks in wait (or, past it, gives up, skips sta_dut_handback and still releases the lock). Install the handlers before InitWrite (the handler is one atomic store and nothing reads g_stop before the loop anyway) and/or let cleanup escalate INT → KILL the way sta_stop does.

Non-blocking

  1. limit_from_env is getenv() != nullptr, but env_config.cpp applies the value only via env_long_strict. DEVOURER_TX_RETRY_LIMIT= (empty) or non-numeric leaves the library default 0, skips the station default 7, and the banner attributes the 0 to the env var. Decide from the parsed value.
  2. The TAP reader returns silently on a fatal read() (got <= 0), no log, no g_stop, no counter: ip link del dvsta0 mid-run leaves a station reporting a healthy link while every host frame vanishes.
  3. Beacons without a DS Parameter Set are tagged with g_tuned, which the loop stores only after SetMonitorChannel returns; a frame still queued from the previous channel during a DEVOURER_STA_SCAN_CHANNELS sweep lands with the new channel and supervise() joins on it. Latent on hostapd (it always sends DS Params); RxAtrib's channel would close it.
  4. test_forged_mic_is_refused: good is built and (void)ed, so the "the real AP's next frame still gets through" half of the property is never fed or asserted (and ap.tx_pn was consumed building it).

Everything else held: lock ordering (g_mu → g_q_mu, device calls outside g_mu), arm after StartRxLoop and outside the mutex, clear on every arm path, replay-window restarts keyed on install generations across both rekeys, the FCS trim (Mt7612uRadio.cpp clears fcs_present), bounds on every header read before the CCMP key-id read, the teardown order, and the cell's ledger regexes against report()'s format strings.

tests/sta_client.cpp is the in-tree caller of the station core and of
IRadio::SetStationIdentity: scan, authenticate, associate, the WPA2-PSK
four-way and CCMP over src/sta/, a TAP data plane for the host. It arms
the station identity only when AdapterCaps::station_mode_ok is true
(otherwise it refuses with exit 2; DEVOURER_STA_ARM=0 runs unarmed), arms
after StartRxLoop and outside the mutex the RX callback takes, and clears
on the way out whenever an arm was attempted, printing whether the clear
verified. Unicast requests an ACK and the hardware 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 data plane uses the core's DupDetector (one transmitter: the joined
AP), delivers a non-Key EAPOL packet that on_decrypted_msdu returns
unconsumed, refuses a ccmp_encrypted_len overflow, and trims the FCS by
RxAtrib.fcs_present.

ctest sta_client_headless (StaClientSelftest, Linux + OpenSSL) runs the
headless cells in tests/sta_client_selftest.inc against a fixture that
plays the authenticator: scan and sweep, re-join policy, key selection by
key id, replay and duplicate windows, PTK/GTK rekeys, refusals and the
ledger's identities. No device.

tests/mt7612u_sta_onair.sh associates the MT7612U against hostapd in a
network namespace: cells open, wpa2 (with group and pairwise rekeys),
noarm (DEVOURER_STA_ARM=0 control) and retry0 (the tx.retry_limit=0
arm-time warning). Exit 0 pass, 1 fail, 2 inconclusive, 3 interrupted.

docs/station-client.md describes the client; docs/station-core.md and
src/sta/CLAUDE.md now name it as the core's caller.

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

snokvist commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

@josephnef thanks, and for the independent run. All six are fixed in 5acbbb3 (still one commit on dea68d4), plus the FW_DIR line.

1. A caught throw no longer exits 0.

  • Every catch goes through one fault() helper. It logs FAULT: <where> threw: <why> and sets an atomic g_fault beside g_stop.
  • main still leaves, clears and prints the ledger, then exits 3. The ledger's first line starts fault=.
  • In the cell, exit 3 is a FAIL in every cell, with the cause taken from the log. Status 0 after up: reads "out of SECS" only when there was no fault.
  • Headless check: test_a_fault_exits_nonzero.

2. Ctrl-C during bring-up.

  • sta_client installs its SIGINT/SIGTERM handlers at the top of main's real path, before InitWrite. That also replaces the SIG_IGN a background job inherits.
  • The cell's cleanup() ends the station through sta_stop, which escalates INT → KILL and never re-enumerates the DUT while the station is alive.
  • The bare wait was in sta_pid_kill itself (tests/mt7612u_sta_lib.sh), so the fix is there: it now polls a zombie-aware liveness check for 10 s and reaps only after the process has exited. It returns 1 without blocking when the process is still alive. That is the contract realtek_station_onair.sh and the other mt7612u_sta_* callers already read ("1 = still running, don't hand back"); shellcheck is clean on all of them.
  • Checked on air: SIGINT to the harness one second after sta_client started, i.e. inside its bring-up. The harness was gone 7 s later, the MT7612U was back on mt76x2u and the 8812BU on rtw88, and no sta_client, hostapd or netns was left.
  • Caveat: the device open can wait up to 15 s for re-enumeration, and the bring-up itself doesn't poll g_stop, so a stop lands once those return. The code and doc say so.

3. DEVOURER_TX_RETRY_LIMIT is decided from the parsed value.

  • The client uses the library's own strict parse, exposed as devourer_env_long_strict() from examples/common/env_config.{h,cpp}. It was file-private; this is the one change outside tests/ and docs/.
  • An empty or non-numeric value now gets the station default of 7. Note that an invalid value is warned about twice, once by the library and once by the client.
  • Headless check: unset, "", "5x", "0" and "12".

4. A fatal TAP poll or read is a fault. POLLERR/HUP/NVAL, a read error or EOF is logged, counted (read errors= on the ledger's TAP line), and raises the fault, so ip link del dvsta0 mid-run ends the run as a FAIL instead of leaving a healthy-looking station.

5. Beacons during a retune.

  • No backend reports a frame's RX channel: rx_pkt_attrib has no channel field, and MT7612U, Jaguar2 and Jaguar3 fill none. So RxAtrib couldn't close this.
  • Instead, the tuned channel reads as unknown while SetMonitorChannel runs and for 50 ms after it returns. In that window a beacon with a DS Parameter Set is still filed under its own channel, and one without is dropped rather than tagged with the new channel.
  • The 50 ms covers frames still in flight without starving a 250 ms dwell.
  • Headless check: test_a_beacon_in_the_retune_window.

6. test_forged_mic_is_refused. The real AP's frame is built at the PN the forgery claimed and fed after it. The cell checks that it is delivered, that its PN is the one admitted, and that a replay of it is refused.

FW_DIR. The script header and the doc say it must hold the decompressed blobs.

Beyond your list. I also ran a sweep for more instances of the same defect classes, which found these, all fixed in the same commit:

  • Faults that exited 0: an RNG failure while building the SNonce, and a ClearStationIdentity that does not verify. Both now go through the same fault path and exit 3.
  • The leave/drain step at teardown was unguarded while both threads were still joinable. It is now guarded like the rest.
  • Lenient parsing: the duration argument, DEVOURER_CHANNEL, DEVOURER_STA_SCAN_DWELL_MS and DEVOURER_STA_BACKOFF_MS went through atoi/strtoul, so garbage became a zero-length run, channel 0, and so on. They are now parsed strictly with bounds and refused with exit 2; there is a headless cell.
  • A selftest check that could not fail ("nothing here touched the send counters", since send_batch never runs headless) is removed.

The same sweep over the already-merged MT7612U harnesses turned up some defects of the same kinds there. These are outside this PR; I'll bring them as a separate cleanup PR:

  • mt7612u_ap_onair.sh's INT trap not exiting;
  • hostapd left running when mt7612u_sta_identity.sh is interrupted during AP start;
  • hand-back while the process is still alive;
  • unbounded waits.

Re-run on 5acbbb3 (MT7612U station, hostapd on an 8812BU under rtw88, ch6): 16 passed, 0 failed, 0 inconclusive, and every adapter was handed back. The 8812CU on rtl88x2cu is still refused as AP before the DUT is taken.

ctest is 83/83 with -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON, and 83/83 under address+undefined. The MT7612U-only and Jaguar3-only subsets are 72/72 each.

@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 5acbbb3 against the b33421b diff. Every item from the first round is fixed as described, and I checked the two blocking ones on hardware rather than by reading.

Verified on this rig

  • Fault exit. fault() → g_fault → exit 3, every guard routed through it, fault= leads the ledger, the cell scores 3 as FAIL with the cause. test_a_fault_exits_nonzero pins it.
  • Interrupt during bring-up. Handlers now first in main; sta_pid_kill polls a zombie-aware liveness check and reaps only after exit; cleanup() goes through sta_stop's INT→KILL escalation. Measured: TERM to the harness the moment sta_client appeared, before up: printed. Harness exited 3 in 6.1 s, the station still left and printed its ledger, no sta_client/hostapd/netns/TAP left, DUT back on mt76x2u.
  • The retry-limit parse now shares env_long_strict (empty / 5x → station default 7, with the library's own warning), the TAP reader faults on error/HUP, the retune guard drops DS-less beacons for 50 ms instead of mis-tagging them, and test_forged_mic_is_refused feeds and asserts the genuine frame. The strict duration/channel/ms parsing and the extra fault paths from the author's own sweep read correctly.
  • Build -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON: ctest 83/83. CI 23/23.

On air (tests/mt7612u_sta_onair.sh, MT7612U station, hostapd on an 8812BU under rtw88, ch6): first full run 14 passed, 0 failed, 1 inconclusive, then retry0 alone 2/2, then the interrupt check's open 5/5. Scored outcomes identical to the first round.

Non-blocking, from the inconclusive

Back-to-back hostapd restarts race the interface. cell_end returns as soon as the hostapd process exits and ap_up launches the next one immediately. The retry0 hostapd started 30 ms after the noarm one logged AP-DISABLED and died with Could not read interface wlp13s0u2 flags: No such device / nl80211 driver initialization failed. One in four restarts here; the author's runs and my first-round run did not hit it. A wait in ap_up for the netdev to be present and type managed before launching (or one retry on that failure) would close it. Correctly scored INCONCLUSIVE either way.

Observations, both on unscored INFO lines: noarm ping 33% loss on the first run and retry0's single-shot uplink 16.7% on the rerun, both 0% on other runs. Six pings at near field is a sample, not a measurement, and the cell says so by not scoring them.

Approving. The AP-restart wait can ride the cleanup PR the author already mentioned for the sibling MT7612U harnesses.

@josephnef
josephnef merged commit 927cfc7 into OpenIPC:master Oct 3, 2026
24 checks passed
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