Skip to content

mt7612u: drain EP 4 when an MCU reply is late, so RXDCOC cannot stall bring-up - #510

Draft
snokvist wants to merge 5 commits into
OpenIPC:masterfrom
snokvist:mt7612u-rxdcoc-ep4-drain
Draft

snokvist wants to merge 5 commits into
OpenIPC:masterfrom
snokvist:mt7612u-rxdcoc-ep4-drain

Conversation

@snokvist

Copy link
Copy Markdown
Collaborator

What

On a channel that is already in use, an MT7612U bring-up takes ~27.5 s instead of ~0.87 s. The RXDCOC calibration of the channel set runs out its 3 s MCU reply budget, and so does every command after it. All the replies then turn up within ~7 ms of the RX ring's first EP 4 transfer.

A register probe at the first timeout read MT_MAC_SYS_CTRL = 0 (MAC RX and TX off) with the USB RX bulk enable on (MT_USB_U3DMA_CFG = 0x00c40020). The EP 4 read decoded there was an RX descriptor and a data frame from the peer's address. So received data reaches EP 4 during RXDCOC before anything reads it, and while it sits there the EP 5 replies are held back too. What in the calibration lets it through is not established.

This is the "bring this one up BEFORE the interferer" note in src/mt7612u/CLAUDE.md, and probably the same thing the "ten reads, not five" comment in mcu_wait_resp() describes ("the MCU answered every command, but seconds late … their nine replies then arrived together").

Change

  • mcu_wait_resp() drains EP 4 (mt_rx_flush()) once per command, on its first 300 ms timeout, and only while nobody else reads EP 4. A reply that arrives in time costs nothing. A late one costs one flush, and its log line gives the command, length and first word.
  • Ownership is a new mt7612u_dev::ep4_owner (NONE / RING / SYNC), not d->a->rx_active:
    • It lives on the device, so the PHY tick can read it while StopRxLoop frees the ring (StopRxLoop does that without _mu).
    • The ring claims it under io_lock before its first RX submit. That is the lock mcu_wait_resp holds from the check through the drain.
    • The ring releases it only once no RX transfer is left in flight. A stranded ring keeps it, and the existing error line says so.
    • An MT_RX_DRAIN_SYNC caller (bringup gate F) holds it until the receiver is switched off.
    • Every transition is a compare-exchange from the expected owner.
  • mt_rx_flush() returns the bytes it drained, partial reads included. Existing callers ignore the return value.
  • Docs.
    • src/mt7612u/CLAUDE.md: a current-state "MCU replies and EP 4" section replaces the "bring it up first" rule.
    • docs/mt7612u.md: a "Bring-up on a busy channel" section with the table and its limits.
    • tests/busy_window_probe.*: the notes that relied on the old rule are updated. Its loaded MediaTek arms stay skipped, because they have not been re-run with the sensor started second.
    • tests/README.md lists the reproducer.
  • Reproducer tests/mt7612u_busy_bringup.sh, built on mt7612u_sta_lib.sh and its root-run rules:
    • a Realtek peer under txdemo loads the channel at zero gap;
    • the DUT is brought up REPS times under it with rxdemo, each run timed to its RX ring starting;
    • one bring-up before the peer starts is the control;
    • it needs a build with -DDEVOURER_MT7612U=ON and says INCONCLUSIVE otherwise.

Measured

One MT7612U (0e8d:7612, USB 3) on ch 100. The peer is an RTL8812EU 50 cm away at DEVOURER_TX_PWR_OFFSET_QDB=-40 (−36 dBm at the DUT). The peer was heard after every bring-up.

peer on the air during bring-up master 09c2ff3 this PR
none (control) 0.86–0.87 s 0.86–0.87 s
6 Mb/s legacy, 500 µs gap, 20 MHz 1 of 6 slow (27.6 s) 0 of 6 (no drain needed)
HT MCS3, 2 ms gap, 20 MHz 4 of 6 slow not run
HT MCS3, zero gap, 20 MHz 6 of 6 slow (27.5 s, 9 timeouts) 10 of 10 at 1.18 s, 0 timeouts
HT MCS3 40 MHz, zero gap, DUT at HT40+ 6 of 6 slow (27.5 s, 9 timeouts) 10 of 10 at 1.18 s, 0 timeouts
  • The drain under full load. Every bring-up drained once, at cmd 31 arg 3 (RXDCOC): 8.6–10.1 kB at 20 MHz, 15.8–17.3 kB at 40 MHz. Each one reached the ring 0.31 s after the control, which is the first reply timeout.
  • An earlier run agrees. The same arms on master, timed to the first delivered frame instead, gave 0 of 10, 1 of 4, 4 of 4 and 6 of 6 slow.
  • A second application on the same unit agrees. An MT7612U receiver restarted under an RTL8812EU airing ~1.7 k HT40 frames/s, sessions pooled, went from 22 of 32 slow restarts to 0 of 31.
cmake -S . -B build -DDEVOURER_MT7612U=ON && cmake --build build -j
sudo env DUT_SYSFS=<mt7612u> PEER_SYSFS=<realtek> PEER_PID=<pid> CH=<ch> tests/mt7612u_busy_bringup.sh

ctest with -DDEVOURER_MT7612U=ON: 86/86 pass; the two that need external reference data are skipped. No new warnings in src/mt7612u/.

Limits

  • Scope: one MT7612U unit, one peer model, one channel, near field.
  • What drives the odds: the stall's odds rise with the peer's load. Frame rate, bytes/s and airtime all moved together here, so which one matters is not separated.
  • Not measured:
    • a ring-less (TX-only) channel set under traffic, which runs the same RXDCOC and takes the same path;
    • whether mt76's five reply reads would now suffice in place of the ten kept here.

Possible follow-ups (not in this PR)

  • The root cause. Find what RXDCOC enables that lets received data into EP 4 with MAC RX off, and keep that path closed until a drainer exists, in the spirit of mt_mac_start's "refuse rather than wedge".
  • Re-run busy_window_probe.sh's loaded MediaTek arms with the sensor started second, and drop their skip.
  • Re-measure the ten-read MCU reply budget with the drain in place.

Found while bringing up an RTL8812EU → MT7612U video link. Before opening, this was reviewed by three independent agents over two rounds. The first revision read d->a->rx_active unlocked from the tick thread; that use-after-free window was caught in review and is gone.

🤖 Generated with Claude Code

https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge

… bring-up

On a channel with traffic, an MT7612U bring-up took ~27.5 s instead of
~0.87 s: from the RXDCOC calibration of the channel set on, every MCU command
ran out its 3 s reply budget, and all the replies then appeared within ~7 ms
of the RX ring's first EP 4 transfer. A register probe at the first timeout
read MT_MAC_SYS_CTRL = 0 (MAC RX and TX off) with the USB RX bulk enable set,
and the EP 4 read decoded there was a data frame from the peer's address. So
received data reaches EP 4 during RXDCOC before anything reads it, and while
it sits there the EP 5 replies are held back too. What in the calibration
lets it through is not established.

mcu_wait_resp() now drains EP 4 once per command, on its first 300 ms
timeout, and only while nobody else reads EP 4. Ownership is a new
mt7612u_dev::ep4_owner (NONE / RING / SYNC) rather than d->a->rx_active: it
lives on the device, so the PHY tick can read it while StopRxLoop frees the
ring (which it does without _mu). The ring claims it under io_lock before its
first RX submit and releases it once no RX transfer is left in flight; an
MT_RX_DRAIN_SYNC caller holds it until the receiver is switched off; every
transition is a compare-exchange from the expected owner. A reply on time
costs nothing; a late one costs one mt_rx_flush(), which now returns the
bytes drained (partial reads included). The drain logs the command, length
and first word.

tests/mt7612u_busy_bringup.sh reproduces it with rxdemo + a txdemo peer
(build with -DDEVOURER_MT7612U=ON; it runs under mt7612u_sta_lib.sh's
root-run rules). One MT7612U, ch 100, an RTL8812EU peer 50 cm away at zero
gap: master 09c2ff3 stalled 6 of 6 at 20 MHz and 6 of 6 at HT40 (27.5 s,
9 timeouts each); with this change 10 of 10 and 10 of 10 reached the RX ring
in 1.18 s, 0.31 s over the quiet control. The odds rise with the peer's
load (a 6 Mb/s peer at a 500 us gap: 1 of 6 on master). Not measured: a
ring-less (TX-only) channel set under traffic, and whether the ten-read reply
budget could go back to five. docs/mt7612u.md has the table and limits;
src/mt7612u/CLAUDE.md replaces "bring this one up before the interferer" with
the mechanism; the busy-window probe's notes that relied on that rule are
updated (its loaded MediaTek arms stay skipped: not re-run with the sensor
started second); tests/README.md lists the reproducer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

mt7612u: drain EP 4 to prevent busy-channel bring-up stalls

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Drain EP 4 after a late MCU reply to unblock bring-up under channel traffic.
• Track EP 4 ownership so the drain cannot compete with active receivers.
• Add a busy-channel reproducer and document measurements and remaining limits.
Diagram

graph TD
  Cmd["MCU command"] --> Wait["EP 5 wait"] --> Late{"300 ms timeout?"} -->|Yes| Free{"EP 4 unowned?"} -->|Yes| Drain["Drain EP 4"] --> Wait
  Late -->|No| Done["Reply processed"]
  Free -->|No| Wait
  Readers["EP 4 readers"] -->|claim ownership| Free
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Prevent RX ingress during calibration
  • ➕ Could eliminate parked EP 4 data rather than clearing it after a timeout.
  • ➖ The calibration behavior that admits frames is not established; changing it risks hardware bring-up.

Recommendation: Keep the ownership-guarded drain as the measured, narrowly scoped mitigation. Investigate preventing RX ingress separately once the calibration path is understood.

Files changed (10) +356 / -26

Bug fix (4) +86 / -17
async.cppTrack RX ring ownership of EP 4 +14/-1

Track RX ring ownership of EP 4

• Claims EP 4 under io_lock before RX submissions and releases it after transfers finish. A stranded RX transfer retains ownership and is identified in the error log.

src/mt7612u/async.cpp

init.cppCount drained bytes and track synchronous RX ownership +25/-8

Count drained bytes and track synchronous RX ownership

• Makes mt_rx_flush() return its byte count, including bytes from partial reads. Synchronous receivers claim EP 4 until RX is disabled or the MAC stops.

src/mt7612u/init.cpp

internal.hDefine device-level EP 4 ownership +16/-1

Define device-level EP 4 ownership

• Adds atomic NONE, RING, and SYNC ownership state that remains accessible during ring teardown. Updates the mt_rx_flush() declaration to return a byte count.

src/mt7612u/internal.h

mcu.cppDrain unowned EP 4 after a late MCU reply +31/-7

Drain unowned EP 4 after a late MCU reply

• On the first 300 ms reply timeout, drains EP 4 at most once per command when it is unowned, then continues waiting on EP 5. Logs nonzero drains with command context while retaining the existing reply budget.

src/mt7612u/mcu.cpp

Tests (1) +194 / -0
mt7612u_busy_bringup.shReproduce repeated bring-ups under peer traffic +194/-0

Reproduce repeated bring-ups under peer traffic

• Adds a root-run harness that times a quiet control and repeated loaded bring-ups, checks that the peer is heard, and reports slow runs. Reuses station-harness ownership and cleanup rules and reports inconclusive runs separately.

tests/mt7612u_busy_bringup.sh

Documentation (5) +76 / -9
mt7612u.mdRecord busy-channel bring-up evidence +35/-0

Record busy-channel bring-up evidence

• Adds baseline and post-fix timings, the reproducer setup, observed EP 4 traffic, and measurement limits.

docs/mt7612u.md

CLAUDE.mdExplain MCU reply stalls and the EP 4 drain +23/-4

Explain MCU reply stalls and the EP 4 drain

• Replaces the advice to start before an interferer with the current understanding of RXDCOC-era EP 4 traffic and the timeout-triggered mitigation.

src/mt7612u/CLAUDE.md

README.mdDocument the busy bring-up reproducer +12/-0

Document the busy bring-up reproducer

• Describes its loaded-peer setup, build requirement, root-run safeguards, and why a lightly loaded channel is insufficient.

tests/README.md

busy_window_probe.cppRemove outdated bring-up ordering advice +2/-3

Remove outdated bring-up ordering advice

• Points readers to the busy-channel reproducer instead of saying an MT7612U must start before the interferer.

tests/busy_window_probe.cpp

busy_window_probe.shClarify why loaded MediaTek arms remain skipped +4/-2

Clarify why loaded MediaTek arms remain skipped

• Explains that the arms have not been re-run with the sensor started second; the skip remains in place.

tests/busy_window_probe.sh

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

qodo-free-for-open-source-projects Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Partial reads can leave replies stalled ✓ Resolved
Description
mt_rx_flush() counts bytes delivered by a timed-out EP 4 read but still stops on that timeout, and
mcu_wait_resp() marks its only drain attempt complete. When a read times out after moving some
bytes while more data remains queued, the remaining EP 4 data can hold back the reply despite the
new recovery path.
Code

src/mt7612u/init.cpp[R269-273]

+		/* A read that times out can still have moved bytes. */
+		if (n > 0)
+			total += (size_t)n;
+		if (rc || n == 0)
  	break;
Evidence
The bulk wrapper reports transfer status and actual length separately, including on timeout. The
flush exits for any nonzero status, while the MCU wait sets its one-shot flag immediately after that
flush.

src/mt7612u/usb.cpp[288-313]
src/mt7612u/init.cpp[263-275]
src/mt7612u/mcu.cpp[43-60]

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

## Issue description
A timed-out EP 4 read can report transferred bytes, but the flush treats that partial read as the end of the queue and the MCU wait does not retry its drain.
## Fix Focus Areas
- src/mt7612u/init.cpp[263-275]
- src/mt7612u/mcu.cpp[51-59]
## Recommended Fix
Continue the bounded flush after a timeout that reports positive bytes. Treat a timeout with no bytes as the empty-queue exit, and retain a bounded way to retry if the flush reaches its cap while data is still arriving.

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


2. Unloaded runs can pass the busy-channel test ✓ Resolved
Description
mt7612u_busy_bringup.sh checks peer liveness only once before the loaded loop and accepts any
positive aggregate count of received peer frames. If the peer exits after an early run that heard
it, later bring-ups can run without the intended load and the script can still print PASS.
Code

tests/mt7612u_busy_bringup.sh[R185-188]

+if [ "$heard" = 0 ]; then
+  echo "INCONCLUSIVE: no bring-up heard the peer - the channel was not loaded"
+  exit 2
+fi
Evidence
The sole peer-process check precedes the loop. The loop sums the per-run heard values, and the final
validation rejects only a zero total.

tests/mt7612u_busy_bringup.sh[159-179]
tests/mt7612u_busy_bringup.sh[181-193]

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

## Issue description
A single peer frame across all repetitions satisfies the load check, even if the peer exits before later bring-ups.
## Fix Focus Areas
- tests/mt7612u_busy_bringup.sh[161-179]
- tests/mt7612u_busy_bringup.sh[181-193]
## Recommended Fix
Check that the peer remains live for each repetition and require each completed loaded run to hear a canonical peer frame. Return INCONCLUSIVE rather than PASS when either check fails.

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



Informational

3. RX ring can start without owning the endpoint ✓ Resolved
Description
mt_async_start() submits RX transfers even when its NONE→RING ownership claim fails, and
mt_mac_start(MT_RX_DRAIN_SYNC) enables synchronous RX even when its claim fails. If either mode
starts while the other owns EP 4, both readers can consume its data; a later SYNC→NONE release can
also let mcu_wait_resp() drain and discard ring frames, though no current caller combines SYNC
with a ring.
Code

src/mt7612u/async.cpp[R221-224]

+		d->io_lock.lock();
+		int none = MT_EP4_NONE;
+		d->ep4_owner.compare_exchange_strong(none, MT_EP4_RING);
+		d->io_lock.unlock();
Evidence
The ring’s unchecked compare-exchange is followed by RX submissions, while synchronous startup also
proceeds from an unchecked claim to enabling RX and reading EP 4 independently.
mt_mac_rx_disable() and mt_mac_stop() release SYNC to NONE without checking for a live ring, and
mcu_wait_resp() drains EP 4 when the owner is NONE. The only current SYNC caller is bringup gate
F, which has no ring, so this is an internal API invariant gap rather than a currently exercised
overlap.

src/mt7612u/async.cpp[218-237]
src/mt7612u/init.cpp[329-335]
src/mt7612u/mcu.cpp[43-60]
src/mt7612u/async.cpp[219-240]
src/mt7612u/init.cpp[304-315]
src/mt7612u/tools/bringup.cpp[1017-1061]
src/mt7612u/rx.cpp[159-173]

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

## Issue description
Both receive modes can continue starting after a failed EP 4 ownership claim. In particular, a ring started while SYNC owns EP 4 can remain live after SYNC releases its claim, allowing `mcu_wait_resp()` to drain its frames.
## Fix Focus Areas
- src/mt7612u/async.cpp[218-240]
- src/mt7612u/init.cpp[301-315]
## Recommended Fix
Check both compare-exchange results before starting reception. In `mt_async_start()`, if the claim fails and the observed owner is not RING, log an ERR that a synchronous drainer owns EP 4, unlock `io_lock`, and go to the existing `fail` cleanup path, which calls `mt_async_stop()`; do not submit RX transfers. In `mt_mac_start(MT_RX_DRAIN_SYNC)`, return an error on a failed claim before enabling synchronous RX.

ⓘ 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 route each severity your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/mt7612u/init.cpp Resolved
Comment thread tests/mt7612u_busy_bringup.sh Resolved
Comment thread src/mt7612u/async.cpp Resolved
…efused claims

From the review on OpenIPC#510:

- mt_rx_flush() ended on a read that timed out after moving bytes, though
  more may be queued behind them; it now ends on an empty read, a hard error
  or its read budget. mcu_wait_resp() drained once per command; it drains
  again on a later timeout if the previous drain found data, all of a
  command's drains sharing one 64-read budget, so the per-command bound stays
  3 s of reply reads + 0.64 s.
- mt_async_start() submitted RX transfers, and mt_mac_start(MT_RX_DRAIN_SYNC)
  enabled RX, even when their ep4_owner claim failed. Both now refuse. The
  SYNC claim is taken under io_lock (the lock the drain's owner check holds)
  and before any register write, so a refusal leaves a running ring's
  receiver and filter untouched; a SYNC claimed by a call that then fails is
  released.
- tests/mt7612u_busy_bringup.sh: the peer is checked before every loaded
  bring-up and every bring-up that comes up must hear it, else INCONCLUSIVE;
  a slow bring-up is FAIL ahead of that.
- CLAUDE.md scoped: the MCU-wait drain never shares EP 4 with a ring or a
  synchronous drainer; the teardown/recovery flushes do not consult the owner.

Hardware, same rig, master d0a9e10 as control: master 3/3 slow at each width
(27.5 s); this 10/10 + 10/10 fast (1.18-1.20 s; one at 0.87 s needed no
drain), and 20/20 with an RTL8812BU peer. docs/mt7612u.md has the table.
ctest 86/86 with -DDEVOURER_MT7612U=ON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@snokvist

Copy link
Copy Markdown
Collaborator Author

Qodo's three findings are addressed in 6c84a49. There's a reply in each thread.

Before pushing, two independent reviewers went over the diff. They found two more things, both fixed in the same commit:

  • The SYNC claim needed io_lock. Without it, the drain's owner check and the claim could interleave.
  • A refused SYNC start still changed state. It wrote MAC_SYS_CTRL and the receive filter before its ownership check, so a refusal could leave a running ring deaf. The claim now comes before any register write.

Bound. All of one command's drains share one 64-read budget, so the per-command worst case is unchanged: 3 s of reply reads plus 0.64 s.

Hardware. Same rig as before: one MT7612U, ch 100, the peer 50 cm away, tests/mt7612u_busy_bringup.sh, master d0a9e10 as the control.

peer, load master this
RTL8812EU, MCS3, zero gap, 20 MHz 3 of 3 slow (27.5 s, 9 timeouts) 10 of 10 at 1.18–1.20 s, one drain each (4.3–10.1 kB)
RTL8812EU, MCS3, zero gap, 40 MHz 3 of 3 slow (27.5 s, 9 timeouts) 10 of 10: nine at 1.18–1.19 s with one drain (15.8–17.3 kB); one at 0.87 s with no drain needed
RTL8812BU, MCS3, zero gap, 20 and 40 MHz not run 20 of 20 at 1.18–1.19 s, one drain each

Limits.

  • No bring-up needed a second drain, so the new re-drain path is unexercised on hardware.
  • Still one MT7612U unit, one channel, near field.
  • The table is in docs/mt7612u.md.
  • ctest 86/86 with -DDEVOURER_MT7612U=ON.

🤖 Generated with Claude Code

https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge

@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.

Review: PR 510 (mt7612u: drain EP 4 when an MCU reply is late)

Verdict: request changes. The drain fixes the stall it targets, and I reproduced both the stall and the fix on this bench. But the revised mt_rx_flush exit rule (commit 6c84a49, the Qodo "partial reads" fix) adds ~2 s to EVERY MT7612U bring-up on this host, quiet channel included.

Reproduced (this bench)

One MT7612U (0e8d:7612, 4-2.3.2, USB 3 hub), peer RTL8812EU (0bda:a81a, USB 2 hub), ch 100, 20 MHz, tests/mt7612u_busy_bringup.sh from the PR branch. The 8812EU on this USB 2 hub aborts txdemo at zero gap (8 consecutive bulk-OUT timeouts), so the peer ran at PEER_GAP_US=300, MCS3/20/LDPC. Firmware had to be decompressed by hand (see nit 1).

build control (quiet) loaded bring-ups
master d0a9e10 (fresh worktree, same configure) 1.79 s 3 of 3 + 1 of 1 slow: 28.3 s, 9 MCU timeouts each
PR 6c84a49 3.79–3.84 s (2 runs) 6 of 6 + 1 of 1 fast: 4.62–4.72 s, 0 timeouts, one drain each of 243–249 kB
PR + candidate fix below 1.79 s 4 of 4 fast: 2.04–2.11 s, 0 timeouts, one drain each of 11–14 kB

The regression

strace of a quiet-channel PR bring-up showed 320 intervals of exactly 10 ms. Instrumenting mt_rx_flush (scratch build): every call on the bring-up path reads 64 × 4096 B, 651 ms, rc = LIBUSB_ERROR_TIMEOUT, n = 4096 on every read, with the buffer returned all zeros over a 0xA5 sentinel fill. So on this host (libusb 1.0.30, kernel 6.18, xhci) a timed-out bulk-IN read of an idle EP 4 reports actual_length == len with no data. The new rule

if (n == 0 || (rc && rc != LIBUSB_ERROR_TIMEOUT))
    break;

treats that as "bytes moved, keep going" and runs the whole 64-read budget. Three flushes sit on bring-up (mt_init_hardware, mt_mac_stop on the previous session's state, …) → ~2 s. The loaded drains are the same thing: 243–249 kB "drained" is ~10 kB of real data plus ~50 phantom 10 ms reads, which is why the loaded bring-up sits 0.8 s above the control here instead of your 0.31 s. On your rig the timed-out read evidently reports 0, which is why your control did not move.

Candidate fix (verified above)

Keep the PR's re-drain logic and go back to "any error ends the drain":

/* A timed-out read on an idle EP 4 reports n == len with no data on
 * some hosts, so n is not a safe continue condition. A drain that ended
 * on a timeout with bytes is re-run by mcu_wait_resp() on its next reply
 * timeout (ep4_last > 0), which covers the genuinely partial read. */
if (n > 0)
    total += (size_t)n;
if (rc || n == 0)
    break;

The Qodo concern (partial read, more queued) is already handled by the caller's re-drain path; it does not need the loop to continue past a timeout. Please re-run your 20/40 MHz cells with this — on this bench control 1.79 s / loaded 2.04–2.11 s with 11–14 kB drains, which matches your 8.6–10.1 kB. docs/mt7612u.md and src/mt7612u/CLAUDE.md describe the "not on a timed-out read that moved bytes" rule, so they need the same edit.

Smaller points

  1. tests/mt7612u_busy_bringup.sh does not call sta_fw_readable / sta_fw_link like its siblings (mt7612u_sta_identity.sh etc.). On a stock linux-firmware host (*.bin.zst only) the control dies with "firmware not found" and the script prints INCONCLUSIVE: the DUT did not come up on a quiet channel (DIED) — the sibling scripts name the real cause.
  2. docs/mt7612u.md "Bring-up on a busy channel": the "After review (…)" paragraph and "across the drain's revisions" narrate PR revision history and give two master baselines (09c2ff3, d0a9e10). Current-state docs only: one table against one master, numbers from the final code.
  3. tests/busy_window_probe.sh: "(its bring-up stall is fixed: …)" is the same historical phrasing. Say what is true now ("the MediaTek sensor can start second; these arms have not been re-run that way").
  4. Ownership logic itself reads correctly: RING claimed under io_lock before the first submit, released only with rx_inflight == 0; SYNC claimed before any register write and released by mt_mac_rx_disable / mt_mac_stop; mt_async_start's failed claim goes through fail → mt_async_stop, whose RING→NONE CAS is a harmless no-op there. mt7612u_dev is new-constructed so the std::atomic initialiser runs. No findings.

snokvist and others added 3 commits October 30, 2026 17:08
…ot data

6c84a49 kept draining past a timed-out read that reported bytes. On one
host (libusb 1.0.30, kernel 6.18, xhci) a timed-out read of an idle EP 4
reports n == len with no data, so every flush ran its whole 64-read budget:
~2 s on every bring-up, quiet channel included (review 5708821119).

Any error now ends the drain, a timeout included; a drain that ended on a
timeout with real data is re-run by mcu_wait_resp() on its next reply
timeout. A timed-out read reporting a full buffer is also not counted:
a read that really fills the buffer completes, and counting the artefact
would make every MCU-wait drain look like data, re-drain on each later
reply timeout and log bytes that were never there.

docs/mt7612u.md: one table against master d0a9e10, numbers from this code.
Same rig (MT7612U 8-1, RTL8812EU peer at MCS3 zero gap, ch 100): master 3 of
3 slow at 20 and at 40 MHz (27.5-27.6 s, 9 timeouts each); this code 10 of
10 at each width, 1.18-1.20 s, one drain each (8.6-10.1 kB at 20 MHz,
15.8-17.3 kB at 40 MHz), control 0.86-0.87 s. Not run on the host that
shows the artefact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
The script never checked the MT7612U blobs, so a host with compressed
firmware only died in the control and printed a misleading INCONCLUSIVE
('did not come up on a quiet channel'). It now picks the directory the
library will load from (FW_DIR, /lib/firmware/mediatek, ./firmware, as
resolve_fw_dir and sta_client_onair.sh do) and refuses through
sta_fw_readable, which names the compressed files. No ./firmware link is
needed: rxdemo takes the directory from DEVOURER_MT7612U_FW_DIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
busy_window_probe.sh said the stall 'is fixed'; it now says what is true:
the MediaTek sensor can start second, and these arms have not been re-run
that way. tests/README.md said 'on master', which stops being true when
this merges: 'without the drain'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@snokvist

Copy link
Copy Markdown
Collaborator Author

Thanks. That's a real regression and a precise diagnosis. Addressed in cee80f4, 2da8bf1 and 447ec95.

Exit rule: your rule as given. Any error ends the drain, a timeout included, and a drain that ended on a timeout with data is re-run by mcu_wait_resp() on its next reply timeout.

One deliberate addition: a timed-out read reporting a full buffer is not counted toward the drained bytes. A read that really fills the buffer completes with rc 0, so rc == TIMEOUT && n == len can only be the artefact you found. Counted, it sets ep4_last on every MCU-wait drain on your host. Each later reply timeout of that command then re-drains, and the log says drained 4096 B for bytes that were never there. Your 11–14 kB drains may each include one such phantom 4 kB read. A real partial read that times out (0 < n < len) is still counted, so the re-drain still covers it. I can't reproduce the artefact here (this host reports what a timed-out read really moved), so this variant is unmeasured on your host. A run of tests/mt7612u_busy_bringup.sh there would settle it.

Re-run, same rig as before (MT7612U 8-1, RTL8812EU peer at MCS3 with zero gap, ch 100, near field), against master d0a9e10:

peer during bring-up master d0a9e10 this branch
none (control) 0.86–0.87 s 0.86–0.87 s
MCS3 zero gap, 20 MHz 3 of 3 slow (27.6 s, 9 timeouts) 10 of 10 at 1.18–1.20 s, one drain each of 8.6–10.1 kB
MCS3 zero gap, 40 MHz (HT40+) 3 of 3 slow (27.5–27.6 s, 9 timeouts) 10 of 10 at 1.18 s, one drain each of 15.8–17.3 kB

No bring-up needed a second drain, so that path is still unexercised on hardware. The 0.31–0.33 s over the control is the first 300 ms reply timeout. ctest passes 86/86 with -DDEVOURER_MT7612U=ON.

Docs: src/mt7612u/CLAUDE.md, docs/mt7612u.md and the mt_rx_flush declaration in internal.h now describe the new rule.

Nits:

  1. mt7612u_busy_bringup.sh now picks the firmware directory the library will load from, in resolve_fw_dir's order as sta_client_onair.sh does. It refuses through sta_fw_readable, which names the .zst files. No sta_fw_link is needed: rxdemo takes the directory from DEVOURER_MT7612U_FW_DIR.
  2. "Bring-up on a busy channel" is current-state only: one table against d0a9e10, numbers from this code. The review-history paragraph, the second baseline and the cross-revision counts are gone. Your host's behaviour is recorded as such, with the note that this exact code hasn't run there.
  3. busy_window_probe.sh now says the sensor can start second and those arms have not been re-run that way. tests/README.md no longer says "on master".

@josephnef

Copy link
Copy Markdown
Collaborator

Re-reviewed 447ec95 (the three commits since my change request).

Code: the exit rule is the one I verified here, plus the full-buffer-timeout exclusion. That exclusion reads correctly to me: a read that really fills the buffer completes with rc == 0, so rc == TIMEOUT && n == len can only be the artefact, and on this host it is what every idle-EP 4 read returns, so without it ep4_last would be nonzero after every drain and each later reply timeout of the same command would re-drain. The mcu_wait_resp() re-drain path and the shared 64-read budget are unchanged. Built with -DDEVOURER_MT7612U=ON: no warnings in src/mt7612u/, ctest 86/86.

Docs and nits: all three addressed as asked. The "Bring-up on a busy channel" section is current-state only now, and it says plainly that this exact code has not run on the host that shows the artefact. busy_bringup.sh now refuses a .zst-only firmware host through sta_fw_readable.

Hardware re-run on this host: not done yet. The MT7612U that showed the n == len timeout (slot #17, hub 4-2.3 port 2) is not on the bus today, so I cannot run tests/mt7612u_busy_bringup.sh against 447ec95 here. I am holding the approval until that run exists, since this variant is unmeasured on the only host known to produce the artefact. Expected from the earlier candidate-fix build: control ~1.79 s, loaded ~2.0–2.1 s with one drain of ~11–14 kB, minus one phantom 4 kB read.

@josephnef
josephnef marked this pull request as draft October 30, 2026 19:24
@josephnef

Copy link
Copy Markdown
Collaborator

Converted to draft until the busy-bringup re-run on the host that shows the full-buffer timeout exists (its MT7612U is off the bus at the moment). Mark it ready again once that run is posted, or once you are happy to ship without it.

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