Skip to content

Commit 47151d0

Browse files
snokvistclaudejosephnef
authored
mt7612u: no TX transfer timeout - a full ring refuses, it never cancels (#470)
@josephnef - the TX half of the #461 / #467 follow-ups, ready for review. Addresses the "frames lost to the TX transfer timeout" finding of #461, and closes three of the #467 follow-ups that touch the `txs` gate. Based on master after #466 (`1134df9`). #461 is the motivation, and it now has a second data point: on the maintainer's second unit, unicast-to-peer gate arms (b-e) landed only 0-11 of 60 own status entries per arm, with ~10 per arm carrying the previous arm's pktid. The maintainer's #467 review reports master reading the same on that unit. The same pattern reproduces on master on the author's bench, and it is gone on this branch (see "What is measured"). This PR removes the loss that the gate and the ring caused themselves. ### What changed Library (`3d9aecb`, review fixes in `65c999a`, `989beef`, `11329ab` and `3863e76`): - **TX bulk transfers carry no timeout**, as mt76's TX URBs carry none. The async ring gave each one 1000 ms. A chip that NAKs bulk OUT for longer than that (its queue full behind slow frames, e.g. unacknowledged unicast at a deep retry limit) had libusb cancel frames that the chip would have taken a moment later. Those frames had already been reported as sent. - **The slot wait is bounded instead.** `mt_async_tx_submit` waits at most 1000 ms for a free slot. A ring that is still full at that point refuses the submit (-1), which every caller already counts as failed. `mt7612u_send_packets` stops at the first refusal, so it does not wait out the bound once for every remaining batch. - **No transfer is submitted after a stop's cancel pass.** `mt_async_stop` sets `stopping` (and clears `rx_active`) under the ring lock, wakes slot waiters, and then cancels. A TX submit happens under the lock and only while `stopping` is clear; the RX resubmit in `rx_done` is likewise decided and submitted in one step under the lock. Before this, a sender parked in the slot wait could take a slot freed by the cancel pass and submit after it. With no timeout, nothing would ever cancel that transfer: on a NAKing chip it outlasts the stop's 2 s wait and strands the device. This does NOT make a send safe against a concurrent stop that frees the ring - see "What it can't do". - **`mt7612u_tx_wire_failed()`** (public C API, `api_link.c` updated) counts frames whose transfer was submitted and then failed, was cancelled, or was stranded by a stop. The count is per device (it survives a ring restart), monotonic, lock-free to read, and in frames (an aggregated transfer adds all of its frames). When a stop strands a slot, it counts that slot's frames under the ring lock and then clears the slot's device pointer. A late completion, reaped by another thread pumping a shared libusb context, then cannot touch the device that `mt7612u_close()` frees. That covers RX too: a stranded stop clears the RX slots' device pointer as well, and `rx_done` parses a completed transfer under the lock it is cleared under, so a late bulk-IN completion neither reads a freed device nor resubmits. The parser therefore takes no lock and counts nothing: it reports an invalid PHY by return code and `rx_done` counts it under the lock it holds (an earlier revision counted from inside the parser and self-deadlocked on one corrupt RXWI). Only submitted slots go into the count: a slot reserved but not yet submitted when the stop's wait expires is refused by its submitter, so (as the header says) it is not in it. The decision to strand still counts that slot, on purpose - it is what keeps the stop from freeing the ring under a submitter between its reserve and its submit - so such a slot can strand the device although libusb never owned it. The count is an upper bound: a cancelled or short aggregate counts all of its frames, and a stranded transfer is never corrected if it completes later. - Header docs for `mt7612u_tx` / `mt7612u_send_packets` now state the bounded wait, the refusal, and the cost: as in mt76, nothing reclaims a slot whose transfer the host controller never completes, so a fully wedged ring keeps refusing until it is stopped. `docs/mt7612u.md`'s `TxStats::failed` caveat now points at the new counter. The RX callback doc says a callback that transmits stalls the event thread for up to the ~1 s slot wait when the TX ring is full. - **New headless cell `mt7612u_rx_ring`** (`tests/mt7612u_rx_ring_selftest.cpp`) drives the ring's RX completion through a test hook with a hand-filled transfer, no context and no device: an invalid-PHY frame must return and move `rx_invalid`, a valid frame is delivered once, and a stranded slot parses and counts nothing. With the parser that counted under the lock it fails itself after 2 s (checked by mutation); ctest `TIMEOUT 10` backs it. Bring-up tool, `mt7612uprobe txs` (`c9e3243`, `d6d98b6`, `e296998`, `878ee7c`): - `DEVOURER_TXS_TRACE=1` prints a `TXS` line for every submit, every expired wait, every popped status entry (raw words, pktid, WCID, classification), every EXT change on an empty FIFO, and every slow status read, plus a per-arm summary. The arm table and its parsers are unchanged. - **Paced to the chip, then to the status.** Each frame waits up to 1.5 s for its transfer. A transfer that completed in error is resent and not counted as sent. A transfer still in flight after 1.5 s counts as sent, because a resend would air the frame twice. The per-frame status wait is floored at 2 s. The 1 Hz PHY tick keeps running through both waits. - An arm cut short (attempt cap, wall clock) prints UNSETTLED, so a short arm whose few frames all filed status cannot pass as a clean row in the uplink harness. A refused submit re-baselines the arm's USB counters. - **A stale-EXT claim the arm did not need is handed back** (#467). One review arm read `61/60, 1 stale-EXT entries claimed`: all 60 own entries arrived anyway, so the claimed one was not the arm's own. At arm end, if the own-pktid entries reach (or, with a MAC duplicate, exceed) the frames sent and exactly one entry was claimed, it moves back to the late (or foreign) column and its SUCCESS bit is un-counted. A claim taken while the arm's first transfer was in flight is handed back the same way if that transfer then fails on the wire. An arm whose surplus claim was handed back reads n/n and counts as settled for the next arm's claim: it owes no entries, which is the condition; a duplicate still disarms it. - `tests/mt7612u_sta_uplink.sh`: the DUT `timeout` bound follows the new waits (`d6d98b6`). The header now says the bound is per harness arm (5424 s at FRAMES=60, 17400 s at 200) while the runtime figure covers the whole run, and states the margin: ~2.7x over every gate arm holding its status at 2 s a frame, ~5x over the measured run (`0931bd6`, #467). Docs (`957145b`, `e296998`, `878ee7c`, `974c89a`, `3863e76`): `docs/mt7612u-tx-retry.md` gets a new section, "Status posted on the next submit", and records the ~1.1 s unacknowledged retire on ch6 as open, with the protection and timeout registers decoded. It also says the claim's settled condition cascades: one UNSETTLED arm disables the claim until an arm settles on its own, so on a unit whose arms do not settle the claim buys almost nothing (#467). ### Why On the uplink harness at ch6 (#461), an unacknowledged frame takes 1.1-1.3 s from status to status. The gate paced at 110 ms, so it filled the chip's queue. Bulk OUT stopped accepting, and the 1000 ms transfer timeout then cancelled 9-50 of every 60 frames per unicast arm. None of that showed in any TX counter a consumer reads, and the surviving backlog filed status in later arms' rows. Pacing the gate fixes the gate. Removing the timeout fixes the same loss for any library caller that transmits faster than the air drains. libusb implements a transfer timeout by cancelling the transfer, so the timeout was never a stronger backstop for a wedged chip than the stop's own cancel pass. The RX ring has always run with no timeout, and an unplug still completes every transfer through the event thread. ### What is measured On the final head: Bench: Linux 7.0 host, ch6, near field. DUT MT7612U (0e8d:7612); ACK responder RTL8812CU; AP/STA peer RTL8812BU. Head benched: `878ee7c`. The maintainer benched `11329ab` (PASS, FRAMES=20). The final head `3863e76` differs from `11329ab` only in `tx_err` accounting on a stop-refused submit, plus comments and docs. - `tests/mt7612u_sta_uplink.sh` (FRAMES=60, RETRY_LIMIT=15, `DEVOURER_TXS_TRACE=1`): **PASS**, rc 0. - Arm A (peer answers): 60/60 acked, 100% ok, retries mean 0.1, max 1. - Arm B (control, peer answers another address): 0/60 acked, retries 16.0. - Gate arms a-h, both receiver passes: every arm 60/60 own entries, with 0 submit failures, 0 per-frame status timeouts, 0 late entries from the previous arm, 0 foreign, 0 WIRE-FAIL and 0 UNSETTLED. - The stale-EXT claim fired once per arm on several arms (a, c, d, g, h on the first pass; b, e, f, g, h on the second). No arm read 61/60, none showed a late entry, and no claim was handed back. - **Same unit, same rig, master `1134df9`** (FRAMES=20): **FAIL**, rc 1. - Arm A: 0/20 acked (entries below sent). Arm B: 0/20. - Unicast gate arms b-e landed 4/20, 0/20, 0/20 and 0/20 own entries on both passes, and nearly every arm read UNSETTLED. - This is the #461 pattern from the maintainer's second unit, reproduced here on master and gone on this branch. - `tests/mt7612u_sta_onair.sh`: 16 passed, 0 failed, 0 inconclusive. - `tests/mt7612u_ap_onair.sh`: 14 passed, 0 failed. - No log from these runs contains "leaking the ring" or "still in flight". Not measured: - An unplug during TX. - An RX soak on a busy channel beyond the harness runs. - Starting and stopping RX while another thread transmits (the race listed under "What it can't do"). Earlier numbers come from the pre-review head (`f5278d0`, without any of the review fixes): uplink A 20/20, B 0/20, 0 WIRE-FAIL; sta_onair 16/16; ap_onair 14/14. ### What it can't do - `Mt7612uRadio::GetTxStats` does not read `mt7612u_tx_wire_failed()` yet. Its `failed` still counts refusals only (documented in `docs/mt7612u.md`). - Nothing here explains the ~1.1 s per unacknowledged frame on ch6 against ~46 ms on ch36. That is recorded as open. - The refusal is a fixed 1000 ms bound, not a queue-depth policy like mt76's. A caller that holds a lock across a send (as `Mt7612uRadio` holds `_mu`) still holds it for up to 1 s on a stalled chip. - **A send concurrent with a ring stop that frees the ring is still unsafe**, as it was on master. `mt_async_tx_submit` reads `d->a` without synchronisation, and a sender parked in the slot wait can wake on the ring's mutex after the stop deleted it. `Mt7612uRadio::StopRxLoop` runs `mt7612u_rx_stop` without `_mu` while the send paths hold it, so the backend can hit this. It is not fixed here: `d->a` is read unsynchronised in `tx.cpp`, `radiotap.cpp` and `init.cpp` as well, so a real fix is a lifetime change to the ring pointer (a user count the stop drains, or a device-level lock), and `StopRxLoop` cannot simply take `_mu` - the stop joins the event thread, whose RX callback can take `_mu`. - The stop race, the RX resubmit and the stop/strand ordering have no headless test, because nothing in the tree fakes a libusb-owned transfer; they are argued from the locking. `mt7612u_rx_ring` covers the RX completion's parse and counting only (rx_active stays 0 there, so it never resubmits). The `txs` claim hand-back has no test: the accounting lives in the hardware-only probe tool, and nothing reaches it without a device. - If a slow transfer, already counted as sent by the gate, later fails, the gate attributes that failure to the frame being waited on and resends it. With no timeout, only an unplug or a stall produces such a late failure. The same can cross an arm boundary: the previous arm's last transfer outlives `TXS_USB_WAIT_MS`, is counted sent, and fails after the next arm re-baselines its USB counters. The new arm's first frame then sees `tx_err` move, is taken as wire-failed and resent (aired twice), and `txs_unclaim_one` hands back a claim that was legitimately this arm's. Rare for the same reason; documented, not fixed. ### Follow-ups - Wire `mt7612u_tx_wire_failed()` into `GetTxStats::failed`. - Make a send safe against a concurrent ring stop (the `d->a` lifetime above); then the strand decision can count only submitted slots. - Find the cause of the ch6 unacknowledged retire. ### #467 items Closed here: the over-attributed stale-EXT claim, the `stale_settled` cascade note, and the uplink harness's per-arm bound. The #461 second-unit data point is recorded above. The station-harness items of #467 (the `sta_identity` verdict branch, the firmware preflight, the INT/TERM exit code, the `ap_onair` reap, the `tail_ms` clamp) are left to a separate PR. ### Review record - Opus adversarial review of the whole delta. Fixed: the stop vs. slot-wait submit race, the use-after-free of a stranded slot's device pointer, the per-batch repeat of the 1 s wait in `send_packets`, the dead `tx_timed_out` counter (it could no longer move once the timeout was gone), the PHY tick gap during the transfer wait, and a garbled library commit message. Commits are reordered: library first, then the gate, with the gate fix-up squashed in. - Flash reviews A1 + A2 (on the pre-rebase diff). Fixed: the short arm that read as settled; the stale `tx_err` baseline after a refused submit; the stranded-slot use-after-free (the same one Opus found); the undocumented loss of the timeout's self-heal; the dead `tx_timed_out`; the stale docs (the `api_link` count, the uplink RUNTIME comment, the `docs/mt7612u.md` caveat, and the "never counted as sent" overclaim). Declined: wiring the counter into `GetTxStats` (lifetime and locking change, listed as a follow-up); a headless test (nothing in the tree fakes libusb transfers); gate rc 1/3 scoring in the harness (predates this PR); resetting `last_ext` per arm (the EXT register persists across arms, which is what the trace compares against). - Flash review of the fix delta. Fixed: the stranded stop left the RX slots' device pointer set, so a late completed bulk-IN transfer could parse through a freed device; a reserved-but-unsubmitted slot was counted into `tx_wire_failed` and then refused; the submit-under-lock comment wrongly said libusb holds no lock across a completion callback (it holds the context's event lock, taken by `libusb_try_lock_events`; there is no cycle because `libusb_submit_transfer` never takes it, and neither does this driver). - Flash + Opus review of that fix round. Fixed: parsing under the ring lock self-deadlocked on an invalid PHY, because the parser counted it under the same lock (Opus reproduced the hang; now the `mt7612u_rx_ring` cell); the RX resubmit outside the lock; the `tx_wire_failed` "never reached the chip" overclaim; the `submitted` comment; the RX callback's stall under a full TX ring, now documented; the claim hand-back's `==` (now `>=`), its effect on the settled condition (documented), and a claim taken during a first transfer that then failed (now handed back). Declined, recorded above: the sender vs. concurrent stop lifetime, and with it counting only submitted slots in the strand decision. - Qodo review on #470 (`11329ab`). Fixed: the slot wait refused on `cv.wait_until` timing out without re-scanning, so a slot freed at the deadline was ignored (it now re-checks the ring and refuses only when no slot is free at the deadline); internal.h's `tx_wire_failed` comment still said "never reached the chip". Kept documented, not fixed: the gate resending the wrong frame on a late wire failure (see "What it can't do"). No headless test for the slot-wait fix: the window is between the condition variable's timeout and re-taking the lock, and reaching it ends in `libusb_submit_transfer`, which needs a real device handle. - Maintainer review on #470 (`3863e76`). The docs and comments this PR touches now state current behaviour and measurements, with no before/after narration and no issue numbers in prose or headings (the flagged lines and a sweep of the rest). `docs/mt7612u-tx-retry.md` states the stale-EXT claim's blind spot: the claimed entry satisfies the per-frame wait and the settle loop one status early, so the hand-back fires only when the arm's last own entry pops inside the arm, and a wrong claim can read n/n (no gate change). A submit refused because a stop began no longer counts in `tx_err`; a libusb submit failure still does. `MT_TX_SLOT_WAIT_MS` sits below the includes. ### Verification - `cmake -DDEVOURER_MT7612U=ON -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON`: 84/84. - The same build with `-DDEVOURER_SANITIZE=address+undefined`: 84/84. - CI subsets: mt7612u-only 73/73, jaguar2-only 73/73, jaguar3-only 72/72. - `mt7612u_rx_ring` mutation check: with the old in-parser count restored the cell fails ("rx_done did not return within 2 s"); with the fix it passes. - The subtree's `frame_shape` test (built by its own Makefile, not ctest) passes against the updated invalid-PHY return. - No new compiler warnings in `src/mt7612u`. - `bash -n` and `shellcheck -x -S warning tests/mt7612u_sta_uplink.sh`: clean. (Plain `shellcheck -x` reports SC2329 at info level for the trap-invoked `cleanup`, and reports it on master too.) - `git diff --check`: clean. - On air: see "What is measured" (benched on `878ee7c`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Joseph <162703152+josephnef@users.noreply.github.com>
1 parent 60f8010 commit 47151d0

15 files changed

Lines changed: 793 additions & 131 deletions

‎CMakeLists.txt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,19 @@ if(DEVOURER_MT7612U)
10031003
${CMAKE_CURRENT_SOURCE_DIR}/src/mt7612u/include)
10041004

10051005
add_test(NAME mt7612u_tsf_api COMMAND Mt7612uTsfApiSelftest)
1006+
1007+
# The async RX ring's completion callback, driven with hand-filled
1008+
# transfers through a test hook: the parse runs under the ring lock, and a
1009+
# parser that counts an invalid PHY under that lock self-deadlocks. The
1010+
# cell fails itself after 2 s; TIMEOUT is the backstop.
1011+
add_executable(Mt7612uRxRingSelftest tests/mt7612u_rx_ring_selftest.cpp)
1012+
target_link_libraries(Mt7612uRxRingSelftest PRIVATE devourer
1013+
PkgConfig::libusb Threads::Threads)
1014+
target_include_directories(Mt7612uRxRingSelftest PRIVATE
1015+
${CMAKE_CURRENT_SOURCE_DIR}/src/mt7612u
1016+
${CMAKE_CURRENT_SOURCE_DIR}/src/mt7612u/include)
1017+
add_test(NAME mt7612u_rx_ring COMMAND Mt7612uRxRingSelftest)
1018+
set_tests_properties(mt7612u_rx_ring PROPERTIES TIMEOUT 10)
10061019
endif()
10071020

10081021
# --- src/sta/: the device-free 802.11 station core -------------------------

‎docs/mt7612u-tx-retry.md‎

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,18 @@ How it reads the status FIFO:
6060
entries that echo the current arm's pktid. Entries carrying the previous
6161
arm's pktid are reported as late, any other as foreign - so late status from
6262
an unsettled arm never lands in the next arm's columns.
63-
- **Paced to the status, not the submit.** A submit only queues the USB
64-
transfer, so each frame waits for its own status entry before the next is
65-
sent, bounded by the ladder at the effective limit; an expired wait counts
66-
as a per-frame status timeout. A final settle loop collects anything still
67-
owed.
63+
- **Paced to the chip, then to the status.** A submit only queues the USB
64+
transfer, so each frame first waits up to 1.5 s for that transfer to
65+
complete - one that fails within that wait is resent and not counted as
66+
sent, and the arm says how many did; one still in flight then counts as
67+
sent, since a TX transfer has no timeout and a resend would air it twice
68+
(should it fail later, the failure is charged to the next frame) - then
69+
for its own
70+
status entry, bounded by the ladder at the
71+
effective limit and never less than 2 s; an expired wait counts as a
72+
per-frame status timeout. A final settle loop collects anything still
73+
owed. `DEVOURER_TXS_TRACE=1` logs every submit, expired wait and popped
74+
entry with its raw words (`TXS` lines; the table is unchanged).
6875
- On receiver-ON passes the 1 Hz PHY tick runs from those waits, and WCID 1
6976
must read back as installed or the gate fails.
7077

@@ -96,7 +103,8 @@ initvals.
96103

97104
Columns: own-pktid entries / sent, successes, mean retry, max retry, fps,
98105
then per-frame status timeouts `T`, late entries from the previous arm `L`,
99-
foreign entries `F`; `U` = UNSETTLED. fps includes each frame's wait for its
106+
foreign entries `F`; `U` = UNSETTLED (fewer entries than frames sent, or
107+
an arm cut short before it sent all of its frames). fps includes each frame's wait for its
100108
own status entry: on a clean row it is per-frame submit-to-status time, on a
101109
row with timeouts it is the wait bound, not a rate.
102110

@@ -163,30 +171,92 @@ What it shows:
163171
one late entry (or, for the very first arm, one foreign entry) in that
164172
SAME arm's row. Counting 39/40 rows, it hit five of sixteen at limit 0,
165173
eleven at limit 5 and seven on the initvals - one run each. Arm a lags in
166-
every pass; which other arms lag varies from pass to pass. The table rules
167-
out "status posted only on the next TX": at limit 0, receiver ON, arm f
168-
lags with L1 although arm e before it settled 40/40 and owed nothing, and
169-
arm g after it shows no late entry. What fits is the two-transfer read:
174+
every pass; which other arms lag varies from pass to pass. This table
175+
alone does not show "status posted only on the next TX" (at limit 0,
176+
receiver ON, arm f lags with L1 although arm e before it settled 40/40 and
177+
owed nothing, and arm g after it shows no late entry); the trace in
178+
"Status posted on the next submit" below shows it for some arms. What fits
179+
this table's first-entry case is the two-transfer read:
170180
when the FIFO is empty at the EXT read and an entry is filed before the
171181
main read, the popped entry is paired with the stale EXT word of the
172182
previous entry. On an arm's first entry that is the previous arm's pktid,
173-
so the entry was counted late, the arm stayed one short, and every
174-
per-frame wait timed out. A race on the poll timing, which is why it
175-
varies from pass to pass and with the host. The gate now claims that
176-
entry back (`txs_drain`), under all of: it is the arm's first popped entry
177-
(no own entry yet), the arm has submitted a frame, and its pktid is that
178-
of the last arm that sent a frame, which must have settled with no entry
179-
owed - or, on the session's first arm, any pktid. A claimed entry counts
180-
in entries, and in success when its SUCCESS bit is set; it stays out of
181-
the retry columns, and is reported as "stale-EXT entries claimed". These
182-
tables were taken before that change. With the claim keyed to the
183-
previous arm, the author's unit then settled 16/16 arms at limits 5 and 0
184-
(recorded on issue #461); keying it to the last arm that sent a frame,
185-
so an arm with every submit failed is skipped, has not been run on
186-
hardware.
183+
so without a correction the entry counts as late, the arm stays one short,
184+
and every per-frame wait times out. A race on the poll timing, which is
185+
why it varies from pass to pass and with the host. These tables are
186+
uncorrected. The gate claims that entry back (`txs_drain`), under all of:
187+
it is the arm's first popped entry (no own entry yet), the arm has
188+
submitted a frame, and its pktid is that of the last arm that sent a
189+
frame, which must have settled with no entry owed - or, on the session's
190+
first arm, any pktid. A claimed entry counts in entries, and in success
191+
when its SUCCESS bit is set; it stays out of the retry columns, and is
192+
reported as "stale-EXT entries claimed". With the claim, the author's unit
193+
settled 16/16 arms at limits 5 and 0, and the ch6 uplink harness settled
194+
every arm of both passes. If the arm's own entries still reach `sent`,
195+
the claimed entry was not its own (one arm read 61/60 with one claimed),
196+
and the gate hands it back to the late/foreign column at arm end; it does
197+
the same when the claim was taken while the arm's first transfer was in
198+
flight and that transfer then completed in error. The hand-back has a
199+
blind spot: the claimed entry counts in entries, so the per-frame wait and
200+
the settle loop are satisfied one status early, and the hand-back fires
201+
only when the arm's last own entry pops inside the arm. A wrong claim
202+
whose last own entry pops after the arm (n-1 own plus 1 claimed) reads
203+
n/n, indistinguishable from a right one, and that entry lands in the next
204+
arm - which, with the arm counted settled, may claim it in turn. An arm
205+
whose surplus claim was handed back reads `sent`/`sent` and counts as
206+
settled for the next arm's claim - it owes no entries, which is what the
207+
condition asks; a MAC duplicate (own entries above `sent`) does not. The
208+
settled condition cascades: one UNSETTLED arm disables the claim for every
209+
following arm until an arm settles on its own, so on a unit whose arms do
210+
not settle the claim buys almost nothing.
187211
- **Arms e-h**: e, f and g read like c whenever they are clean; nothing
188212
distinguishes them. h (broadcast, WCID 1) lagged in five of six passes.
189213

214+
## Status posted on the next submit
215+
216+
`tests/mt7612u_sta_uplink.sh` at ch6, retry limit 15, the RTL8812CU peer
217+
answering for the DUT's target in its arm A and for another address in arm
218+
B, with `DEVOURER_TXS_TRACE=1`: one run at 60 frames per arm and two at 20
219+
(the second pair differing only in the data-frame QSEL, below) - six harness
220+
arms, each running all sixteen gate arms.
221+
222+
**Frames, not status, are what a gate paced faster than the air loses.** A
223+
per-frame wait far shorter than an unacknowledged frame's ~1.1 s on this
224+
channel (60 + 50 ms, say) submits ahead of the air: the chip's queue
225+
fills, its bulk OUT stops accepting, and a ring with a 1000 ms transfer
226+
timeout cancels 9-50 of every 60 frames per unicast arm. Those count as
227+
sent, and the surviving backlog files its status seconds later in later
228+
arms' rows, as late or foreign. TX transfers carry no timeout (as mt76's do
229+
not) - a full ring refuses a submit instead - and the gate waits for each
230+
transfer and floors the status wait at 2 s; with both, no transfer failed
231+
in any of the three runs.
232+
233+
**Some arms file each frame's status only when the NEXT frame is
234+
submitted.** The trace shows it frame by frame: SUBMIT, the whole 2 s wait
235+
expires with nothing, and the entry pops within ~1 ms of the next frame's
236+
bulk transfer - so every wait times out, and the arm's last entry lands in
237+
the next arm as late (N-1/N, `late=1`). Which arms, across the six harness
238+
arms (rx0/rx1 = receiver off/on):
239+
240+
| arm | rx0 | rx1 |
241+
|---|---|---|
242+
| a broadcast, No Ack | 6/6 | 6/6 |
243+
| h broadcast, wcid 1 | 6/6 | 6/6 |
244+
| e, f, g unicast No Ack (ownSA) | 0/6 | in all 3 peer-answering arms (A), one to three of e/f/g each; 0/3 in B |
245+
| b unicast Normal (static SA, never ACKed) | 1/6 | 1/6 |
246+
| c unicast No Ack; d unicast Normal (ownSA) | 0/6 | 0/6 |
247+
248+
So broadcast holds every time, in both receiver states; unicast No-Ack holds
249+
only with the receiver on and a peer answering on the channel. Why the MAC
250+
holds the entry is not established. The 2 s floor does not cause it - no wait
251+
helps when the entry needs the next submit - but it makes a held arm cost
252+
2 s a frame, which is what makes the harness slow (35 min at 60 frames).
253+
254+
**The data-frame queue is not it.** `MT_QSEL_EDCA` here is 1, which is
255+
mt76's HCCA value (`enum mt76_qsel`: MGMT 0, HCCA 1, EDCA 2). The two 20-frame
256+
runs aired data at QSEL 1 and at QSEL 2 from one build and read the same:
257+
the same arms held and the same ~1.1 s unacknowledged retire. The define is
258+
left as it is.
259+
190260
## Radiotap NOACK disables the retry
191261

192262
`src/mt7612u/radiotap.cpp` maps radiotap TX_FLAGS NOACK to `rate->no_ack`, and
@@ -247,6 +317,25 @@ It is the difference between `docs/mt7612u.md`'s "address one-way links to
247317
broadcast or multicast" guidance and a usable one-way *unicast* link, which is
248318
why it is worth an issue of its own.
249319

320+
## Open: ~1.1 s per unacknowledged frame on ch6
321+
322+
On ch6 an unacknowledged frame at limit 15 retires in 1.1-1.4 s (arms b and d
323+
in arm B, b in both; the status reads 16 retries at OFDM 6M, rate word
324+
0x2000), against ~46 ms on ch36 above - ~70 ms an attempt where the EDCA
325+
arithmetic gives ~3-5 ms. Not the queue (above). What the registers say,
326+
decoded against mt76's field layout, none of it decisive:
327+
328+
- `MT_TX_RTS_CFG` 0x00ffff20: RTS retry limit 32, threshold 0xffff.
329+
- `MT_CCK_PROT_CFG` 0x07f40003 / `MT_OFDM_PROT_CFG` 0x07f42004: CTRL 0 (no
330+
protection), RTS-threshold enable set - so no RTS at 1400 bytes.
331+
- `MT_MM20/GF20_PROT_CFG` 0x01752004, `MM40/GF40` 0x03f52084: CTRL 1, RTS/CTS
332+
FORCED for HT frames, at OFDM 24M. The first attempts at HT MCS7 would
333+
each be an unanswered RTS, retried to 32 - but the same initvals ran on
334+
ch36, and the final attempts (OFDM 6M) fall under OFDM_PROT, which is off.
335+
- `MT_TX_TIMEOUT_CFG` 0x000a2290: ACK timeout 0x22 (34 us).
336+
337+
Nothing in-tree writes these after the initvals.
338+
250339
## Caveats
251340

252341
- One adapter, near field, 40 frames per arm, one run per setting, one

‎docs/mt7612u.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,11 @@ and nothing was ever queued on the healthy path.
380380

381381
## Offline tests
382382

383-
`ctest` runs eight MediaTek cells. The first four are C++ and need neither
383+
`ctest` runs nine MediaTek cells. The first four are C++ and need neither
384384
hardware nor the `DEVOURER_MT7612U` option — the code they cover is
385-
header-only. The fifth is C++ and needs the option, because it calls the
386-
subtree's own symbols. The sixth is header-only C++ gated on the option. The
387-
last two are Python and need the `reference/mt76` submodule:
385+
header-only. The fifth and sixth are C++ and need the option, because they
386+
call the subtree's own symbols. The seventh is header-only C++ gated on the
387+
option. The last two are Python and need the `reference/mt76` submodule:
388388

389389
| cell | what it holds |
390390
|---|---|
@@ -393,6 +393,7 @@ last two are Python and need the `reference/mt76` submodule:
393393
| `mt7612u_rx_queue` | the RX hand-off queue's two load-bearing properties: a full ring drops the **newest** frame and counts it, and a popped slot outlives the queue lock. Broken, the first reorders frames or wedges the part and the second is a use-after-free — and both look like a healthy link until a packet processor falls behind |
394394
| `mt7612u_tsf_read` | that the TSF read stays coherent when the low word wraps between any two of its register accesses: a scripted counter swept across the wrap one microsecond at a time, every failure position, and an all-ones low word as a value. The pre-fix DW0,DW1 order runs against the same sweep and must tear, or the cell cannot see the bug. Mutation-tested by hand three ways — no retry, a retry that keeps the first high word, a retry that skips the low-word re-read — each fails |
395395
| `mt7612u_tsf_api` | that a failed TSF read stays distinguishable from a value at the C entry points: `mt7612u_read_tsf_chk` refuses a NULL device or output with -1 and does not write through the pointer, and `mt7612u_read_tsf` answers 0. `0xffffffff` is a legitimate register word here, so only the return code can carry a failure. Not free: deleting the guard in `mt7612u_read_tsf_chk` segfaults the cell (hand-run). Reaches the NULL refusals only; a failed transfer on a live device, the `Mt7612uRadio::ReadTsf` throw and the `tsf_write` caps bit need the part (`bringup caps` prints it, and `Mt7612uRadio::GetAdapterCaps` takes `tsf_write_ok` from it rather than restating it) |
396+
| `mt7612u_rx_ring` | the async ring's RX completion (`rx_done`, through the test hook `mt_async_rx_done_for_test`), driven with hand-filled transfers and no device: an invalid-PHY frame returns and counts `rx_invalid` and `rx_dropped`, a valid frame is delivered once, and a stranded slot (device pointer cleared by a stop) parses and counts nothing. The parse runs under the ring lock, so a parser that takes that lock again self-deadlocks on one corrupt RXWI; the cell fails itself after 2 s if the call does not return. It does not cover the resubmit or the stop/strand ordering, which need a transfer libusb owns |
396397
| `mt7612u_station_identity` | the station-identity decision and ownership hand-off (`src/mt7612u/StationIdentity.h`): both register reads fail closed, a failed port read is reported as a failed read rather than a mismatch, argument checks run before any I/O, the port comparison is tri-state (a failed read is UNKNOWN, never DIFFERENT), a verified move drops the arm, an unreadable one keeps it, and a failed start that unwinds restores it. Header-only, gated on `DEVOURER_MT7612U`, which the MediaTek CI jobs set. The hardware counterpart is `mt7612uprobe staid` (`docs/mt7612u-station-identity.md`) |
397398
| `mt7612u_usb_ids_vs_mt76` | that the id table above really is the complete `mt76x2u_device_table` from the pinned reference, byte-checked. An id we *forgot* is invisible to `mt7612u_usb_ids` — the adapter just falls through to the Realtek path — and the first draft of that header had 11 of the 16, taken from the host's kernel tree |
398399
| `mt7612u_initvals_generated` | that the checked-in `initvals.h` still matches what `tools/extract_mt7612u_tables.py` produces from the pinned reference |
@@ -421,7 +422,7 @@ nothing. Each fails the cell, and each names the property it broke.
421422

422423
| test | what it holds |
423424
|---|---|
424-
| `api_link` | takes the address of all 39 public entry points while including only the public header, so a declaration that loses its definition is a link error. Still compiled as C, which is what keeps the `extern "C"` guard honest now the library itself is C++ |
425+
| `api_link` | takes the address of all 40 public entry points while including only the public header, so a declaration that loses its definition is a link error. Still compiled as C, which is what keeps the `extern "C"` guard honest while the library itself is C++ |
425426
| `frame_shape` | `mt_hdrlen_from_fc()` over management, all eight control subtypes and the five data shapes; the RX L2-pad fold on a synthetic QoS frame, with a negative control that redoes the old fixed-24 fold and asserts the QoS Control really is destroyed; the radiotap VHT bandwidth mapping over all eleven codes the part can express; the txwi pktid for a normal send (0), `MT_TXOPT_TXS` alone and `MT_TXOPT_PKTID(id)` |
426427
| `field_macros` | `MT_CTZ` against `__builtin_ctz` over all 32 single-bit and all 528 contiguous masks, plus a `FIELD_PREP`/`FIELD_GET` round-trip, plus a static initialiser that fails to compile if the macro stops being constant-foldable, plus the `MT_TX_RETRY_CFG` read-modify-write (both limits set, bits 16+ kept) |
427428
| `log_sink` | that `mt7612u_set_log_sink()` **diverts** rather than copies — stderr must stay silent while a sink is installed — that the sink gets the bare message with no prefix to double up, that every level letter arrives, and that NULL restores the built-in sink instead of silencing the library |
@@ -556,8 +557,9 @@ Stated because the numbers above are uniformly favourable.
556557
accepted and the URB then failed to deliver does not move it. The library's
557558
own `tx_err` cannot simply be added: it double-counts submit failures, counts
558559
URBs where this counts frames, and restarts at zero when a ring is torn down,
559-
so `failed` would go backwards across an RX restart. Needs a monotonic
560-
wire-failure counter in the library.
560+
so `failed` would go backwards across an RX restart. `failed` counts
561+
refusals only. `mt7612u_tx_wire_failed()` is the library's monotonic
562+
per-frame wire-failure counter; `GetTxStats` does not read it yet.
561563
- **Effectively one witness implementation.** Every on-air number outside the
562564
regression matrix is an RTL8812AU running this project's `rxdemo`: `paggr`,
563565
`bw` and `rate` are that implementation's reading, not an independent

‎src/mt7612u/Mt7612uRadio.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,8 +1001,8 @@ devourer::TxStats Mt7612uRadio::GetTxStats() {
10011001
* a uint64_t reads as ~1.8e19. Reading it also took _mu, which send_packet
10021002
* holds across a blocking submit - so a stats poll from another thread
10031003
* stalled for as long as the TX ring was saturated. A documented gap beats
1004-
* four wrong numbers; the fix belongs in the library, which needs a
1005-
* monotonic wire-failure counter that outlives a ring.
1004+
* four wrong numbers. The library's monotonic per-frame counter is
1005+
* mt7612u_tx_wire_failed(), which this does not read yet.
10061006
*
10071007
* Counted here rather than read from mt7612u_get_stats(), which reports the
10081008
* ASYNC RING's counters. mt_tx_raw() only uses that ring when one is running

0 commit comments

Comments
 (0)