Skip to content

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

Merged
josephnef merged 6 commits into
OpenIPC:masterfrom
snokvist:mt7612u-rxdcoc-ep4-drain
Oct 31, 2026
Merged

josephnef merged 6 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

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

Copy link
Copy Markdown

PR Summary by Qodo

mt7612u: Drain EP 4 when busy-channel MCU replies are late

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Drain unread EP 4 data after MCU reply timeouts to prevent RXDCOC from stalling bring-up.
• Track EP 4 ownership so MCU waits cannot drain an active receiver’s frames.
• Add a busy-channel reproducer and document measured results and remaining limits.
Diagram

graph TD
  CMD["MCU command"] --> WAIT["EP 5 wait"] --> LATE{"Reply timeout?"} -->|yes| FREE{"EP 4 free?"} -->|yes| DRAIN["EP 4 drain"] --> WAIT
  LATE -->|no| DONE["Reply handled"]
  FREE -->|no| WAIT
  RING["RX ring"] -->|claims ownership| FREE
  SYNC["Sync reader"] -->|claims ownership| FREE
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Block EP 4 traffic during RXDCOC
  • ➕ Could prevent the endpoint backlog rather than clearing it after a timeout.
  • ➖ The mechanism that admits frames during calibration is not established, making a safe fix premature.
2. Drain EP 4 before every MCU reply wait
  • ➕ Would not wait for the first reply timeout to clear a backlog.
  • ➖ Adds USB reads to healthy commands and still requires exclusive EP 4 ownership.

Recommendation: Keep the timeout-triggered, ownership-guarded drain: it addresses the measured stall without adding reads to timely replies. Investigate preventing RXDCOC traffic separately once its entry path is known.

Files changed (10) +443 / -28

Bug fix (4) +133 / -19
async.cppClaim EP 4 for the asynchronous RX ring +22/-1

Claim EP 4 for the asynchronous RX ring

• Claims ownership under the I/O lock before submitting RX transfers and refuses a conflicting reader. Releases ownership after transfers are reaped, retaining it if RX transfers remain stranded.

src/mt7612u/async.cpp

init.cppMake EP 4 flushing bounded and track synchronous ownership +55/-10

Make EP 4 flushing bounded and track synchronous ownership

• Returns drained byte counts and supports a shared read budget, stopping on errors and excluding a known full-buffer timeout artifact. Claims EP 4 for synchronous receive before MAC start and releases that claim when receive stops.

src/mt7612u/init.cpp

internal.hDefine device-level EP 4 ownership and drain interface +20/-1

Define device-level EP 4 ownership and drain interface

• Adds atomic NONE, RING, and SYNC ownership to the device so it remains available during ring teardown. Declares the byte-counting, optionally budgeted flush interface.

src/mt7612u/internal.h

mcu.cppDrain unowned EP 4 data after late MCU replies +36/-7

Drain unowned EP 4 data after late MCU replies

• On a reply timeout, drains EP 4 only when it has no receiver owner; later timeouts can trigger another drain if the previous one moved data, within one per-command budget. Logs successful drains with command context and retains the existing ten-read reply budget.

src/mt7612u/mcu.cpp

Tests (1) +209 / -0
mt7612u_busy_bringup.shAdd repeated hardware regression for loaded bring-up +209/-0

Add repeated hardware regression for loaded bring-up

• Starts a Realtek transmitter, times repeated MT7612U starts to the RX ring, and checks that each run hears the peer. Includes a quiet control, explicit inconclusive outcomes, and adapter cleanup through the shared station harness.

tests/mt7612u_busy_bringup.sh

Documentation (5) +101 / -9
mt7612u.mdDocument busy-channel bring-up measurements +42/-0

Document busy-channel bring-up measurements

• Adds baseline and drain-enabled timings, the observed RXDCOC endpoint behavior, host-specific timeout caveats, and limits of the hardware measurements.

docs/mt7612u.md

CLAUDE.mdReplace the bring-up ordering rule with EP 4 guidance +40/-4

Replace the bring-up ordering rule with EP 4 guidance

• Explains how queued EP 4 data delays EP 5 replies, when MCU waits drain it, and how receiver ownership prevents competing reads. Replaces advice to start the adapter before an interferer.

src/mt7612u/CLAUDE.md

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

Describe the busy-bring-up reproducer

• Documents the hardware setup, repeated loaded bring-ups, control run, build requirement, and why a lightly loaded peer cannot establish a meaningful pass.

tests/README.md

busy_window_probe.cppRemove obsolete interferer-order warning +2/-3

Remove obsolete interferer-order warning

• Replaces the warning that an MT7612U must start before interference with a reference to the dedicated busy-bring-up reproducer.

tests/busy_window_probe.cpp

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

Clarify why loaded MediaTek probe arms remain skipped

• Updates the skip message to acknowledge busy-channel bring-up while preserving the skip until those probe arms are rerun with the sensor starting second.

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

@snokvist

Copy link
Copy Markdown
Collaborator Author

I tried to reproduce the full-buffer timeout here, so that 447ec95 could be measured without your slot #17 MT7612U. I couldn't make it happen on this bench.

Setup:

  • MT7612U on root port 8-1 (USB 3, 5 Gb/s, AMD xHCI), kernel 7.0.
  • RTL8812EU peer at MCS3/20/LDPC with zero gap on ch 100.

The shim: an LD_PRELOAD wrapper around libusb_submit_transfer (source below).

  • It fills each MT7612U bulk-IN buffer with 0xA5 before submit.
  • It logs every completion: status, length, actual_length, and whether [0, actual_length) holds data, zeros or the untouched fill.
  • It also logs which libusb each process actually loaded.

Runs:

  • tests/mt7612u_busy_bringup.sh on 447ec95, under the shim. REPS=5 once against the system libusb 1.0.29 (Ubuntu build) and once against 1.0.30 built from the release tarball with --disable-udev. Then a second pass at REPS=3 with the version logging.
  • An idle-EP 4 sweep: adapter up, MAC RX off, 50 mt_bulk() reads at each of 512 / 1024 / 4096 / 16384 B × 1 / 5 / 10 / 50 ms, on both versions.

Results:

  • The like-for-like evidence is the bring-up flushes themselves, the reads you instrumented. Every timed-out EP 4 read returned actual_length == 0, on both versions. Here a flush ends on its first read, so the control comes up in 0.87 s.
  • Across all runs, 1,724 EP 4 reads timed out. None reported a full buffer, and none reported any bytes. The 16 ring URBs cancelled at each exit (96 per version) were empty too. Since you saw the artefact on every idle read, it is a deterministic property of that host, not a rare event that a few thousand reads might miss.
libusb 1.0.29 libusb 1.0.30
control 0.87 s 0.87 s
loaded, first pass 5 of 5 at 1.19–1.20 s, one drain each of 4320–10080 B 5 of 5 at 1.19–1.20 s, one drain each of 8640–10080 B
loaded, second pass 3 of 3 at 1.20 s 3 of 3 at 1.19–1.20 s
idle sweep 804 timed-out reads, all actual_length == 0 804 timed-out reads, all actual_length == 0

How to read these numbers:

  • The times come from the script's 100 ms poll, so they agree to that grain only.
  • "0 MCU timeouts" means none of the 3 s reply timeouts. Every loaded bring-up still had exactly one 300 ms EP 5 reply timeout followed by the drain, and that is where the extra ~0.3 s goes.
  • Per version, the 35 bring-up timeouts break down as 23 bring-up flushes, 5 of them inside the MCU-wait drain, plus 12 teardown flushes.

libusb doesn't look like the difference.

  • 1.0.30 behaves the same as 1.0.29 here.
  • In its diff from 1.0.29, the Linux submit, reap, cancel and timeout paths are untouched, and sync.c and events_posix.c are identical. The changes are in enumeration and device-list handling, the disconnect signature, logging guards, and an iso-only field.
  • A timed-out read reports whatever actual_length the kernel returned for the URB. So a full count with no data looks to come from the kernel or the controller. A usbmon capture of one idle read on your host would show which.

What's left:

  • What your host has and this one doesn't: kernel 6.18, its xHCI controller, and the USB 3 hub at 4-2.3.2. There's no hub on this bench, and I can't run 6.18 here.
  • If you can post lspci -nn | grep -i usb and lsusb -t from that host, the record will name the controller and hub.

On this host the exclusion never fired, so these runs measure your exit rule alone. Only your host can show the exclusion catching a phantom read.

It also isn't airtight on a real full read.

  • libusb reports TIMED_OUT whenever its cancel succeeds. If the URB completes in the controller after DISCARDURB accepted it, the surplus data is kept, so a real full read can come back as TIMEOUT with n == len.
  • If that happens on the first read of an MCU-wait drain, ep4_last stays 0. That command then skips its re-drain, at a cost of up to its remaining reply budget. No data is lost.
  • It needs a single frame of 4 kB or more (RX aggregation is off), completing within microseconds of the 10 ms expiry. The 1.4 kB frames on this rig can't produce it at all.
  • If you want it closed, the cheap tightening is to treat a full-length timeout as a phantom only when its buffer is all zeros. Every phantom read you reported was all zeros, and a real read starts with a non-zero RXINFO length. I haven't pushed it, because it adds an assumption of its own: that a phantom read's buffer is always zeros, which only your host has shown. On a host whose phantom reads carried stale bytes, they would count again and cost a short extra re-drain on each later reply timeout. It trades one case this bench can't test for another. Happy to add it if you prefer that trade.

Limits: one adapter, one root port, one controller, one kernel. No hub, no USB 2 path, no 6.18.

You said you're holding approval until that run exists, or until we're happy to ship without it. Since this bench can't produce the artefact, I'd take the second option and mark this ready, unless you'd rather wait for slot #17. If it comes back, the shim below shows the phantom reads directly on any build.

inshim.c
// LD_PRELOAD shim: sentinel-fill every bulk-IN buffer of a MediaTek (0e8d)
// device before submit, and log each completion: endpoint, status, length,
// actual_length, and what [0, actual) holds (data / all-zero / untouched
// sentinel). A phantom read is a non-COMPLETED status with actual == length
// and no data. Log file: $INSHIM_LOG (default /dev/stderr).
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <libusb-1.0/libusb.h>

struct hook { libusb_transfer_cb_fn cb; void *ud; };
static int fd = -1;
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static unsigned long n_all, n_phantom, n_partial_to, n_data;

static double now_ms(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec * 1e3 + t.tv_nsec / 1e6; }
static const char *st(int s) {
  static const char *n[] = {"COMPLETED","ERROR","TIMED_OUT","CANCELLED","STALL","NO_DEVICE","OVERFLOW"};
  return s >= 0 && s <= 6 ? n[s] : "?";
}

static void LIBUSB_CALL on_done(struct libusb_transfer *t) {
  struct hook *h = t->user_data;
  int a = t->actual_length, zeros = 1, sent = 1;
  for (int i = 0; i < a; i++) { if (t->buffer[i]) zeros = 0; if (t->buffer[i] != 0xA5) sent = 0; }
  const char *what = a == 0 ? "empty" : zeros ? "ZEROS" : sent ? "SENTINEL" : "data";
  int phantom = t->status != LIBUSB_TRANSFER_COMPLETED && a == t->length && (zeros || sent);
  pthread_mutex_lock(&mu);
  n_all++; n_phantom += phantom; n_data += what[0] == 'd';
  n_partial_to += t->status != LIBUSB_TRANSFER_COMPLETED && a > 0 && !phantom;
  char line[200];
  int k = snprintf(line, sizeof line, "%.3f pid=%d ep=%02x st=%s len=%d act=%d %s%s\n", now_ms(), getpid(),
                   t->endpoint, st(t->status), t->length, a, what, phantom ? " PHANTOM" : "");
  if (write(fd, line, (size_t)k) < 0) {}
  pthread_mutex_unlock(&mu);
  t->user_data = h->ud; t->callback = h->cb; free(h);
  t->callback(t);
}

int LIBUSB_CALL libusb_submit_transfer(struct libusb_transfer *t) {
  static int (*real)(struct libusb_transfer *);
  if (!real) real = (int (*)(struct libusb_transfer *))dlsym(RTLD_NEXT, "libusb_submit_transfer");
  if (fd < 0) {
    const char *p = getenv("INSHIM_LOG"); fd = p ? open(p, O_WRONLY | O_CREAT | O_APPEND, 0644) : 2; if (fd < 0) fd = 2;
    const struct libusb_version *v = libusb_get_version();
    Dl_info di; dladdr((void *)real, &di);
    char l[400]; int k = snprintf(l, sizeof l, "LOADED pid=%d libusb %u.%u.%u from %s\n", getpid(), v->major, v->minor, v->micro, di.dli_fname);
    if (write(fd, l, (size_t)k) < 0) {}
  }
  struct libusb_device_descriptor dd;
  if (t->type != LIBUSB_TRANSFER_TYPE_BULK || !(t->endpoint & 0x80) ||
      libusb_get_device_descriptor(libusb_get_device(t->dev_handle), &dd) || dd.idVendor != 0x0e8d)
    return real(t);
  struct hook *h = malloc(sizeof *h);
  if (!h) return real(t);
  h->cb = t->callback; h->ud = t->user_data;
  memset(t->buffer, 0xA5, (size_t)t->length);
  t->callback = on_done; t->user_data = h;
  int rc = real(t);
  if (rc) { t->callback = h->cb; t->user_data = h->ud; free(h); }
  return rc;
}

__attribute__((destructor)) static void fini(void) {
  if (fd < 0 || !n_all) return;
  char line[200];
  int k = snprintf(line, sizeof line, "SUMMARY pid=%d in_completions=%lu data=%lu phantom=%lu partial_timeout=%lu\n",
                   getpid(), n_all, n_data, n_phantom, n_partial_to);
  if (write(fd, line, (size_t)k) < 0) {}
}

@snokvist
snokvist marked this pull request as ready for review October 31, 2026 09:10
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 447ec95

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

Approved at 447ec95. The exit rule is the one measured here; the full-buffer-timeout exclusion reads correct and its worst case on this host is a bounded re-drain inside the shared 64-read budget. Shipping without the slot #17 re-run as you proposed; when that adapter is back, your shim is the follow-up check.

@josephnef
josephnef enabled auto-merge (squash) October 31, 2026 11:13
@josephnef
josephnef merged commit 182e344 into OpenIPC:master Oct 31, 2026
26 checks passed
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