Skip to content

sta: station client on every station-capable die, AP restart survival, MT7612U managed RX filter - #471

Open
snokvist wants to merge 53 commits into
OpenIPC:masterfrom
snokvist:pr/sta-station
Open

snokvist wants to merge 53 commits into
OpenIPC:masterfrom
snokvist:pr/sta-station

Conversation

@snokvist

@snokvist snokvist commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

@josephnef - the station half of the #461 / #467 follow-ups, ready for review. The TX half is #470.

What changed

The station client (#464, #465) now runs on every die that reports station_mode_ok, survives its AP restarting, and on MT7612U it is no longer promiscuous while armed. It also closes the station-harness items of #467.

Refs #461, Refs #467 (partial: the harness items; see Follow-ups).

Library

  • sta: StationSm::link_lost. A caller's liveness check gets back into the ordinary failure path: from Connected it fails the association as Failure::Unconfirmed (no AID, queue cleared, like any failure); in any other state it does nothing. Association Responses that arrive when none is awaited are now counted (rx_assoc_repeat) instead of being dropped silently. ctest station_sm: test_link_lost.
  • mt7612u: the station arm owns the receive filter. Addresses the managed-filter item of Follow-ups left open by the station identity seam (#460) #461.
    • SetStationIdentity installs MT_RX_FILTR_CFG_MANAGED (0x00015f97, the value every station cell in docs/mt7612u-station-identity.md measured; regs.h now names its bits). It replaces the monitor filter that StartRxLoop installs, and the arm counts only once the write reads back.
    • ClearStationIdentity puts the replaced value back and returns true only once that reads back. A failed clear keeps the arm recorded, so a second clear retries.
    • While armed, mt7612u_set_monitor_rx() records its request instead of installing it, so the arm does not depend on whether it came before or after StartRxLoop.
    • A beacon or ACK responder that takes the port identity drops the arm and restores the pre-arm filter; a failed beacon start that restores the arm reinstalls the managed one. Both writes are read back.
    • Stop() does not clear a still-armed station. Every bring-up rewrites the filter (the initvals, then mt_mac_start()), and so does mt76, so a filter left armed at a close reaches no later opener.
    • StartRxLoop's log names the filter in force at RX start, and SetStationIdentity logs the switch to the managed filter (the usual station arms after RX start).
    • A refusal writes nothing. A managed write that does not read back is undone and the undo is verified; an undo that misses is recorded, so the clear still restores the pre-arm value and a retried arm does not mistake the stranded managed filter for it.
    • API: mt7612u_clear_station_identity() now returns int. IRadio.h and AdapterCaps.h change comments only; no Realtek backend is touched.
    • The policy lives in StationIdentity.h and is pure; ctest mt7612u_station_identity covers it, including the bit decode. The hardware gate mt7612uprobe staid checks the filter across arm, re-request, refusal, clear and drop.

Station client and harness (tests only)

  • tests/mt7612u_sta_onair.sh → tests/sta_client_onair.sh.
    • DUT: an MT7612U, an RTL8812CU (8822C) or an RTL8812BU (8822B).
    • AP: any adapter whose in-kernel driver supports AP mode and can change network namespace (mt76, rtw88).
    • A Realtek DUT is taken and handed back through new generic lib helpers (sta_dev_record / _opened / _unbind_wifi / _handback), which replace realtek_station_onair.sh's own copies. A DUT is still never re-enumerated while sta_client is alive.
    • The one-run-per-OUT lock is now an flock(1) on the OUT directory. The old mkdir + separate PID write could let a concurrent run read an empty PID, reclaim the live lock and share OUT.
  • Per-die scoring. On Realtek, noarm is the arm's control: unarmed, the MAC does not acknowledge, so hostapd never lets the station in. On MT7612U, noarm is the receive filter's control. On both dies the clear must verify.
  • reconnect / noreconnect cells. hostapd is stopped for DOWN_S and restarted on the same BSSID; the variant runs with DEVOURER_STA_RECONNECT=0. The ledger's first line is the state the run ended in, taken before the teardown's leave, so noreconnect ends Failed reason=... rather than Idle.
  • The MT7612U managed-filter cells (wpa2, noarm).
    • Once associated, a monitor vif on the AP's phy injects two plaintext unicast streams from the AP's BSSID: one to an address nobody holds, one to the station's own address.
    • The own stream is the positive witness that the injection reaches the DUT: sta_client refuses it on WPA2 and counts it (plaintext refused, new). If less than half of it arrives, the check is INCONCLUSIVE.
    • Armed: not-for-us must stay under 1% of the foreign stream. Unarmed: at least half of it must arrive.
    • The own stream proves the injection path airs, not that the foreign injector did. So an armed PASS is held until the noarm control of the same run has seen the foreign stream arrive; without it the check is INCONCLUSIVE.
    • A Realtek DUT skips the injection (INFO).
    • The injection runs after the cell's ping and before the rekeys. Injected before the ping, the AP's retransmissions of the unacknowledged foreign stream delay the ping's first echo (see the reviewer's second bench below). It starts only when now + INJECT_S + 8 s is still before both first rekeys, read off hostapd's own stamps: REKEY_S after AP-ENABLED and PTK_REKEY_S after the four-way. The injectors are killed at INJECT_S + 2 s (KILL 1 s later; was + 10 / -k 5), so the 8 s cover them, the vif's add and delete, and a margin. Otherwise it is skipped and the check is INCONCLUSIVE, naming the knobs. REKEY_S and PTK_REKEY_S default to 90 s and 80 s (were 20 and 25), sized for noarm, the longer cell: four-way ~2 s after AP-ENABLED, up to 3 s of warm-up ping, the 30 s PING_S window, then INJECT_S + 8 = 18 s - about 53 s from AP-ENABLED, 51 s from the four-way, leaving ~30 s for a slower join or host. The wpa2 cell now waits about a minute longer for its rekeys.
  • Open associations are confirmed (sta_client). A station cannot see the AP's side, so an open association counts as unconfirmed until the AP's first unicast data frame to the station. A (QoS) Null carries no MSDU and does not count.
    • A question is a frame whose answer, if one exists, the AP must forward back: an ARP request (not a gratuitous or probe ARP), an ICMP / ICMPv6 echo request, a unicast IPv6 neighbour solicitation, a TCP SYN, or a DNS query. Once the host has asked at least 3 of them, and at least 5 s have passed since the first with no unicast reply, the link is lost as unconfirmed and re-joined.
    • One-way traffic is never judged; that includes a UDP video or telemetry uplink, the FPV case. Multicast chatter, any TCP segment other than a SYN, and an idle host are never judged either. WPA2 needs no such rule: the four-way is the confirmation.
    • Backoff per BSS. A question can go unanswered on a healthy link: the host pings or ARPs a peer that is switched off, or its DNS has no upstream. So consecutive verdicts on one BSS back off. The first fires as above. After n of them, the next association on that BSS is judged only once 5 s × 2^n has passed since it was made: 10 s, 20 s, 40 s, 80 s, then the cap of 2 minutes. Questions asked inside the backoff are not counted. A unicast reply from the AP, or an association on a different BSS, resets it; a broadcast does not.
    • The cost: with a dead peer, the station re-joins at most once per backoff period, growing to one re-join every 2 minutes (plus the 5 s window). An association the AP really dropped is still found, up to one backoff period late. (An earlier draft let the BSS off after one verdict, which could leave a held-but-dead association dead for good.)
    • The numbers 3 / 5 s / 2 min are pinned to these docs by a static_assert.
    • On air, the open cell FAILs a verdict on an association the AP held. Each association and verdict line from sta_client carries an at= wall-clock stamp, and a verdict FAILs if hostapd (-t) logged AP-STA-CONNECTED for our address between the association it ended and the verdict. A CONNECTED logged after the verdict does not count; the re-join's own probe can release a held status that late.
    • That ordering is checked, not assumed. hostapd stamps CONNECTED microseconds after accepting an association, and the station's stamp lands milliseconds either side of it. So every CONNECTED in hostapd's log is accounted for. Each association owns the window from its at= to its verdict or the next association:
      • a CONNECTED inside a verdict's window is that verdict's FAIL, reported whatever else the log shows;
      • the first CONNECTED inside a window without a verdict is that association's own: the positive control, counted once per association;
      • a CONNECTED outside every window must be followed by a DISCONNECTED before the next association - an episode the re-join ended.
    • Any other CONNECTED proves this rig can stamp a held association's CONNECTED before the station's at=, so the verdicts are INCONCLUSIVE rather than cleared.
    • Also INCONCLUSIVE: a verdicted association that began while hostapd still held us - the last event for our address before its at= is a CONNECTED with no DISCONNECTED after it. That CONNECTED may be its own, stamped early. This closes the case where an earlier window has no CONNECTED of its own (an association hostapd never held, re-joined after a deauthentication, which leaves no verdict) and would otherwise claim the next association's early CONNECTED.
    • Also INCONCLUSIVE: a run with no control; a missing, empty, malformed or out-of-order stamp; hostapd stamps for our address that go backwards in its log (the AP clock stepped); a missing or mismatched ledger; or logs that could not be parsed.
    • Only our address on the cell's AP interface counts, matched case-insensitively as the token after the event name, so a newer hostapd's trailing auth_alg= field is fine. On a rig where hostapd stamps first, expect INCONCLUSIVE rather than INFO whenever a verdict fired.
    • A question, a probe and a nudge are counted only once the 128-deep transmit queue accepted them.
    • Pinned headless, and checked by 35 mutations of sta_client.cpp on a scratch copy, all killed (see Verification).
  • The association nudge (sta_client). The moment an Association Response is accepted, open or WPA2, the station sends one SSID-specific probe request (to broadcast, carrying the SSID element). On WPA2 a second one follows if no EAPOL has arrived 1 s later, and none after that. The nudge does not retune and does not touch liveness state (pinned headless). See "AP quirk" below for why.
  • Diagnostics. Ping windows (PING_S) replace six-ping link reports. Also added: HOSTAPD_DEBUG=1 (hostapd -dd), the dmesg tail of a failed cell, and AP_OFDM_ONLY=1.
  • The AP-netdev wait / force-managed before each hostapd came in with cleanup: follow-ups from the station series reviews (#451-#464), part of #465 #466 under the old file name; the renamed script carries that block unchanged.

Harness follow-ups from #467 (tests only)

  • mt7612u_sta_identity.sh: a gate that could not measure now reads as INCONCLUSIVE. An overrun BSSID gate set r_bss=2, but the verdict case had only 0, 3 and *, so the operator read "did not pass" for a run the exit code called INCONCLUSIVE. The probe-response gate had the same gap (r_ack=2, arm C never reported), and both now have a 2) branch.
  • Firmware readability preflight. The new sta_fw_readable in the lib checks that both MT7612U blobs are present, readable and non-empty, and names the .bin.zst-only case when that is what it finds. sta_fw_link runs it through the link it made or found, so mt7612u_sta_identity, _autoack and _uplink refuse the rig (exit 2) in seconds, before the DUT is taken. Before this, the uplink harness scored ABORTED the DUT did not confirm retry limit 15: with an empty reason. sta_client_onair.sh runs the same check on FW_DIR for an MT7612U DUT.
  • INT/TERM exits 3 in every station harness. mt7612u_sta_autoack, mt7612u_sta_uplink and mt7612u_ap_onair exited 130. All six station harnesses now share one convention: 0 pass, 1 fail, 2 inconclusive, 3 interrupted. The autoack, uplink and AP headers now state it, and no caller of a station harness reads 130.
  • mt7612u_ap_onair.sh: reap no longer leaves zombies. On the KILL path it cleared KIDS without waiting on anything. It now gives a KILL 2 s to land, then waits on every child that has exited. A child still running after that is not waited on, since that wait could block. The return value is unchanged.
  • realtek_station_onair.sh: tail_ms is clamped at 0. The final tx.stats t can come a few ms before the last tx.report t. This never affected live.

Harness hardening from review (tests only)

  • A second Ctrl-C cannot abandon a hand-back. Each harness's cleanup set CLEANED=yes first, and the INT trap was cleanup; exit 3, so a second INT mid-cleanup returned at once and exited. That left the AP phy in the netns, the netns itself, NetworkManager unmanaged and the DUT not re-enumerated. cleanup now ignores INT/TERM as its first line. In mt7612u_ap_onair.sh, which also cleans up between cells, the ignore sits in the INT handler and the EXIT trap, and a between-cell cleanup runs with INT/TERM ignored.
  • mt7612u_ap_onair.sh: a child that outlives its KILL stays recorded. Before, reap dropped it, so the EXIT trap's later cleanup re-enumerated the AP under it. Now every later reap returns 1 and leaves the adapter alone.
  • Every DUT and peer take refuses an adapter with a live holder (sta_usb_unheld): an interface bound to usbfs (a process has claimed it), or a process with the adapter's /dev/bus/usb node open; the message names the PID. sta_dut_take (MT7612U) and sta_dev_record (a Realtek DUT or peer, in sta_client_onair, realtek_station_onair and the MT7612U harnesses' peer) both run it; sta_dut_take also refuses interface 0 bound to any driver other than mt76x2u. A Realtek DUT is marked opened before its unbind, so an unbind cut short (Ctrl-C, or one of two netdevs freed) is still handed back; the gate has already refused a held adapter, so the authorized toggle never lands under a live process. The AP paths need no gate: every AP guard requires a wireless netdev, which a usbfs-claimed interface does not carry. Before, a second run with its own OUT could toggle authorized under a live devourer process. An unbound interface that nothing holds is taken as it is: a devourer session detaches mt76x2u and never reattaches it, and a host may blacklist mt76x2u. The lib header no longer claims the adapters are exclusive, and says what it cannot see: another harness between two of its gates.
  • sta_usb_holder reads ls -l /proc/*/fd. It used find -lname, which busybox find lacks; the swallowed error read as "nothing holds it". GNU find -samefile holds the node open itself, so it is no substitute.
  • proc_running is gone from sta_client_onair.sh and realtek_station_onair.sh: both call the lib's sta_pid_alive, which has the empty-PID guard.
  • sta_dut_handback waits for mt76x2u to bind again (up to 5 s, when it is loaded), so a back-to-back next run starts from a bound adapter.
  • The firmware preflight follows the library. sta_client_onair takes the first of FW_DIR, /lib/firmware/mediatek and ./firmware that holds both blobs, as resolve_fw_dir does. sta_fw_readable tells a missing blob from an unusable one and names any compressed copy (.zst, .xz, .gz).
  • mt7612uprobe staid: the clear-after-drop check poisons the register first and reads the poison back, so it can fail. Before, the drop had already written the value it read back. A poison that does not stick is a SKIP, which counts as a failure like the gate's other SKIPs. A clear that missed is retried once, and a second miss is reported.

Why

#464 could only take an MT7612U. On that part the arm writes no identity register, so neither the arm nor its control was ever measured on the dies where the arm writes registers. On the MT7612U itself, a station driven through IRadio ran the monitor filter: it received promiscuously, and the measured property "moving the port identity makes a station deaf" did not hold for it. And a station that cannot survive its AP restarting is not ready for real use.

What is measured

Bench: one host (Linux 7.0), ch6, near field. MT7612U at 1-1, RTL8812CU at 5-1, RTL8812BU at 8-1.

Which head. The base full run is on 0fa46cc. af19b9c was re-benched after the Qodo round (below), here and on the reviewer's bench, as was c54226e, the same code on air before af19b9c's selftest and lib-guard commit. The last benched code is 232a631 (the review round on af19b9c: harness gates, the injection schedule), benched here, as was 42fab15, the same code before the opened-mark and injector-bound commit (below). The PR head is fba6700, a doc-only commit adding the newest runs to the identity table on top of a66f659, which changed the schedule after that and was benched here on the MT7612U-station row, twice: wpa2 ping 6/6 and noarm 60/60 both times, managed filter own 893/893 and 942/942 with foreign 0/796 and 0/789. One run was 29/29; the other 24 + noreconnect INCONCLUSIVE, the beacon-loss intermittent below (1 beacon received after keying; that cell does not inject). It moves the injection back after the ping, with REKEY_S/PTK_REKEY_S defaults of 90/80 (below). ddec632 before it added only an INCONCLUSIVE message of its own for a missing hostapd stamp, a check that REKEY_S and PTK_REKEY_S are at least 1, and doc and comment corrections. The reviewer's second bench ran ddec632. Between 0fa46cc and 2f6cffe only the open cell's verdict scoring (verdicts_scored), its fixtures in tests/sta_client_selftest.inc, and docs changed, and the open cell was re-run on 2f6cffe. The held managed-filter counts are in the table in docs/mt7612u-station-identity.md.

On air (tests/sta_client_onair.sh, all cells, 0fa46cc):

DUT AP Result
MT7612U 1-1 RTL8812BU 8-1 (rtw88) 29/29
RTL8812CU 5-1 (rtl88x2cu) MT7612U 1-1 (mt76x2u) 28/28
RTL8812BU 8-1 (rtw88) MT7612U 1-1 (mt76x2u) 28/28
  • Held managed filter on the MT7612U DUT: PASS in ten armed runs over two benches (the table in docs/mt7612u-station-identity.md). In every run the noarm control saw the foreign stream.
    • Injection after the ping, REKEY_S=20: 0fa46cc own-addressed 943/943, foreign not-for-us 0/609 (control own 724/740); c54226e 943/943, 0/927 (control 728/739); af19b9c on the reviewer's bench 775/863, 0/642 and 875/875, 0/713 (one control figure for both runs: own 612/706).
    • Injection straight after the four-way, REKEY_S=30: 42fab15 746/746, 0/684 (control 821/821); 232a631 751/751, 0/683 (control 944/944) and 833/833, 0/721 (control 746/746). ddec632 764/764, 0/739 (control 870/870).
    • Injection after the ping, REKEY_S=90/PTK_REKEY_S=80 (the harness default): a66f659 893/893, 0/796 (control 720/943) and 942/942, 0/789 (control 700/744).
    • Unarmed, the control counts 9-31x as many foreign frames as were injected, while the own stream arrives 1:1: the rtw88 AP very likely retransmits frames nobody acknowledges. The control needs only half, so the excess does not change a verdict.
  • 8812CU station, MT7612U AP, open wpa2 ×3 (HOSTAPD_DEBUG=1, 0fa46cc): 13/13 each.
  • mt7612u_sta_identity (0fa46cc): staid 22/22, rc 0. STAACK is INCONCLUSIVE and the BSSID gate is judged by the operator, both as documented.
  • Harnesses (0fa46cc):
    • mt7612u_sta_autoack 3/3.
    • realtek_station_onair: 5/5 with the 8812CU as the DUT and 5/5 with the 8812BU; the tail_ms clamp was exercised.
    • mt7612u_ap_onair with CELLS=all: 13/14. In the stop cell, the 8812BU witness scan did not see the re-arm phase's beacon once, although devourer logged "beaconing" for the re-arm. Re-running that cell 3× on 2f6cffe gave 4/4 each time. The library diff against the previous 14/14 run (6e7813e) is two log lines. Read it as an intermittent witness miss, not a pass.
  • mt7612u_sta_uplink (FRAMES=20): FAIL, rc 1. Master 1134df9 fails identically on the same unit: the unicast arms get 0-7 of their 20 status entries and are UNSETTLED. This is the Follow-ups left open by the station identity seam (#460) #461 status loss inside the txs gate, which this PR does not touch. mt7612u: no TX transfer timeout - a full ring refuses, it never cancels #470 fixes it: on that branch every arm reads 60/60. This harness's verdict depends on mt7612u: no TX transfer timeout - a full ring refuses, it never cancels #470, not on this PR.

The open cell on 2f6cffe: MT7612U station 5/5, 8812BU station 5/5, and the 8812CU station 5/5 twice.

One 8812CU run caught the real case on air, against the MT7612U AP:

  • Association 1 (at=…74.513104) never got an AP-STA-CONNECTED from hostapd: the AP held its status.
  • The station judged it unconfirmed 6.6 s later, after 5 questions and no unicast reply, and re-joined.
  • Association 2 (at=…82.126184) got AP-STA-CONNECTED at …82.127645, 1.5 ms after the station's own stamp, so the ordering control held on this rig.
  • The cell scored INFO "recovered", ordering checked against 1 held association.
  • Re-join time, from the verdict to the next association: ~1.04 s.

af19b9c (after the Qodo round). sta_client_onair, all cells:

  • MT7612U station, 8812BU AP: 7 runs. 6× 29/29: one with HOSTAPD_DEBUG=1, four with a sniffer on a third radio. 1× 24 passed with noreconnect INCONCLUSIVE.
  • 8812BU station, MT7612U AP: 2× 28/28.

c54226e (the same code on air):

  • 8812CU station, MT7612U AP: 28/28 for all cells, and open wpa2 2× 13/13.
  • MT7612U station, 8812BU AP: wpa2 FAIL and noreconnect INCONCLUSIVE (see "Intermittent on the MT7612U-station row" below).
  • 8812BU station, MT7612U AP: noreconnect INCONCLUSIVE. That is the known MT7612U-AP hold of the association response's TX status ("AP quirk", below); with DEVOURER_STA_RECONNECT=0 the station cannot re-join past it.
  • mt7612u_sta_identity: staid rc 0.
  • realtek_station_onair: 5/5 with the 8812CU as the DUT, 5/5 with the 8812BU.

af19b9c on the reviewer's bench (ch6; RTL8812BU AP under rtw88, modprobed explicitly). ctest 83/83, no new warnings. sta_client_onair, all cells:

  • RTL8812CU station, MT7612U AP (mt76x2u): 28/28.
  • MT7612U station, RTL8812BU AP: run 1 had 28 passes and 1 FAIL. A wpa2 noarm repeat gave 11/11.
    • The FAIL is wpa2: ledger associations=2. hostapd's Group Key Handshake 1/2 went unanswered four times (TX status ack=1, ack=1, then ack=0) in the seconds the injection's monitor vif was being torn down. The AP then deauthenticated, and the station re-joined after a handshake timeout.
    • With the defaults, that overlap was built in. The group timer (20 s) starts with the AP. The injection began after the four-way and a 6 s ping and ran for 10 s, so it ended within a second of the first group rekey. Our c54226e log shows the same timing: injection from about t0+10.5 to t0+20.5 s, and the group rekey completed at t0+19.4 s (that time it was answered). The injection is now scheduled clear of the rekeys (above), and wpa2 passed in every run since (below).
    • Managed-filter witness, both runs: own-addressed 775/863 and 875/875 arrived, foreign not-for-us 0/642 and 0/713. The noarm control saw both streams (own 612/706).

42fab15 and 232a631 (the review round on af19b9c). sta_client_onair, all cells; wpa2 passed in every run with the new injection schedule.

  • 42fab15, full: MT7612U station, 8812BU AP 29/29; 8812CU station, MT7612U AP 28/28, and open wpa2 2× 13/13; 8812BU station, MT7612U AP 28/28. mt7612u_sta_identity: staid rc 0. realtek_station_onair: 5/5 with the 8812CU as the DUT, 5/5 with the 8812BU.
  • 232a631: MT7612U station, 8812BU AP 2× 29/29; 8812BU station, MT7612U AP 1× 28/28.

ddec632 on the reviewer's bench (second round). The rekey FAIL is gone. A new FAIL repeated 2 of 2: wpa2 ping 6 transmitted, 5 received, +1 errors (seq 1 "Destination Host Unreachable", seq 2 at 464 ms), and the noarm window 58/60. The ping started ~9 s after the injectors ended, behind the rtw88 AP's retransmissions of the unacknowledged foreign stream (6030 seen unarmed for 770 injected). The managed-filter check passed and the ping after the rekeys was 0% loss. a66f659 injects after the ping again, the order that was clean in every earlier run.

Intermittent on the MT7612U-station row. It was seen twice in 11 runs of that row: c54226e wpa2 and af19b9c noreconnect. The three runs since (42fab15 once, 232a631 twice) were clean.

  • What it looks like: for one hostapd instance, the DUT receives the 8812BU (rtw88) AP's unicast (EAPOL, data, pings) but none of its beacons and no group-addressed data.
  • Station counters: beacons 0-5 against the usual ~60-400; group 0 against 9-19. Frames from foreign BSSes keep arriving at the normal rate.
  • The station then declares beacon loss about 1 s after keying.
  • It is not caused by this PR's diff: hostapd always stopped on TERM, and the new guards never fired.
  • It is a different signature from the reviewer's rekey FAIL above. In our c54226e wpa2 FAIL, the station logged link lost: beacon-lost 2 s after association 1, about 6 s before the injection started; the rekey completed and was answered.
  • A monitor-mode sniffer on a third radio did not catch it in 4 runs; every hostapd instance beaconed at ~9.6/s. So whether it is AP-side (the rtw88 beacon engine) or DUT-side is still open. Listed in Follow-ups.

Against it: one run per cell (except where repeats are given), one rig, one channel.

What the Realtek noarm control scores. Unarmed, the MAC does not acknowledge own-addressed unicast, so the AP never sees its authentication response ACKed and never lets the station in. The cell scores:

  • that the station tried (beacons observed, authentication sent; otherwise INCONCLUSIVE);
  • that hostapd did NOT complete the four-way within 30 s.

A completed four-way is a FAIL. The cell is INCONCLUSIVE unless the armed wpa2 cell of the same run got in, which is its positive control.

AP quirk: an MT7612U AP holds the association's TX status

The intermittent open-association failure of an 8822C station against an MT7612U AP is AP-side. It is the same silicon behaviour as #461's next-submit hold (a frame's TX status is posted only when the next frame goes out), here in the kernel's mt76x2u.

hostapd's debug log of a failing run:

  • "association OK (aid 1)", the station added, the Association Response sent;
  • no TX status for six seconds, while the station had received and ACKed the response and believed itself associated;
  • the station's next frame made the AP transmit, and the status arrived as ack=1. By then hostapd no longer held the station ("handle_assoc_cb: STA ... not found").

Supporting observations:

  • A capture of passing runs shows the 8822C ACKing the response at 1 Mb/s within ~0.3 ms.
  • The AP never retransmitted the response (assoc_repeat=0).
  • Whether a station hits the hold depends on whether anything else makes the AP transmit right after the response. That is why runs pass, then fail several in a row.
  • hostapd starts the WPA2 four-way only from the same TX status, so WPA2 stalls the same way. In a failing run every attempt reads "authenticated", then nothing until the station's handshake-timeout re-join about 4 s later releases the status, eight times in 30 s.

Bench, open wpa2 ×5 per AP:

  • 8822C station, 8812BU AP (rtw88): 5/5, everything.
  • 8822C station, MT7612U AP, with an open-only nudge: open 5/5 (one nudge per run, unconfirmed=0 every run); wpa2 3/5. The two failures are the hold above, which the open-only nudge did not cover.
  • Before any nudge: 4/5. One run recovered after 3 unconfirmed associations; one failed six in a row.
  • With the nudge on every association (this PR), 8822C station against the MT7612U AP, open wpa2 ×3: 3/3 clean (13/13 each) on 6e7813e and again on 0fa46cc, unconfirmed=0 every run on 6e7813e.

What it can't do

  • Dies other than MT7612U / 8822C / 8822B can be named with DUT_VID / DUT_PID, but sta_client refuses them unless they report station_mode_ok.
  • The reconnect cell keeps one arm across the re-join, because the arm is per BSSID. A re-join to a different BSSID re-arms; that path is not on air here.
  • The Realtek noarm result rests on hostapd's TX-status reporting through the AP's driver.
  • The filter cells need the AP's phy to add a monitor vif next to the AP; one that cannot makes them INCONCLUSIVE.
  • Confirmation: an unheld open association carrying one-way traffic alone is found only when the host's stack next asks something; its neighbour re-verification is a unicast ARP request. That is the price of never judging a one-way uplink. And after verdicts on a BSS, an unheld association there is found only once the backoff has passed, up to 2 minutes late (above).
  • While armed, the MT7612U drops FCS-bad frames (rx.keep_corrupted applies to the monitor filter only) and runs hardware duplicate drop, as mt76 runs a station.
  • The backoff remembers one BSS. A station alternating between two BSSes of one ESS resets it at each switch, so a dead peer can then cost a verdict and re-join every cycle: the 5 s window, the re-join backoff (1 s by default) and the handshake. select_open normally keeps to one BSS.
  • sta_dut_handback rebinds mt76x2u even when the run took the DUT unbound.

Base

Rebased on master after #466 (1134df9). This PR renames tests/mt7612u_sta_onair.sh, which #466 edited. The renamed tests/sta_client_onair.sh carries #466's ap_up block (wait for the AP netdev, force it to managed, leave it up on a timeout) line for line, so the rename drops nothing #466 added. Two of this branch's commits duplicated that block for the old file name, and they were dropped in the rebase. Every other file's diff against master is the same as it was against #464.

Follow-ups

Verification

  • cmake -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON: ctest 83/83.
  • -DDEVOURER_SANITIZE=address+undefined: ctest 83/83, and sta_client --self-test run directly under it prints no sanitizer report.
  • CI subsets, with the workflow's flags: mt7612u-only 72/72, mt7612u+jaguar1 73/73, rtl8733b-only 75/75, jaguar2-only 73/73, jaguar3-only 72/72.
  • mt7612u_usb_ids_vs_mt76 and mt7612u_initvals_generated report Skipped in every build here, and are counted in the totals. They need the reference/ submodules.
  • No compiler warning in a file this PR touches.
  • bash -n passes on every touched script (and sh -n on the lib). shellcheck -x (0.11) adds no new finding class to any of them. What remains is the SC2329 note on indirectly called functions, which these scripts already suppress under its older code SC2317, plus mt7612u_ap_onair.sh's SC2046/SC2015/SC2012, which predate this branch.
  • sta_client --self-test, mutation-checked. 35 mutations of sta_client.cpp, each compiled from a scratch copy, and all 35 are killed - 33 by a failing check and 2 at compile time by the static_assert. They cover:
    • the verdict, the 3-question threshold (the check and the constant), the 5 s window, and the window opening at the first question rather than at the association;
    • a broadcast from the AP confirming, and a (QoS) Null or an empty-bodied Data / QoS Data frame confirming (with the to-us test, the subtype check, the body-length check, or both checks dropped);
    • every solicits_reply branch: TCP never or always a question, a SYN-ACK counted, the TCP length bound, ICMPv6 never counted, any ICMP type counted, the non-first-fragment check, the multicast-DA refusal, any UDP counted, ARP never counted, gratuitous ARP counted;
    • the backoff not honoured, never counted, not reset by a reply, reset by a broadcast, not per BSS, not doubling, uncapped, and its cap constant;
    • a reply not confirming;
    • a dropped question or probe being counted;
    • the at= stamps: CLOCK_MONOTONIC instead of the wall clock, millisecond precision, and at= dropped from the verdict line or from the association line.
    • The TCP length bound is also checked under ASan, where its fixture reads past an exact-size allocation.
  • The harness fixes, checked off-air:
    • sta_fw_readable on fixtures, under bash and dash: refuses .zst-only and .xz-only directories by name, an empty .bin as unusable, and a missing directory. It passes a good directory, a symlink to one, and this host's /lib/firmware/mediatek, which has both forms.
    • reap with a TERM-ignoring child: rc 1 and no zombies. With a simulated unkillable child: rc 1, and the child is kept, so a later reap returns 1 again. A clean reap still returns 0.
    • A cleanup of the harnesses' shape, hit by INT, INT, TERM, runs to the end and exits 3.
    • verdicts_scored (the open cell's verdict check), on 31 fixtures, with identical results under gawk, mawk and busybox awk:
      • A CONNECTED inside a verdict's window FAILs, including at 1 µs, with a -dd double stamp, and only the held verdict of three.
      • A late release with the control in order is INFO.
      • A FAIL on one verdict survives a garbage stamp on another row, blank lines in hostapd's log, and an upper-case MAC with a trailing auth_alg=open.
      • A CONNECTED for our MAC on another interface is not counted.
      • INFO also with two controls, and with an unheld, held, unheld sequence of associations.
      • INCONCLUSIVE: a CONNECTED stamped before its association's at= (the held-early race; a control whose CONNECTED is early; an association's CONNECTED landing in the previous association's window; three shapes of a verdicted association that began while hostapd still held us; an AP clock that stepped back), no control, missing, empty or garbage stamps, a verdict before its association, a ledger mismatch, and a missing ledger.
    • The live-holder gate, on a copy of the lib with its sysfs and usbfs roots moved under a scratch directory (no real device is touched), under dash, bash and busybox sh: 8/8 checks. A free adapter and one bound to a kernel driver are recorded. One whose node a stand-in process holds open is refused, naming that PID, and leaves no identity to hand back. Released, it is recorded again. An interface bound to usbfs is refused. The af19b9c lib fails 5 of the 8 checks.
    • rekey_clear (the injection's schedule) against c54226e's hostapd_wpa2.log, with a fixed clock, under gawk, mawk and busybox awk: 8/8. Clear with room; clear by 0.08 s and not clear by 0.42 s before the group rekey; the pairwise timer counted from the LAST four-way; no four-way for that station; an empty log; a missing log.
    • summarize with a final tx.stats 6 ms before the last report: tail_ms=0, live=1.
  • On air: see the table above.

Review record

  • josephnef on sta: station client on every station-capable die, AP restart survival, MT7612U managed RX filter #471 (CHANGES_REQUESTED at af19b9c), all addressed in 26db041..ddec632; his second round (the ping behind the injection's backlog) in a66f659. The library delta was found sound. Done: a live-holder gate on the Realtek DUT take, lifted into the lib for every DUT and peer take (the opened mark stays before the unbind: the gate closes the live-process case, and moving the mark would strand an adapter whose unbind is cut short); the identity doc's on-air numbers in place of TBD; the history narration and issue reference taken out of that doc; proc_running replaced by sta_pid_alive, in two scripts. The two wording notes are fixed: the 3-question / 5 s rule, and "SSID-specific", not "directed". His wpa2 FAIL (a group rekey unanswered during the injection vif's teardown) led to the injection being scheduled clear of the rekeys.

  • Qodo on sta: station client on every station-capable die, AP restart survival, MT7612U managed RX filter #471, two findings, both held and fixed:

    • A QoS Null addressed to us confirmed an open association. It reaches the plaintext path, because is_qos_data() admits the no-data subtype; a plain Null does not. Only a data-bearing frame now confirms.
    • A hostapd that outlived its kill lost its PID record, so run_reconnect could start a replacement beside it, and cleanup could move the AP phy out from under it. The record is now kept; the AP is not restarted or moved while it lives; the cell is INCONCLUSIVE.
  • Independent verification of the Qodo fixes, two follow-ups, both done (af19b9c):

    • The body-length half of the confirmation check had no test. A Data frame and a QoS Data frame with an empty body are now pinned, and that mutation is killed.
    • An empty PID record read /proc//stat (that is, /proc/stat) and counted as running. sta_pid_alive now treats an empty or non-numeric PID as not live.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3

snokvist and others added 30 commits October 7, 2026 19:30
A station cannot see the AP's side of an association. An Association
Response the station received but whose acknowledgement the AP never saw
leaves the AP without the station while StationSm sits Connected, and on an
open BSS nothing in the protocol ever says so (seen on air with an 8822C
station against an mt76 AP: the AP's status for the response stayed
pending, no deauthentication followed).

What proves the AP's side is a unicast reply to the station's own traffic,
which only the data plane sees, so the check belongs to the caller.
link_lost() is its way back into the ordinary failure path: from Connected
it fails the association with the new Failure::Unconfirmed (no AID, queue
cleared, as any failure); anywhere else it changes nothing.

on_assoc_resp also counts Association Responses that arrive when none is
awaited (rx_assoc_repeat) instead of dropping them silently - an AP
retransmitting one is the visible half of a lost acknowledgement.

station_sm: test_link_lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
SetStationIdentity now installs MT_RX_FILTR_CFG_MANAGED, the managed filter
every station cell measured, in place of the monitor filter the RX loop
installs, and reads it back before the arm counts. ClearStationIdentity puts
the replaced value back and returns true only once it reads back; a failed
clear keeps the arm recorded so a second clear retries. While armed,
mt7612u_set_monitor_rx() records its request instead of installing it, so
the arm is order-independent against StartRxLoop. A port-identity drop
(beacon / ACK responder) restores the pre-arm filter and a restore
re-installs the managed one; both writes are read back and a miss is WARNed
(kept out of the caller's I/O-error accumulator). Refusals write nothing.
Stop() clears a still-armed station before closing, best effort - the chip
keeps the managed filter across a close otherwise - and logs a failure only
after the stop and close.

mt7612u_clear_station_identity() now returns int (0 restored or nothing
armed, -1 not verified). IRadio.h / AdapterCaps.h change comments only.

The policy is pure (StationIdentity.h) and covered by ctest
mt7612u_station_identity, including the bit decode of 0x00015f97 (regs.h
names the bits); the mt7612uprobe staid gate checks the filter across
arm / re-request / refusal / clear / drop. Addresses the managed-filter item
of OpenIPC#461.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
tests/realtek_station_onair.sh carried its own rt_record / rt_opened /
rt_handback for adapters devourer opens over libusb, and the lib carried
the same three for PEER_SYSFS only. They are now one set in
tests/mt7612u_sta_lib.sh, kept per NAME in files in OUT (POSIX, and a
process started in a command substitution can still mark a device
opened):

  sta_dev_record NAME SYSFS VID PID   refuse a hub or the wrong VID:PID,
                                      note idVendor:idProduct:serial
  sta_dev_opened NAME                 just before a process opens it
  sta_dev_handback NAME SYSFS         authorized 0/1 toggle, only when this
                                      run opened it and SYSFS still names
                                      the recorded device; idempotent

sta_peer_record / _opened / _handback stay as thin wrappers, so the
mt7612u_sta_* harnesses are unchanged. sta_dev_unbind_wifi SYSFS unbinds
the kernel driver from every interface of a device that carries a
wireless netdev (rtw88, an out-of-tree rtl88x2*, mt76x2u; not a composite
adapter's Bluetooth interface) and refuses if one is left bound.

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

tests/mt7612u_sta_onair.sh becomes tests/sta_client_onair.sh: the DUT is
an MT7612U, an RTL8812CU (8822C) or an RTL8812BU (8822B) - the dies whose
station_mode_ok is true - and the AP any netns-movable in-kernel adapter,
e.g. the MT7612U on mt76x2u.

- DUT take / hand-back by die: the MT7612U keeps sta_dut_take; a Realtek
  DUT goes through the lib's sta_dev_record / sta_dev_opened /
  sta_dev_unbind_wifi / sta_dev_handback, with the same rule - never
  re-enumerated while sta_client is alive. sta_client gets the die by
  DEVOURER_VID / DEVOURER_PID from the DUT's sysfs identity.
- noarm is a real control on Realtek, where the arm writes the port
  registers and unarmed the MAC does not ACK own-addressed unicast: scored
  that the station tried (beacons seen, authentication sent) and the AP did
  NOT complete the four-way; a completed one FAILs. On MT7612U the arm
  writes nothing and the link stays information.
- The clear is scored as verified on Realtek, information on MT7612U.
- reconnect: hostapd stopped for DOWN_S and restarted; scored the lost
  link reported, a second four-way within REJOIN_S, ping 0% over a PING_S
  window, ledger 2 associations / 1 reconnect, exactly one arm across the
  re-join (the arm is per BSSID and stays in place), the clear.
  noreconnect: the same with DEVOURER_STA_RECONNECT=0 - lost link
  reported, no re-join, ledger ends Failed after 1 association.
- noarm and retry0 report their link over a PING_S ping window (2/s)
  instead of six pings.

sta_client logs each association ("station connected (association N)")
and each failure ("station link lost: <reason>" / "station join failed:
<reason>"), so a re-join is visible while the run lasts.
docs/station-client.md documents the cells, the per-die arm scoring and
the re-join policy, including DEVOURER_STA_RECONNECT=0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The ledger's first line read the state machine after the teardown's
leave(), which always returns it to Idle, so every run - including one that
gave up with DEVOURER_STA_RECONNECT=0 - reported state=Idle with no reason.
The end state (state, failure reason and status, AID, keyed) is now taken
under g_mu just before leave(), and report() prints that: Connected for a
run that ended associated, Failed reason=<why> for one that gave up.

test_reconnect_can_be_disabled now also checks that leave() returns to
Idle while the recorded end state is Failed, reason beacon-lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
- The lib clears .id_* / .opened_* when a run takes OUT's lock: a reused
  OUT no longer carries an earlier run's device records into this run's
  hand-back (realtek_station_onair.sh HALF=up never records `peer`).
- reconnect measures its re-join bound, and the printed time, from the
  moment hostapd is started again, not from ap_up returning.
- The Realtek noarm control has a positive control: unless the ARMED wpa2
  cell of the same run completed a four-way against the same hostapd
  configuration, a silent AP proves nothing and noarm is INCONCLUSIVE.
- wpa2 scores the two MIC counters for what they are: the four-way's
  (mic_failures=) must be 0, the data plane's (MIC failures=) may reach one
  per pairwise rekey.
- AP_OFDM_ONLY=1 (2.4 GHz) makes hostapd advertise and use OFDM rates only,
  so its authentication and association responses go out at 6 Mb/s instead
  of 1 Mb/s CCK - the first experiment on the 8822C association issue,
  which docs/station-client.md now records as a known issue.
- noreconnect's "ends Failed" check now holds against the client's end
  state; the doc says the ledger reports the state the run ended in.

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

On air an 8822C station intermittently received its Association Response
while the AP never saw it acknowledged: the AP never added the station,
dropped its traffic and sent no deauthentication, and on an open BSS the
station sat "connected" until the run ended. (On WPA2 the four-way timeout
already recovers it.)

An open association now counts as unconfirmed until a unicast data frame
from the AP arrives for the station. Once the host has asked something -
kConfirmUplink (3) unicast or ARP frames - and kConfirmMs (5 s) has passed
with no such reply, supervise() calls StationSm::link_lost(): the link is
lost as "unconfirmed" and the ordinary re-join policy (backoff,
DEVOURER_STA_RECONNECT) takes over. Multicast chatter and an idle host are
never judged. The ledger gains unconfirmed= and assoc_repeat=.

Headless: an unconfirmed association is lost and re-joined; a unicast reply
confirms for good; an idle host and multicast chatter are never judged;
unanswered ARP requests are.

tests/sta_client_onair.sh's open cell now runs a ping from the moment the
TAP is up, as a user's host would, and scores hostapd's AP-STA-CONNECTED for
our address within 30 s - covering one recovered association - before the
scored ping; a recovery is reported. docs/station-client.md documents the
rule and restates the 8822C known issue as intermittent, not CCK-only.

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

HOSTAPD_DEBUG=1 runs hostapd with -dd into the cell's hostapd log, for the
AP's view of an association (station add, TX status). Off by default; meant
for the open cell, since the extra lines can repeat the text the wpa2 cell
counts for its rekeys.

A cell that FAILs now saves the tail of dmesg to dmesg_<cell>.txt and echoes
its mt76 / rtw88 / cfg80211 lines - read-only, best effort - so an AP driver
error during station add shows next to the verdict.

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

An MT7612U AP (kernel mt76x2u) can hold a transmitted frame's TX status
until its next transmission - the same silicon behaviour
docs/mt7612u-tx-retry.md records for this tree's own MT7612U driver.
hostapd counts a station associated, and starts the WPA2 four-way, only
once the Association Response's status is in. hostapd's debug log of a
failing run: "association OK", the response sent, no TX status for six
seconds, then - when the station's next frame made the AP transmit - the
status with ack=1, too late ("handle_assoc_cb: STA ... not found"). On WPA2
every attempt reads "authenticated", then nothing until the station's
handshake timeout. The station had acknowledged the response; it was not
at fault.

The moment an Association Response is accepted - Associating to Connected
(open) or FourWay (WPA2) - sta_client queues one probe request (every AP
answers one), so the AP transmits and a held status is released. On WPA2 a
second one follows if no EAPOL has arrived kNudgeAgainMs (1 s) later, and
none after that. The safety nets stay: the confirmation rule (open) and the
four-way timeout (WPA2). The ledger counts the nudges.

Headless: open is nudged once and never again; WPA2 is nudged on the
accepted response, once more after 1 s without EAPOL, never a third time,
and not a second time when message 1 arrives at once. A nudge is exactly
one probe request: it does not retune, does not open the retune guard
(g_tuned / g_retune_ms) and does not move the scan sweep; the probe
response it provokes counts as AP liveness and leaves the BSS on its
channel.

docs/station-client.md: the nudge, and the AP quirk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
Plaintext data from our BSS on a WPA2 link was refused silently. It is now
counted (`plaintext refused` in the ledger's data-plane line), leaving out
the four-way's own cleartext EAPOL (recognised by the full SNAP header,
is_ethertype_snap + 0x888e) and the no-data subtypes (Null / QoS Null),
which carry nothing to refuse. The on-air harness uses the count as its
positive witness that unicast addressed to the station gets through the
MT7612U's managed receive filter.

The header comments say what the RX path now is on MT7612U: promiscuous
until the arm, the managed filter while armed, so StationSm::on_rx's
`not-for-us` count is that filter's witness.

Headless: an unprotected data frame on a protected link is counted as
refused; a 26-byte QoS Null is not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
With an MT7612U DUT, once the wpa2 / noarm four-way is in, a monitor vif on
the AP's phy injects two plaintext unicast data streams from the AP's
BSSID (INJECT_S at INJECT_PPS each): one to FOREIGN, an address nobody
holds, and one to the station's own address. The own stream is the
positive witness that the injection reaches the DUT: the station refuses
it on a WPA2 link and counts it (`plaintext refused`), and it must reach
half of what was injected, else the check is INCONCLUSIVE. Then armed
(wpa2) `not-for-us` must stay under 1% of the foreign stream; unarmed
(noarm, the monitor filter) at least half of it must arrive. A phy that
cannot add a monitor vif makes the filter check INCONCLUSIVE, not the cell.
On a Realtek DUT the injection is skipped and said as INFO.

The two streams share a transmitter address, so the own stream starts at
sequence 2048 (sta_unicast_inject.py gains an optional seq0, default 0;
existing callers unchanged). Each injector is bounded by `timeout -k`, its
PID recorded, and killed - and the monitor vif deleted - by the cleanup
on every exit path, before the AP phy leaves the namespace.

The clear is now scored as verified on both dies: on MT7612U it restores
the monitor filter and reads it back, so it is no longer trivially true.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
- StationIdentity.h: an arm after a DROP keeps the recorded pre-arm filter
  instead of recording the register. The drop's restore write may have
  missed and left the managed filter there; recording it made the clear
  "restore" a managed receiver and report it verified. Headless check in
  test_rx_filter_ownership.
- Mt7612uRadio::Stop(): the best-effort clear is guarded. Its own WARN goes
  through the same logger as everything else (log_trampoline), and an
  escape there - with `_dev` already nulled - skipped the stop and the
  close and leaked the handle, which is exactly what the comment said must
  not happen.
- mt7612uprobe staid: the two clears between cases are checked (the second
  one is the lost-arm clear that re-writes and verifies the filter) instead
  of their results being dropped.

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

The open-association confirmation judged "kConfirmUplink frames sent and
kConfirmMs since the ASSOCIATION": a host idle for longer than kConfirmMs
that then sent a burst of three unicast frames (a neighbour still cached
from a previous association needs no ARP first) was judged lost on the
next loop pass, before any reply could arrive, and re-joined a healthy AP.
The window now opens the first time supervise() sees a question, which is
what docs/station-client.md already said ("within 5 s"). Headless:
test_a_burst_after_idle_gets_its_window (fails against the old rule); the
existing cells open the window explicitly. The comment and the doc now
state the one-way-unicast limit as it is: judged until the host's stack
re-verifies the neighbour, not only with static entries.
on_association() goes back to taking no argument.

sta_client_onair.sh: a timed-out AP-netdev wait leaves the interface up,
as the same block in OpenIPC#466 does, so the two read the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
When the managed filter did not read back, the arm put the previous value
back with a bare unchecked write. If that missed too, the register was left
managed with nothing recorded: ClearStationIdentity reported "restored
(verified)" for a receiver still managed, and a retried arm (sta_client
retries) read the managed value back as the pre-arm one.

The undo is now verified. An undo that misses is recorded
(mt7612u_sta_strand: `stranded`, with the pre-arm value), so the clear still
restores it, a filter request is still recorded, and the next arm keeps the
recorded value instead of reading the register. Headless:
test_stranded_undo_keeps_the_pre_arm_filter.

docs/mt7612u-station-identity.md: says so, and no longer claims the RX loop
never writes the filter - StartRxLoop's mt7612u_set_monitor_rx() does,
mediated while armed. The Stop() comment no longer overstates what the
catch protects (mt7612u_stop sits outside it).

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

sta_client: the open-association confirmation counted any host unicast as
a question, so a one-way UDP uplink to a peer behind the AP (video,
telemetry - the FPV case) got nothing unicast back and was judged lost and
re-joined every few seconds, for as long as it ran. Only a frame whose
answer the AP must forward back now counts (solicits_reply): an ARP request
that is neither gratuitous nor a DAD probe, an ICMP / ICMPv6 echo request,
a unicast IPv6 neighbour solicitation, TCP, a DNS query. Headless:
test_a_one_way_udp_uplink_is_not_judged (and DNS still is), gratuitous and
probe ARPs not counted; the confirmation fixtures send real ICMP / TCP / ARP
requests. Both fail against the old predicate. The comment and
docs/station-client.md say what an unheld association under one-way traffic
costs instead.

probe() returns whether it built a frame, and a nudge is counted only then.

sta_client_onair.sh:
- the open cell's background ping is killed on the station-gone path too;
- an armed managed-filter PASS is held until the noarm control of the same
  run has seen the foreign stream arrive (the own stream witnesses the
  injection path, not the foreign injector), else INCONCLUSIVE.

mt7612u_sta_lib.sh: the one-run-per-OUT lock is an flock(1) on the OUT
directory. mkdir plus a separate PID write let a concurrent run read an
empty PID, reclaim the live lock and share OUT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
An overrun BSSID gate sets r_bss=2 and the exit code calls the run
INCONCLUSIVE, but the verdict `case` had only 0, 3 and *, so the operator
read "the BSSID gate did not pass". The probe-response gate had the same
gap: arm C never reported is r_ack=2, printed as "arm C did not hold".
Both now have a 2) branch that says INCONCLUSIVE.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
On a host whose MediaTek firmware is zstd-compressed (only
/lib/firmware/mediatek/*.bin.zst), sta_fw_link succeeded, the probe then
logged "cannot open firmware/mt7662_rom_patch.bin", and the uplink
harness scored "ABORTED the DUT did not confirm retry limit 15:" with an
empty reason (identity: "could not read the DUT's MAC", exit 1).

sta_fw_readable checks both blobs are present, readable and non-empty,
and names the .zst case when that is what it finds. sta_fw_link runs it
through the link it made (or found), so identity, autoack and uplink
refuse the rig (exit 2) in seconds, before the DUT is taken.
sta_client_onair runs it on FW_DIR for an MT7612U DUT.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
realtek_station_onair, sta_client_onair and mt7612u_sta_identity already
exited 3 on an interrupt; mt7612u_sta_autoack, mt7612u_sta_uplink and
mt7612u_ap_onair still exited 130. All six now follow the one
convention: 0 pass, 1 fail, 2 inconclusive, 3 interrupted. The autoack
and AP harness headers now state it; no caller in the tree reads 130.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
On the KILL path reap cleared KIDS without `wait`ing anything, so every
child of that cleanup - the KILLed one and the ones TERM had already
ended - stayed a zombie for the rest of the run. reap now gives a KILL
2 s to land (it waits for the process to leave the kernel), then waits
every child that has exited, on both paths. A child still running after
that is not waited on, since that wait could block. The return value is
unchanged: 1 when anything had to be KILLed.

Checked with a TERM-ignoring child beside two plain ones: rc 1, no
zombies left; a clean reap still returns 0.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The final tx.stats t can precede the last tx.report t by a few ms (-6 ms
in one review arm), and summarize printed that as a negative silence.
It was harmless to `live`, which only compares tail_ms against
MAX_GAP_MS, but it read as a clock fault. tail_ms is now clamped at 0,
and the summarize comment says why.

Checked on a fixture whose final tx.stats is 6 ms before the last
report: tail_ms=0, live=1.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The open-association confirmation rule could thrash a healthy link. A
question whose answer never comes - a host pinging or ARPing a peer
that is switched off, DNS with no upstream, TCP retransmissions or a RST
to a peer that has gone - failed the association as Unconfirmed, and
on_association re-armed the judge on every re-join, so the link went
down every ~5 s plus backoff and join, dropping host traffic each time.

- The verdict is now a bounded backstop: at most one per BSS until an
  association on that BSS is confirmed. After a verdict the re-joined
  association on the same BSS is not judged (g_strike); a unicast reply,
  or an association on a different BSS, lifts the strike. The cost is
  stated where the rule is: a second unheld association on the struck
  BSS is not found by this rule.
- A TCP segment is a question only as a SYN (SYN set, ACK clear). The old
  comment's "a bare ACK only follows data this station received" was
  false across a re-join.
- A question, a probe and a nudge count only once enqueue() accepted
  them; one the 128-deep queue dropped never reached the AP.
- g_unconfirmed is folded into g_judge.

Headless cells: fewer than three questions are not judged, a broadcast
from the AP does not confirm, what is and is not a question (TCP SYN vs
SYN-ACK/ACK/data/RST, ICMP echo vs reply/unreachable, non-first
fragment, DNS vs other UDP, unicast ICMPv6 echo/NS vs reply/multicast),
one strike per BSS and its lift, the strike is per BSS, a dropped frame
is not counted. The multicast-chatter cell now sends real IPv6 multicast
echo requests; the reply cell sends real echo requests and judges across
a full window; the idle-burst cell runs the loop while idle.

Checked by 21 mutations of sta_client.cpp on a scratch copy; all are
killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
Each station harness's cleanup set CLEANED=yes first, and the INT trap
was `cleanup; exit 3`. A second INT during cleanup's sleeps and kills
re-entered it, returned at once and exited, leaving the AP phy in the
netns, the netns itself, NetworkManager unmanaged and the DUT not
re-enumerated. cleanup now ignores INT/TERM as its first line in
sta_client_onair, realtek_station_onair, mt7612u_sta_identity,
mt7612u_sta_autoack and mt7612u_sta_uplink. Ignored rather than
deferred, so the children it starts (the sleep between the two
`authorized` writes) ignore them too.

mt7612u_ap_onair also runs cleanup between cells, so there the ignore
sits in the INT handler and the EXIT trap, and a between-cell cleanup
runs with INT/TERM ignored and the handler restored after it.

The same harness's reap dropped every pid from KIDS, a KILLed survivor
included. In CELLS=all, a between-cell cleanup that returned 1 led to
the EXIT trap's cleanup, whose reap saw nothing, returned 0, and
re-enumerated the AP under the still-running process. A child still
alive after its KILL now stays in KIDS, so every later reap returns 1
and no later cleanup touches the adapter.

Checked off-device: a cleanup of the same shape hit by INT, INT, TERM
runs to the end and exits 3; reap with a simulated unkillable child
returns 1 and keeps it, and a later reap returns 1 again.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The lib header said the adapters were "exclusive anyway". They are not:
the OUT lock keeps apart only runs that share an OUT, and an adapter is
held only while a devourer process has it open, which nothing does
between two gates. A second run with its own OUT found interface 0
unbound, took the DUT, and its hand-back toggled `authorized` under the
first run.

sta_dut_take now refuses when interface 0 has no driver bound (another
run, or a crashed one, holds it; the message says how to re-enumerate
it) or is bound to anything but mt76x2u (usbfs: a devourer process has
it open). The header now says what the lock does and does not cover.

A host that runs without mt76x2u loaded is now refused rather than
taken.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
Stop() cleared a still-armed station before closing, "because the chip
keeps its registers across a close" and a managed filter would reach
whoever opened the adapter next. It would not: every bring-up rewrites
the receive filter (the initvals, then mt_mac_start()), and so does
mt76, as docs/mt7612u-station-identity.md already said one sentence
later. As a second chance for a failed clear it buys nothing for the
same reason, so the hunk is dropped and Stop() is master's again; the
doc now says why it does not clear.

StartRxLoop logged "monitor RX" even when an armed station kept the
managed filter (the monitor request is recorded, not installed). It now
says which filter is in force.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The open cell reported every Unconfirmed verdict as INFO "recovered",
including one that hit an association hostapd had already logged as
AP-STA-CONNECTED - a false positive that cost the user a re-join, and
the on-air oracle for the confirmation rule.

hostapd logs AP-STA-CONNECTED once per association it holds, so at most
(associations - held) verdicts can be genuine. More than that is a FAIL
naming both counts; the genuine "the AP lost us" recovery stays INFO.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The last staid case checked that a clear after a responder dropped the
arm "verifies the monitor receive filter". The drop had already written
and verified that value, so the read-back held whether or not the clear
wrote anything. The register is now poisoned with the managed value
first, so only a clear that re-writes the pre-arm filter passes. On the
SKIP path the clear still runs, unchecked, as cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
sta_unicast_inject.py's seq0 and sta_client_onair's two streams at 0
and 2048 were justified as keeping duplicate detection from merging
them. The injected frames never set Retry, so 802.11 duplicate detection
should not merge them in the first place. The ranges stay - the
benched configuration used them - but both comments now call them a
precaution rather than a need.

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

- sta_client_onair checked FW_DIR alone, but the library's
  resolve_fw_dir falls back to /lib/firmware/mediatek and then
  ./firmware. The preflight now takes the first of those that holds both
  blobs, exactly as the library does, and requires that one to be
  readable; with none, the refusal names FW_DIR. Header and
  docs/station-client.md say so.
- sta_fw_readable said "only mt7662.bin.zst is there" even when an
  empty or unreadable .bin was there too. A missing .bin and an unusable
  one are now told apart, and a missing one reports whichever compressed
  copies sit beside it (.zst, .xz, .gz) as "compressed firmware".
- mt7612u_sta_uplink.sh gets the "Exit status:" header line the
  autoack and AP harnesses have, placed after its Env line.

Checked on fixtures under bash and dash: .zst-only and .xz-only refused
by name, an empty .bin (with or without a .zst beside it) refused as
unusable, a missing directory refused, a good directory and a symlink to
it pass, and so does this host's /lib/firmware/mediatek.

Refs OpenIPC#467.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
shellcheck 0.11 reports the new INT handler as never invoked (SC2329,
SC2317 before 0.10). It is reached through `trap on_int INT TERM`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
One strike per BSS could leave a held-but-dead association dead for
the life of the process. Against an MT7612U AP that holds the
association's TX status, the AP silently drops the station's data,
never deauthenticates it, and keeps beaconing. A second such
association on the struck BSS was never judged, and nothing else
ended it.

The strike is now a backoff. After n consecutive verdicts on a BSS
(g_strikes), the next association on it is judged only once
kConfirmMs * 2^n has passed since it was made: 10 s, 20 s, 40 s, 80 s,
then the 2-minute cap (kStrikeCapMs). Questions asked inside the
backoff are not counted, so the window opens at a question asked after
it. A unicast reply, or an association on a different BSS, resets the
count; a broadcast does not. A dead peer still costs at most one
re-join per backoff period, and a dropped association is still found.

Also:
- A static_assert pins 3 questions / 5 s / the 2-minute cap to what
  docs/station-client.md states.
- The association and verdict lines carry `at=`, the wall-clock time in
  hostapd -t's form, for the on-air harness to order them against the
  AP's log.

Headless cells:
- test_a_struck_bss_backs_off: the backoff values and cap; no
  judgement and no counting inside the first backoff; judged right
  after it; the second backoff twice as long; a reply resets it.
- test_a_group_frame_does_not_reset_the_backoff.
- test_the_strike_is_per_bss, reworked.
- test_a_truncated_tcp_header_is_not_read: the frame sits in an exact
  allocation, so a missing l4n bound reads past it under ASan.

29 mutations of sta_client.cpp on a scratch copy are all killed. Two of
them (the threshold and cap constants) are killed at compile time by
the static_assert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
snokvist and others added 5 commits October 7, 2026 20:54
- New headless cell test_the_stamps_are_wall_clock_microseconds. It
  captures stderr across an association and a verdict, and requires
  both lines to carry at=<sec>.<6 digits> within 5 s of time(NULL).
  CLOCK_MONOTONIC, millisecond precision, and dropping at= from either
  line are now killed.
- The verdict path no longer re-checks the BSS before counting a
  strike; on_association already resets the count for a new BSS.
- docs/station-client.md:
  - quotes the association and verdict lines with at=;
  - describes the open cell's ordering check;
  - adds two limits: the backoff remembers one BSS, so a station
    alternating between two BSSes of one ESS resets it each time (a
    verdict per cycle of the 5 s window, the re-join backoff and the
    handshake); and sta_dut_handback rebinds mt76x2u even when the run
    took the DUT unbound.

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

verdicts_scored's positive control took the LATEST CONNECTED in
[previous verdict, next association]. So the next association's
CONNECTED satisfied an earlier row, and that row's own early CONNECTED
was never compared. Flash's input - association 1 at 1005, association
2 at 1010, a verdict at 1015, CONNECTEDs at 1004.9 and 1009.9 - scored
INFO.

Each association now owns a window: its at= to its verdict, or to the
next association. Every own CONNECTED in hostapd's log is accounted
for:
- inside a verdict's window: that verdict FAILs;
- the first one inside a window without a verdict: that association's
  own, the positive control - counted once per association, so one
  CONNECTED cannot satisfy several rows;
- outside every window, or a second one in a window without a verdict:
  ignored if hostapd logged DISCONNECTED for us before the next
  association (an episode the re-join ended - the late release);
  otherwise it was stamped before an association's at=, the ordering
  premise fails on this rig, and the verdicts are INCONCLUSIVE.
Flash's input is INCONCLUSIVE. It is not a FAIL: once the rig stamps
first, a CONNECTED before an at= cannot be pinned to one association.

Also:
- BAD is tested before STAMP, so a malformed stamp on one row no longer
  hides a FAIL on another.
- Records shorter than two fields are skipped ($(NF-1) on a blank
  record is fatal in gawk).
- A missing parse result while the station log shows verdicts is
  INCONCLUSIVE.
- docs/station-client.md describes the windowed pairing.

24 fixtures, run under gawk:
- FAIL: held inside, multi (verdict 2 only), epoch 1 us, -dd double
  stamp, a FAIL beside a garbage stamp, blank hostapd lines.
- INFO: late release in order, two controls, and unheld/held/unheld.
- INCONCLUSIVE: Flash's shift, the held-early race, an early control,
  unheld/held-early/unheld, no control, missing/garbage stamps, a
  verdict before its association, a ledger mismatch, a missing ledger.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The at= cell redirected stderr without checking dup()/dup2(), and
required the stamp within 5 s of time(), which a loaded or sanitised
CI box can miss. The redirect and the restore are now checked. The
window is +/-60 s: the point is wall clock rather than monotonic,
which differ by about the epoch. The 6-digit check is unchanged, and
the four stamp mutations are still killed.

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

A window without a verdict and without a CONNECTED of its own could
claim the next association's early-stamped CONNECTED as its control.
That happens when association k-1 was never held and ended in a
deauthentication re-join, which leaves no verdict. Association k's
false-positive verdict then had an empty window and scored INFO.

New conservative rule: for each verdicted association, if the last
hostapd event for our address before its at= is a CONNECTED with no
DISCONNECTED after it, hostapd still held us when the association
began. That CONNECTED may be its own, stamped early, so the verdicts
are INCONCLUSIVE. The clean recovered path - late release, then
DISCONNECTED, then the new association's CONNECTED after its at= -
stays INFO.

Also:
- Our address is matched case-insensitively as the token after the
  event name, not as $NF (newer hostapd appends auth_alg=), and only
  on the cell's AP interface.
- An empty at= is a stamp error.
- The comment records two fail-safe quirks: mawk reading an empty
  hostapd log as station rows, and duplicated lines.

30 fixtures; gawk, mawk and busybox awk agree on every one. The three
hole shapes are INCONCLUSIVE. Also: an upper-case MAC with a trailing
auth_alg= FAILs; our MAC on another interface is ignored; an empty at=
is INCONCLUSIVE; every earlier fixture is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
The held-at-start rule takes the last hostapd event in log order before
an association's at=. If hostapd's wall clock steps back, a
DISCONNECTED later in the file can be stamped earlier than the
CONNECTED before it. The rule then reads "not held", and a false
positive scores INFO (fixture clock_back).

If hostapd's stamps for our address are not monotonic in file order,
the verdicts are now INCONCLUSIVE, with a message saying the AP clock
stepped. 31 fixtures; gawk, mawk and busybox awk agree on every one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
@snokvist
snokvist requested a review from josephnef October 7, 2026 20:10
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Extend station coverage, AP recovery, and MT7612U managed filtering

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

Grey Divider

AI Description

• Extend station on-air coverage to supported Realtek dies and AP restart scenarios.
• Give armed MT7612U stations a verified managed receive filter instead of promiscuous reception.
• Recover unconfirmed open associations without judging one-way uplinks or repeatedly re-joining
 healthy links.
Diagram

graph TD
  Host["Host TAP"] --> Client["Station client"] --> Sm["Station state machine"] --> Radio["Radio interface"] --> Ap["Hostapd AP"]
  Client --> Judge{"Open link confirmed?"} -->|"No, after backoff"| Sm
  Radio --> Filter["MT7612U RX filter"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Actively probe every open association
  • ➕ Could test association liveness without waiting for host traffic.
  • ➖ Adds traffic to idle and one-way links.
  • ➖ An unanswered probe is not reliable proof that the AP dropped the association.
2. Keep MT7612U reception in monitor mode
  • ➕ Avoids register ownership and restoration logic.
  • ➖ Leaves an armed station promiscuous and fails to provide the measured managed-filter behavior.

Recommendation: Keep the role-owned filter and caller-driven confirmation: they address separate hardware and open-association failures without imposing periodic traffic on one-way uplinks. Prioritize review of filter rollback and timestamp-based verdict scoring.

Files changed (29) +3205 / -299

Enhancement (3) +354 / -36
mt7612u.hSpecify checked station-filter clear results +16/-2

Specify checked station-filter clear results

• Documents managed-filter arming and restoration, and changes mt7612u_clear_station_identity to return a success or failure result.

src/mt7612u/include/mt7612u/mt7612u.h

regs.hName MT7612U managed receive-filter bits +24/-0

Name MT7612U managed receive-filter bits

• Adds receive-filter bit definitions and assembles the measured managed-station value from named drop bits.

src/mt7612u/regs.h

sta_client.cppConfirm open associations and nudge stalled APs +314/-34

Confirm open associations and nudge stalled APs

• Judges unanswered, queued host questions on open links after a five-second window, with per-BSS exponential backoff; a unicast AP frame confirms the link. Nudges the AP after accepted association responses, records refusal diagnostics, and preserves final state before teardown.

tests/sta_client.cpp

Bug fix (11) +469 / -179
Mt7612uRadio.cppExpose and report managed-filter operation +14/-6

Expose and report managed-filter operation

• Reports the filter active at RX start and logs a successful station arm. ClearStationIdentity now reports whether restoration read back successfully.

src/mt7612u/Mt7612uRadio.cpp

StationIdentity.hTrack station receive-filter ownership +53/-1

Track station receive-filter ownership

• Adds saved pre-arm filter and stranded-undo state. Provides policy helpers that retain the managed filter under an arm and record later monitor requests for restoration.

src/mt7612u/StationIdentity.h

init.cppPreserve armed filters across RX-loop setup +17/-6

Preserve armed filters across RX-loop setup

• Names and pins the measured managed filter value. Routes monitor-filter requests through station ownership policy so an RX start does not replace an armed filter.

src/mt7612u/init.cpp

station.cppVerify managed-filter arm, drop, and clear +90/-18

Verify managed-filter arm, drop, and clear

• Installs the managed filter after successful station checks and verifies writes by reading back. Restores the saved filter on clear or identity drop, retains failed-restore state for retry, and reinstates managed reception when a dropped arm returns.

src/mt7612u/station.cpp

StationSm.hFail an externally detected lost association +23/-5

Fail an externally detected lost association

• Adds link_lost to fail a Connected association as Unconfirmed through the existing failure path. Counts Association Responses received outside the associating state.

src/sta/StationSm.h

mt7612u_ap_onair.shReap terminated AP-test children safely +40/-12

Reap terminated AP-test children safely

• Waits briefly after KILL, reaps exited children, and retains any still-running child so later cleanup cannot re-enumerate its adapter. Protects cleanup from repeated signals and uses exit status 3 for interruption.

tests/mt7612u_ap_onair.sh

mt7612u_sta_autoack.shPreflight firmware and protect auto-ACK cleanup +8/-2

Preflight firmware and protect auto-ACK cleanup

• Refuses unreadable firmware before taking the DUT. Makes cleanup resistant to repeated INT/TERM and uses exit status 3 for interruption.

tests/mt7612u_sta_autoack.sh

mt7612u_sta_identity.shReport unmeasured identity gates as inconclusive +8/-2

Report unmeasured identity gates as inconclusive

• Adds explicit inconclusive messages for probe-response and BSSID gates. Preflights firmware and ignores repeated signals during hand-back.

tests/mt7612u_sta_identity.sh

mt7612u_sta_lib.shCentralize safe station-harness device handling +191/-81

Centralize safe station-harness device handling

• Replaces the racy OUT PID lock with flock, adds generic libusb device record and hand-back helpers, and refuses MT7612U DUTs with live USB holders. Adds firmware readability checks and waits for mt76x2u on DUT hand-back.

tests/mt7612u_sta_lib.sh

mt7612u_sta_uplink.shPreflight uplink firmware and standardize interruption +8/-2

Preflight uplink firmware and standardize interruption

• Checks firmware before taking the DUT, prevents repeated signals from abandoning cleanup, and changes interrupted exit status to 3.

tests/mt7612u_sta_uplink.sh

realtek_station_onair.shShare Realtek device hand-back helpers +17/-44

Share Realtek device hand-back helpers

• Replaces local device tracking with the shared station-harness helpers and protects cleanup against repeated signals. Clamps a slightly negative report-tail interval to zero.

tests/realtek_station_onair.sh

Tests (6) +2122 / -27
bringup.cppGate station filter transitions on hardware +82/-17

Gate station filter transitions on hardware

• Extends the staid hardware gate to check monitor, managed, refusal, drop, and clear states. Poisons and verifies the register before testing clear-after-drop so that check requires a restoring write.

src/mt7612u/tools/bringup.cpp

mt7612u_station_selftest.cppTest managed-filter bits and ownership policy +109/-4

Test managed-filter bits and ownership policy

• Covers the measured filter bit mask, re-arm and RX-restart requests, drop and restoration behavior, and stranded failed-undo state.

tests/mt7612u_station_selftest.cpp

sta_client_onair.shRun cross-die station and AP-restart cells +1135/-0

Run cross-die station and AP-restart cells

• Introduces the generic MT7612U/Realtek on-air harness with open, WPA2, no-arm, retry-limit, and AP-restart cells. Scores die-specific arm behavior, verifies managed filtering with paired injection controls, and checks open-link verdicts against ordered hostapd events.

tests/sta_client_onair.sh

sta_client_selftest.incExercise confirmation, backoff, and nudge behavior +745/-3

Exercise confirmation, backoff, and nudge behavior

• Adds headless tests for question classification, unicast confirmation, one-way traffic, backoff, dropped-queue accounting, and wall-clock stamps. Tests probe nudges and the final-state ledger snapshot.

tests/sta_client_selftest.inc

sta_unicast_inject.pyAllow disjoint injection sequence ranges +16/-3

Allow disjoint injection sequence ranges

• Adds an optional validated starting sequence number so own-address and foreign-address on-air streams can use separate ranges.

tests/sta_unicast_inject.py

station_sm_selftest.cppTest lost-link failure and repeated responses +35/-0

Test lost-link failure and repeated responses

• Checks that link_lost is inert before joining, fails a Connected association as Unconfirmed, and clears its AID. Verifies that a repeated Association Response increments its counter without changing the connection.

tests/station_sm_selftest.cpp

Documentation (9) +260 / -57
CMakeLists.txtPoint build guidance to the renamed harness +1/-1

Point build guidance to the renamed harness

• Changes the sta_client target comment to name the generic on-air script.

CMakeLists.txt

mt7612u-station-identity.mdDocument managed-filter ownership and behavior +87/-14

Document managed-filter ownership and behavior

• Explains the filter bits, arm and clear restoration, and on-air filter witness. Corrects the earlier interpretation of the PROMISC bit.

docs/mt7612u-station-identity.md

station-client.mdDocument recovery policy and cross-die station tests +138/-24

Document recovery policy and cross-die station tests

• Describes open-link confirmation, per-BSS backoff, association nudges, final ledger state, and expanded on-air cells. Records the MT7612U AP TX-status quirk and its limitations.

docs/station-client.md

AdapterCaps.hClarify MT7612U station capability evidence +4/-9

Clarify MT7612U station capability evidence

• Updates capability comments to reflect that a successful arm installs the managed receive filter but writes no identity register.

src/AdapterCaps.h

IRadio.hClarify receive-filter ordering contract +3/-2

Clarify receive-filter ordering contract

• Notes that an MT7612U RX-loop restart preserves an armed station's filter.

src/IRadio.h

CLAUDE.mdRecord station filter ownership rule +10/-1

Record station filter ownership rule

• Documents that station arming owns the receive filter and monitor requests must be mediated while armed.

src/mt7612u/CLAUDE.md

Mt7612uRadio.hSpecify order-independent MT7612U filter arming +10/-6

Specify order-independent MT7612U filter arming

• Updates the backend contract to explain how RX restarts retain an armed managed filter and clearing restores the monitor request.

src/mt7612u/Mt7612uRadio.h

beacon.cppExplain filter restoration on identity handoff +6/-0

Explain filter restoration on identity handoff

• Documents why a beacon start that drops the station arm restores its prior receive filter.

src/mt7612u/beacon.cpp

CLAUDE.mdIndex station liveness coverage +1/-0

Index station liveness coverage

• Adds link_lost and repeated Association Response counting to the state-machine test map.

src/sta/CLAUDE.md

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

qodo-free-for-open-source-projects Bot commented Oct 7, 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. Stuck AP processes outlive cleanup ✓ Resolved
Description
run_reconnect() ignores a failed sta_pid_kill hostapd call, although that helper deletes the
recorded PID before returning failure when hostapd remains alive after ten seconds. If hostapd stays
running, the harness attempts to start a replacement and later moves the AP interface during cleanup
without a PID record for stopping the original process.
Code

tests/sta_client_onair.sh[1037]

+  sta_pid_kill hostapd
Evidence
The new reconnect path proceeds after an unchecked stop. The shared helper can return failure after
deleting the PID record, while cleanup relies on that record before moving the AP interface. Thus a
live hostapd can remain during hand-back, and the cell has not established the required stopped-AP
interval.

Clean up on-air harness processes and waits
Add an on-air reconnect cell
tests/sta_client_onair.sh[1034-1042]
tests/mt7612u_sta_lib.sh[210-229]
tests/sta_client_onair.sh[291-300]

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 reconnect cell continues after AP termination fails, and the termination helper has already discarded the PID needed for cleanup.
## Fix Focus Areas
- tests/sta_client_onair.sh[1034-1042]
- tests/mt7612u_sta_lib.sh[210-229]
- tests/sta_client_onair.sh[291-300]
## Recommended Fix
Require confirmed hostapd exit before starting the replacement AP. Retain its PID on termination failure so cleanup can retry or escalate; if it remains alive, report the cell as inconclusive and do not move the AP interface under that process.

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



Remediation recommended

2. Open links can be falsely confirmed ✓ Resolved
Description
rx_frame() clears g_judge and resets the backoff for any unprotected frame addressed to the
station, including a QoS Null frame with no data. If the AP sends a QoS Null before answering the
host’s questions, the client stops checking that association for an unanswered unicast reply.
Code

tests/sta_client.cpp[R628-630]

+    if (to_us) {                        /* the AP holds this association */
+      g_judge = false;
+      g_strikes = 0;                    /* ...so its BSS backs off no more */
Evidence
is_qos_data() accepts QoS Null (frame-control byte 0xc8), so it passes the receive path’s
data-frame check. The state machine recognizes that subtype as carrying no data, but the new
open-link block checks only the destination address before treating it as confirmation.

src/sta/Dot11.h[774-774]
src/sta/StationSm.h[372-378]
tests/sta_client.cpp[554-556]
tests/sta_client.cpp[616-634]

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

## Issue description
An open association can be marked confirmed by an AP QoS Null frame, which contains no reply data.
## Fix Focus Areas
- tests/sta_client.cpp[616-631]
- src/sta/Dot11.h[774-774]
## Recommended Fix
Require a data-bearing frame before clearing `g_judge` or resetting `g_strikes`. Add an open-link test that delivers a unicast QoS Null and verifies that confirmation remains pending.

ⓘ 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 keep summaries lean with Findings visible per group, which tucks the rest behind a View link

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread tests/sta_client_onair.sh Outdated
Comment thread tests/sta_client.cpp Outdated
snokvist and others added 3 commits October 7, 2026 22:21
…ociation

Qodo on OpenIPC#471: rx_frame() cleared g_judge and reset the backoff on any
unprotected frame addressed to us. A QoS Null (fc0 0xc8) does reach that
path: is_qos_data() is (fc0 & 0x8c) == 0x88 (src/sta/Dot11.h:774),
which admits the no-data subtype, and the gate at tests/sta_client.cpp
:571 passes it. An AP sends a QoS Null for power-save or keepalive
probing whether or not it forwards our traffic, so an AP that drops our
data could still "confirm" the association. A plain Null (0x48) never
got there: it is neither kFcData nor QoS data.

The lift now needs a data-bearing frame: no-data subtype bit (0x40)
clear, and a body. New cell test_a_null_frame_does_not_confirm covers a
Null, a QoS Null, and a QoS Null with trailing bytes - each must leave
the association to be judged Unconfirmed. Three mutations are killed:
the to_us test dropped, null frames confirming, and the subtype check
dropped. 35 of 35 in the set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
…tarts or moves under it

Qodo on OpenIPC#471: run_reconnect ignored a failed `sta_pid_kill hostapd`, and
sta_pid_kill deleted the PID record before returning 1 for a process
still alive after 10 s. So a stuck hostapd survived untracked, a
replacement was started on the same interface, and cleanup later moved
the AP phy and netns out from under the original.

- sta_pid_kill keeps the record when the process survives. Every caller
  was checked; a kept record only means a later call (cleanup) tries
  again, which every caller tolerates. New sta_pid_kill_hard escalates
  to KILL, and new sta_pid_live asks whether a recorded process still
  runs.
- sta_client_onair:
  - every hostapd stop is sta_pid_kill_hard;
  - ap_up refuses (the cell INCONCLUSIVE, the reason in the cell's
    hostapd log) while a recorded hostapd lives, so no replacement ever
    starts beside it;
  - run_reconnect scores INCONCLUSIVE and does not restart the AP when
    the old hostapd outlived TERM and KILL;
  - cleanup leaves the AP phy in the netns and the netns in place when
    hostapd outlived both, and prints the recovery commands - the same
    survivor rule as mt7612u_ap_onair's reap.
- mt7612u_sta_identity and realtek_station_onair escalate the same way
  and do not re-enumerate the AP under a surviving hostapd.
- The autoack/uplink trap comments no longer say sta_pid_kill forgets a
  PID.

Checked off-device:
- A TERM-ignoring stand-in: sta_pid_kill returns 1, the record is kept,
  ap_up refuses; sta_pid_kill_hard then ends it and drops the record.
- A simulated KILL-resistant one: sta_pid_kill_hard returns 1, the
  record is kept, ap_up refuses.

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

- test_a_null_frame_does_not_confirm gains a Data (0x08) and a QoS Data
  (0x88) frame addressed to us with an empty body. Neither may confirm.
  Without them, dropping the `len > hlen` half of the lift survived; the
  mutation is now killed.
- sta_pid_alive returns "not live" for an empty or non-numeric PID.
  Before, an empty record read /proc//stat, which is /proc/stat, and
  counted as running. Checked under dash and bash.

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

snokvist commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef - Qodo round addressed (407ea6d, c54226e, af19b9c): a (QoS) Null no longer confirms an open association, and a hostapd that outlives its kill keeps its PID record, is never replaced or moved under, and scores INCONCLUSIVE. ctest 83/83 normal and ASan+UBSan, 35/35 mutations killed. Re-benched on air; the description is updated with the numbers, including one intermittent on the MT7612U-station row that this diff does not cause (listed as a follow-up).

🤖 Generated with Claude Code

@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 af19b9c on this bench (ch6). The library delta is sound; one harness bug and the doc fixes are what I am asking for.

Library. The arm owns the receive filter with a read-back on every write, the clear restores and verifies (and keeps the arm recorded on a miss so a retry works), the stranded state covers an undo that did not read back, mt7612u_sta_rx_filter_request records a monitor request while armed/lost/stranded and installs the managed filter only while armed, and the drop/restore in mt7612u_station_identity_check writes the filter outside the io-error accumulator. StationSm::link_lost is Connected-only and goes through the ordinary fail(); every Failure switch site carries the new enumerator. ctest 83/83, no new warnings.

Hardware (tests/sta_client_onair.sh, all cells):

  • RTL8812CU station (3-2.4) vs MT7612U AP (4-2.3.2, mt76x2u): 28/28.
  • MT7612U station (4-2.3.2) vs RTL8812BU AP (4-2.3.3, rtw88, modprobed explicitly - this host blacklists rtw88 autoload): run 1 28 pass, 1 FAIL; a wpa2 noarm repeat 11/11.
    • The FAIL is wpa2: ledger associations=2. hostapd's Group Key Handshake 1/2 went unanswered four times (TX status ack=1, ack=1, then ack=0) in the seconds the injection monitor vif was being torn down, the AP deauthenticated, the station re-joined after a handshake-timeout. That is the intermittent your description reports on this row, with a different signature (rekey not answered, not beacon loss). Worth looking at whether INJECT_S overlapping REKEY_S is what provokes it, and either not overlapping them or scoring the case.
    • The managed-filter witness passed both runs: own-addressed 775/863 and 875/875 arrived, foreign not-for-us 0/642 and 0/713, with the noarm control seeing the foreign stream.

Requested:

  1. The Realtek DUT take path has no live-holder gate (inline).
  2. docs/mt7612u-station-identity.md still says the managed filter is TBD on air, and carries history/issue refs in prose (inline).

Nit inline: proc_running duplicates sta_pid_alive without the empty-PID fix. Two wording notes, no change needed: the description says "3 questions within 5 s of the first" while the code is >= 3 questions AND >= 5 s since the first (the doc matches the code); and the nudge is called a "directed probe request" but build_probe_req addresses it to broadcast with the SSID element, so it is SSID-specific rather than directed.

🤖 Generated with Claude Code

Comment thread tests/sta_client_onair.sh
Comment thread tests/sta_client_onair.sh Outdated
Comment thread docs/mt7612u-station-identity.md Outdated
Comment thread docs/mt7612u-station-identity.md Outdated
Comment thread docs/mt7612u-station-identity.md Outdated
snokvist and others added 8 commits October 8, 2026 15:49
The Realtek take in sta_client_onair.sh (and every sta_dev_record user:
realtek_station_onair.sh's DUT and peer, the MT7612U harnesses' peer) had
no live-holder gate. sta_dev_unbind_wifi skips an interface with no
wireless netdev, which is exactly what a libusb-claimed interface looks
like, so a DUT another devourer process held passed, and cleanup's
sta_dev_handback then toggled `authorized` under that live process.

sta_usb_unheld refuses an adapter with an interface bound to usbfs or a
process holding its /dev/bus/usb node. sta_dut_take and sta_dev_record
both call it. sta_client_onair.sh marks the Realtek DUT opened only once
its unbind has succeeded.

sta_usb_holder reads `ls -l /proc/*/fd` instead of `find -lname`: busybox
find has no -lname, and the swallowed error read as "nothing holds it".
GNU find -samefile is no substitute, because it holds the node open itself.

The AP paths need no gate: every AP guard requires a wireless netdev on
the adapter, which a usbfs-claimed interface does not carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
sta_client_onair.sh and realtek_station_onair.sh each carried a
proc_running that duplicated sta_pid_alive without its empty-PID guard:
`proc_running ""` reads /proc//stat, which is /proc/stat, and returns
true.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
A bench run of the wpa2 cell failed `ledger associations=2`. hostapd's Group
Key Handshake 1/2 went unanswered four times while the injection's monitor
vif was being torn down; the AP then deauthenticated and the station
re-joined. With the defaults the overlap was structural. The group timer
(20 s) starts with the AP. The injection began after the four-way and a
6 s ping, and ran 10 s, so it ended within a second of the first group
rekey.

The injection now runs straight after the four-way, before the ping, and
only when hostapd's own -t stamps show it ends, with 5 s of margin for the
vif and the early group timer, before both first rekeys: REKEY_S after
AP-ENABLED and PTK_REKEY_S after the station's last four-way. Otherwise
it is skipped and the filter check is INCONCLUSIVE, naming the knobs.
The REKEY_S default moves from 20 to 30 s so that the defaults leave room.
The cell still waits for both rekeys and pings after them.

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

The witness section said "On-air numbers: TBD" and listed the managed
filter under a live association as not established. It is measured. An
MT7612U station against an RTL8812BU AP on two benches gave own-addressed
882/882, 943/943, 775/863 and 875/875 arrived, and foreign not-for-us
0/761, 0/927, 0/642 and 0/713. In every run the noarm control saw the
foreign stream. The numbers go into a table, and the TBD bullet goes.

Two passages narrated history: a retraction ("an earlier version of this
page said") and "used to run promiscuous ... (issue OpenIPC#461)". Both now state
the current behaviour: the PROMISC bit's decode, and why the armed station
runs the managed filter. The provenance stays in git.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3
build_probe_req addresses the probe to broadcast and carries the SSID
element, so it is SSID-specific, not directed. The comments, the docs and
one check message use that term. Behaviour is unchanged.

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

Marking the DUT opened only after sta_dev_unbind_wifi succeeded left a
driverless adapter in two cases: an unbind cut short by Ctrl-C during its
sleep, and an adapter with two netdevs that frees one and then fails. In
both, .opened_dut was absent, so the hand-back skipped the `authorized`
toggle. The mark goes back before the unbind. sta_dev_record now refuses a
held adapter, so the toggle cannot land under a live process, and a toggle
on an adapter its kernel driver still holds is harmless.

The injection guard said the injection "ends at least 5 s before" the
rekeys. What the code checks is now + INJECT_S + margin < the first
rekey, and that margin must also absorb the vif add/delete and the
injectors' start. The injectors are now killed at INJECT_S + 2 s (KILL 1 s
later) instead of + 10 / -k 5. The margin rises to 8 s, so it covers that
bound, the vif, and a remainder before the rekey. The comments and the doc
say exactly that.

The identity doc's measurement table names each row's head, and notes
that every row ran the earlier after-the-ping schedule with REKEY_S=20.

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

The table gains three bench-A rows from 42fab15 and 232a631. In these
runs the injection starts straight after the four-way, clear of the
rekeys, with REKEY_S=30. Own-addressed 833/833, 751/751 and 746/746
arrived; foreign not-for-us was 0/721, 0/683 and 0/684. The noarm control
saw the foreign stream each time (own 746/746, 944/944, 821/821).

A schedule column and a noarm column are added. The note says which heads
ran which schedule.

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

The managed-filter table had the 42fab15 row and the second 232a631 row
swapped. Each row now matches its own log. The table also cites 0fa46cc's
held PASS (943/943, 0/609) in place of the pre-rebase 4335ee4. It fills in
the noarm counts that were recorded: 0fa46cc 724/740, c54226e 728/739.
Bench B's single control figure (612/706) covered both of its runs, so it
now sits in the note, not on one row.

The unarmed control counts 9-31x as many foreign frames as were injected,
while the own stream arrives 1:1. The AP very likely retransmits frames
nobody acknowledges. The doc says so. The injector's docstring and the
harness comment had claimed the frames never set Retry and that mac80211
injects them no-ack; both claims now stay within what the counts allow.

rekey_clear returns its own codes for an unreadable log (2) and for a
missing AP-ENABLED or four-way stamp (3). The skip names that case, not
"would not end before the first rekey". The log is checked in the shell,
because busybox awk exits 1 on a missing file. REKEY_S and PTK_REKEY_S
must be at least 1: 0 switches hostapd's rekey off.

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

snokvist commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef - thanks for benching it. All addressed in 26db041..ddec632:

  • Realtek take: a live-holder gate, sta_usb_unheld, now runs on every DUT and peer take through the lib. It also fixes sta_usb_holder failing open under busybox find. We kept the opened mark before the unbind; the reason is in the inline reply.
  • Identity doc: on-air numbers replace the TBD, in a table naming each row's head and injection schedule. The history wording is gone.
  • proc_running: replaced by sta_pid_alive in both scripts.
  • Wording: the description now says ≥3 questions and ≥5 s since the first, and "SSID-specific" everywhere the PR touches.

On your wpa2 FAIL: with the defaults, the injection overlapped the first group rekey every time. The group timer starts with the AP at 20 s, and the injection ran from about t0+10 to t0+21 s, so its vif teardown, and its load on the AP's air, fell on that rekey. Our own c54226e log has the same timing; that time the rekey was answered. The fix:

  • The injection now runs straight after the four-way.
  • It starts only when now + INJECT_S + 8 s is still before both first rekeys, read off hostapd's own stamps. The injectors are killed at INJECT_S + 2 s, so the 8 s cover them, the vif add and delete, and a margin.
  • If that doesn't hold, or the stamps can't be read, the injection is skipped and the filter check is INCONCLUSIVE with a message saying which.
  • REKEY_S now defaults to 30 s, and 0 is refused. The cell still waits for both rekeys and pings after them.

With this schedule, wpa2 passed in every run here. On 42fab15 the full set passed: MT7612U station 29/29, both Realtek stations 28/28, staid clean, realtek_station_onair 5/5 on each die. On 232a631 the MT7612U station passed 29/29 twice and the 8812BU station 28/28; ddec632 and on ddec632 the MT7612U station passed 29/29 again (managed filter: own 764/764, foreign 0/739).

Our earlier c54226e wpa2 FAIL is a different signature: beacon loss 2 s after association 1, before any injection. That is the intermittent already in the description. A wpa2 noarm repeat on your bench would be welcome, to confirm the rekey FAIL is gone there too.

🤖 Generated with Claude Code

@snokvist
snokvist requested a review from josephnef October 8, 2026 15:52

@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 ddec632. Everything I asked for is in: sta_usb_unheld on every DUT and peer take (and the busybox-safe holder scan), sta_pid_alive everywhere, the on-air table in the identity doc, no history wording left in the added text, and the injection fitted before the first rekey. ctest 83/83. The rekey FAIL is gone here: two further runs of the MT7612U-station row both read one association, three rekeys answered.

But the reordering brings a new FAIL on this bench, repeatable 2 for 2 (one full row A, one wpa2 noarm repeat):

FAIL  wpa2: four-way completed, ping 6 packets transmitted, 5 received, +1 errors, 16.6667% packet loss

ping_wpa2.txt: seq 1 "Destination Host Unreachable", seq 2 at 464 ms, seq 3-6 at 5-17 ms. The ping now starts about 9 s after the own-stream injector finished. The noarm cell's 60-ping window in the same runs also shows +1 errors (58/60). The managed-filter check itself passes both times (own 938/938 and 835/835, foreign 0/576 and 0/708), the ping after the rekeys is 0% loss, and the earlier ordering (ping before injection) had 0% loss on this row every time.

Reading: the AP is still retransmitting the unacknowledged foreign stream when the ping starts - your own sta_unicast_inject.py note says the unarmed station sees 10-30x the injected foreign frames (6030 for 770 here), and the AP (rtw88 8812BU) is on a single hardware queue - so the ARP reply and first echo sit behind those retries. A hypothesis, from timing only; I did not capture the AP's queue.

Ask: make the post-injection ping independent of the AP's retry backlog - either a settle before ping_ap that is tied to the foreign stream (the injector's frame count times the AP's retry limit at the lowest rate, or a poll until the station's not-for-us counter stops moving), or keep the injection before the rekeys but after the first ping with REKEY_S raised to fit (rekey_clear already refuses when it does not). Inline at the reordering.

🤖 Generated with Claude Code

Comment thread tests/sta_client_onair.sh Outdated
snokvist and others added 2 commits October 8, 2026 20:03
Injected before the ping, the managed-filter stimulus cost the wpa2 cell
its first echo on a reviewer's bench, 2 of 2 runs: seq 1 "Destination
Host Unreachable", seq 2 at 464 ms. The noarm window lost one of 60. The
rtw88 AP keeps retransmitting the unacknowledged foreign stream (6030
seen unarmed for 770 injected), and the ARP and the first echo sit behind
that backlog. Ping-then-inject was clean in every run.

So the injection runs after the ping again, in wpa2 and noarm alike. It is
still gated by rekey_clear. The defaults are sized for the longer case,
noarm: the four-way ~2 s after AP-ENABLED, a warm-up ping of up to 3 s, a
30 s PING_S window, then INJECT_S + 8 = 18 s. That is about 53 s from
AP-ENABLED and 51 s from the four-way. REKEY_S=90 and PTK_REKEY_S=80 leave
about 30 s for a slower join or host. The only ping after an injection is
wpa2's ping after the rekeys, about a minute later.

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

snokvist commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

@josephnef - moved the injection back after the ping in a66f659, for both wpa2 and noarm. The REKEY_S/PTK_REKEY_S defaults go to 90/80 so rekey_clear still passes. The longer case is noarm: four-way ~2 s after AP-ENABLED, a ≤3 s warm-up ping, the 30 s window, then 18 s for the injection, ending at about +53 s. That leaves ~30 s for a slower join or host before the first rekey. The only ping after an injection is now wpa2's post-rekey ping, about a minute later.

On our bench, two runs of the MT7612U-station row on a66f659 read wpa2 ping 6/6 and noarm 60/60 both times, with managed filter own 893/893 and 942/942, foreign 0/796 and 0/789. fba6700 adds those runs and ddec632's to the identity table (docs only). Thanks for confirming the rekey FAIL is gone; a re-run of wpa2 noarm on your bench would close this one out.

🤖 Generated with Claude Code

@snokvist
snokvist requested a review from josephnef October 8, 2026 18:19

@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 fba6700. The injection runs after the cell's ping again, for wpa2 and noarm, with the rekey defaults at 90/80 so rekey_clear still fits it before the first rekey, and the station's run budget scales with them. The post-injection ping FAIL is gone on this bench.

Hardware on fba6700, MT7612U station (4-2.3.2) vs RTL8812BU AP (4-2.3.3, rtw88), ch6:

  • all cells: 29/29. wpa2 ping 6/6 before and after the rekeys, one association, three rekeys answered; noarm 60/60; managed filter own 907/907, foreign 0/644 with the noarm control seeing the foreign stream.
  • wpa2 noarm repeat: 11/11, ping 6/6 and 60/60, managed filter own 885/885, foreign 0/859.

ctest 83/83. Earlier on this bench: RTL8812CU station vs MT7612U AP 28/28 (ddec632's harness, library unchanged since).

🤖 Generated with Claude Code

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