mt7612u: DEVOURER_MT7612U option, C++ portability, and a factory gate that stops the Jaguar1 misroute - #421
Conversation
PR Summary by QodoAdd MT7612U build support, safe factory gating, and FCS metadata
AI Description
Diagram
High-Level Assessment
Files changed (36)
|
Code Review by Qodo
1.
|
…onment qodo's first finding on OpenIPC#421, and it is right for a reason that is specific to this PR: OpenIPC#412 deferred the getenv while the subtree was unreachable, but DEVOURER_MT7612U now links that open path into libdevourer, so "the library picks its adapter from ambient process state" stopped being a bench-tool quirk and became a library property. A consumer with two adapters could claim the wrong one because of a variable it never set. open_selected() takes the selector as a parameter, mt7612u_open_selected() is the public way to pass it, and mt7612u_open() is that with NULL. bringup fills d->dev_selector from MT7612U_DEV before mt_open(), so the operator-facing spelling is unchanged. The library now reads no environment at all — the claim two comments made prematurely in the previous commit is finally true. The messages had to move with it. They named MT7612U_DEV, which the library no longer reads, so a consumer that is not bringup would have been told to set something it does not use. They now name "the selector" and point bringup users at the variable. tests/api_link.c covers the new entry point (26 now, still compiled as C), which is the test's whole purpose: a public declaration with no definition fails there rather than at the first caller. Verified on hardware, two adapters attached: MT7612U_DEV=2-1 and =5-1 each open their own unit (distinct EEPROM MACs 40:a5:ef:50:27:a1 and 40:a5:ef:5a:32:f8), and with no selector it still takes the first and warns. 59/59 ctest in all three configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
qodo's second finding on OpenIPC#421. I first deferred this to the backend PR on the grounds that a sink has nothing to connect to yet. That was inconsistent: I had just fixed the MT7612U_DEV getenv precisely because DEVOURER_MT7612U links this code into libdevourer and changes its exposure, and the same argument applies here with the same force. mt_diag() reimplemented devourer's logger rather than using it. It formats "devourer [%c] mt7612u: " and does one fwrite + fflush for per-line atomicity -- the same shape as Logger::emit and for the same stated reason -- while honouring none of that class's three controls: - set_diag_stream(): a host that redirects diagnostics still gets 79 call sites' worth on raw stderr. - set_level(): a host at Error still gets every Info line. - DEVOURER_LOG_MAX_LEVEL: compile-time stripping does not reach them. And on Android it is worse than cosmetic. Logger::emit has an __android_log_write branch; these writes do not, so on the platform devourer actually ships to they land nowhere a user can see. mt7612u_set_log_sink(fn, user) diverts every line. The sink gets the level letter and the BARE message, so a host applies its own prefix and nothing double-prefixes -- a devourer consumer forwards to Logger::info/warn/error and gets level gating, stream redirection and logcat for free. The prefix and the stderr write now live in one built-in default sink, which is the only place this library names stderr at all. Default stays stderr rather than silence. qodo suggested the reverse -- have applications install stderr explicitly -- but devourer's own Logger defaults to stderr too, so silencing this subtree by default would make it quieter than every other devourer component, and bringup would lose its output. A host that wants silence installs a no-op sink. Easy to flip if you would rather. tests/log_sink is the guard, and it tests the property that actually matters: that installing a sink DIVERTS rather than copies. A hook that receives a copy while stderr keeps getting the original looks identical in casual use and fixes nothing, so every case asserts the sink saw the line AND that stderr did not. Mutation-tested three ways -- sink-gets-a-copy, sink-gets-the-prefixed-line, and NULL-silences-instead-of-restoring -- each fails it. api_link covers the new entry point (27 now, still compiled as C). Verified on hardware: bringup's output is unchanged through the default sink, and an 8 s async RX run (11151 frames, 1391/s, rx_err=0) exercises the sink from the event thread. 59/59 ctest in all three configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
josephnef
left a comment
There was a problem hiding this comment.
Approving.
The factory gate is the right shape and the reasoning holds up. Two things I checked rather than took on trust:
- The pair-vs-vendor argument.
Mt7612uUsbIdsSelftestcross-checks against all 91 Realtek ids including the 64-entry Jaguar witness list, which is the half that matters — the interleaved vendors (Netgear, ELECOM, ASUS, TP-Link, Edimax, Mercury) all live there, and a Kestrel/8733B-only check would have been blind to exactly them. read_chip_idreturningnullopton transfer failure rather than a plain 0. Distinguishing "did not answer" from "answered 0x00" is what makes the unconditional-Jaguar1 fallback safe to close, and keeping a successful 0x00 as a value preserves the cold-boot transient behaviour.
Verified locally: -DDEVOURER_MT7612U=ON builds clean, 63/63 ctest. Default-OFF build also clean, 63/63, and still compiles the MediaTek gate — the header-only claim holds.
Two non-blocking notes:
-
mt_async_stop()clearsa->runningafter the drain loop but does notcv.notify_all(), so a thread parked inmt_async_tx_submit's slot wait never wakes — anddelete athen destroys the condition variable it is blocked on. #423 fixes this (6b73e9f, with a comment saying exactly why). Since this stack lands in order the exposure on master is only the window between this merge and #422's, but the two lines would be worth cherry-picking here if that window matters to you. -
mt_async_startincrementsrx_inflightafterlibusb_submit_transfersucceeds, sorx_done's decrement can land first and take the counter transiently negative. The net converges and I could not construct a live failure from it, but increment-before-submit / decrement-on-failure is the shape that cannot go wrong.
read_chip_id also now refuses on a single transfer failure with no retry. That is a strict improvement in signal over the old fall-through, but one retry is cheap insurance and would only ever cost a control transfer on the failure path.
…r1 fallback
CreateRadio ends in an unconditional Jaguar1 construction, and read_chip_id()
discarded its libusb return code, so any device that does not answer the Realtek
SYS_CFG2 vendor read arrived there as chip-id 0x00 and came up as an RTL8812AU.
Measured on the bench, the same transfer on both arms:
RTL8812AU 0bda:8812 rc=1 chip_id=0x04
MT7612U 0e8d:7612 rc=-7 LIBUSB_ERROR_TIMEOUT destination byte UNTOUCHED
The MediaTek arm was run with a 0xAA poison byte in the destination, and it
survived: the 0 that reached the dispatch was the caller's own initialiser, not
a reading. Two guards close that, both ahead of the Realtek read:
- Mt7612uUsbIds.h, the mt76 mt76x2u vid:pid set, gated the way Kestrel gates.
- read_chip_id() now returns nullopt when the transfer itself failed, which is
not the same as reading 0x00 — a successful 0x00 keeps its fall-through so
no cold-boot transient changes behaviour.
The gate is vid:pid and NOT "vid is not 0x0bda", which OpenIPC#419 asks for literally.
devourer already serves Realtek silicon behind ASUS, Edimax, D-Link, ZyXEL, MSI
and Mercury vendor ids, and five of the eleven MediaTek ids share a vendor id
with a Realtek table entry, so a vendor-id rule would refuse working adapters --
including the RTL8812AU this bench uses as its witness. The transfer-failure
guard covers the same intent without that cost: it catches every non-Realtek
part, named or not.
tests/mt7612u_usb_ids_selftest.cpp (ctest cell mt7612u_usb_ids) pins the one
property that makes a pre-SYS_CFG2 gate safe: the MediaTek set never claims a
device a Realtek table owns. The shared-vendor-id count is reported, not
asserted, so removing an OEM entry cannot fail the build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
…uilds it The subtree was reachable only from its own Makefile, on Linux. OpenIPC#419 asks for a DEVOURER_MT7612U option and for the pthread / nanosleep / clock_gettime plane to become <thread>/<chrono> so MSVC and mingw can build it. Both here. The sources become .cpp rather than staying C compiled as C++, so there is one compilation mode and no second path that can rot unnoticed. Cost, measured by compiling each file with g++ -std=c++20 -Wall -Wextra before changing anything: 12 sites in the library (11 implicit void* conversions, 1 uint8_t->enum), and 40 in bringup of which 32 cascaded from three _Atomic declarations. No C++ keyword collisions, no VLAs, no compound literals, no tentative definitions; the two designated initialisers were already in declaration order. The exported surface stays C. include/mt7612u/mt7612u.h already had an extern "C" guard, and tests/api_link.c is deliberately still compiled by $(CC), so the C ABI is tested rather than assumed: it resolves 25 entry points against the C++ objects, and nm shows the public symbols unmangled. Threading and timing: pthread_mutex_t -> std::mutex io_lock (recursive) -> std::recursive_mutex pthread_cond_t -> std::condition_variable_any pthread_t -> std::thread nanosleep -> std::this_thread::sleep_for clock_gettime(MONOTONIC) -> std::chrono::steady_clock condition_variable_any, not condition_variable, so it waits on the bare mutex and every lock site keeps its original shape — a much smaller diff through code whose teardown ordering is documented as a use-after-free hazard. These waits are teardown and TX back-pressure, never a hot path. Two things fell out. A constructed std::recursive_mutex member retires the io_lock_ready flag: a zeroed pthread_mutex_t was a valid NON-recursive lock, so an open path that skipped the explicit init self-deadlocked the PHY tick, and that is now unrepresentable. And the teardown's cond_timedwait deadline arithmetic became one wait_for, dropping a CLOCK_REALTIME dependence where a wall-clock step could stretch or skip the 2 s budget. The two structs holding those members moved from calloc/free to new (std::nothrow) T{} / delete — calloc never runs a constructor. {} still zeroes every scalar, and nothrow keeps the existing null checks meaningful. -Wclass-memaccess then caught a real defect this introduced: frame_shape memset over a constructed device. Its loop case now declares the device inside the loop, which keeps the per-iteration reset the memset was doing. Not ported: the flock adapter lock is _WIN32-guarded, not mirrored. It works by contending for the SAME lock file UsbDeviceLock uses, but on Windows that class is a named mutex, so a file lock would exclude nobody and mirroring the mutex would duplicate what the devourer path already owns. Unprotected there is a bare mt7612u_open() with no devourer around it — the bench tool's case, and the bench is Linux. CI is what verifies the Windows claim, since none of it can be built locally: DEVOURER_MT7612U=ON is set in the multi-platform matrix (gcc, clang, MSVC cl, macOS) and in the mingw job, plus one stripped build-configs cell so the subtree cannot quietly acquire a dependency on a Realtek chip's sources. Verified locally: both CMake configs build warning-free with 59/59 ctest, the stripped cell builds with 49/49, and make -C src/mt7612u check passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
Adding the Mt7612u enumerator was not free. bw_mask_for_generation ends in a catch-all that returns 20/40/80 PLUS kBw5|kBw10, describing the Realtek BB small-BW modes, and a new generation falls into it silently: old chain, ChipGeneration::Mt7612u -> 0x1f (kBw5|kBw10 set) new chain, ChipGeneration::Mt7612u -> 0x1c (20/40/80 only) every other generation -> unchanged MT_RATE_BW encodes 20/40/80/160 and nothing narrower, so there is no 5 or 10 MHz to select on this part — OpenIPC#419 lists exactly that among its known limits. Nothing calls this with Mt7612u today, which is precisely why it is worth fixing now: the first caller is the backend in the next block, and a capability claimed there is claimed silently. adapter_caps_selftest pins both the value and the narrowband bits directly, so a future generation added to the permissive arm cannot quietly re-acquire them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
That subtree moved from calloc/free to new/delete and its mutexes, condition variable and event thread are constructed members now, so a lifetime regression in it is the most likely way this port breaks. build-sanitizers had the option off, which meant none of those objects were compiled into the ASan/UBSan build at all. Verified locally at RelWithDebInfo with address+undefined: 59/59 ctest. The same sanitizers were also run against real hardware through the subtree's own bringup harness — 10 s of async RX on an MT7612U, 2623 frames, no ASan report, no UBSan report, and clean under detect_leaks=1, which is what actually exercises the ring's new/delete teardown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
…e C ABI
Two reviewers found four things worth fixing, two of them real defects.
THE ID TABLE COULD NOT SEE JAGUAR. The selftest cross-checked the MediaTek set
against KestrelUsbIds.h and Rtl8733bUsbIds.h — 27 ids. Jaguar1/2/3 dispatch by
chip-id and have no table in devourer, so 64 more were invisible, and every
vendor id where the two silicon families interleave lives in that 64. The
demonstration: adding
{0x0b05, 0x17d2}, /* ASUS USB-AC56 — an RTL8812AU */
to the table passed the old test while refusing a real Jaguar1 adapter before
the SYS_CFG2 read, with no second chance. The test now carries a 64-entry
witness list from reference/rtl8812au and checks all 91, asserts that coverage
count so a future trim cannot silently restore the blindness, and pins
0b05:17d2 by name.
THE TABLE WAS 11 OF 16, FROM THE WRONG SOURCE. I transcribed it from this
machine's kernel tree instead of reference/mt76 @ be5ce79 — the tree this port
was ported from, pinned so exactly this is re-verifiable. Five ids were missing.
Recomputed against the complete table and all 91 Realtek ids: still zero pair
collisions, but SIX shared vendor ids, not three. The interleaving is the real
argument and it was understated:
0x0846 Netgear MediaTek 9014, 9053 Realtek 9051, 9052, 9054
0x056e ELECOM MediaTek 400a Realtek 4007, 400b, 400d, ...
0x0b05 ASUS MediaTek 17eb, 180b, 1833 Realtek 17d2, 1817, 1852, ...
A one-digit slip in the Netgear entry refuses an RTL8814AU. The selftest now
pins those three neighbours directly.
EXCEPTIONS COULD CROSS THE extern "C" BOUNDARY. Both were proved by probe, not
argued:
- `catch (const std::system_error &)` on the std::thread construction is
narrower than the throw set: libstdc++ allocates the thread state with a
THROWING new inside the constructor, so OOM arrives as bad_alloc and
escaped. It would have skipped the teardown, leaving d->a live with
running=1 and evt_started=0, then unwound into a C caller with no handler.
- `new (std::nothrow) T{}` is not nothrow for these types. nothrow suppresses
a throw from the allocation FUNCTION only, and std::condition_variable_any
holds a shared_ptr<mutex> that allocates in its constructor.
Both are now try/catch(...) returning the existing -1 / NULL.
Also: three comments still said mt_dev_state_init/destroy manage io_lock, which
this branch had already made empty; one comment quoted a read_chip_id note the
same commit deleted; mt_usleep is no longer interruptible (measured: 10 ms vs
201 ms for a 200 ms request under 100 Hz SIGALRM) and now says so; the option's
help text said nothing about the refusal being unconditional; and the
mt7612u+jaguar1 CI cell cannot strip Jaguar1, so it no longer implies it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
…onment qodo's first finding on OpenIPC#421, and it is right for a reason that is specific to this PR: OpenIPC#412 deferred the getenv while the subtree was unreachable, but DEVOURER_MT7612U now links that open path into libdevourer, so "the library picks its adapter from ambient process state" stopped being a bench-tool quirk and became a library property. A consumer with two adapters could claim the wrong one because of a variable it never set. open_selected() takes the selector as a parameter, mt7612u_open_selected() is the public way to pass it, and mt7612u_open() is that with NULL. bringup fills d->dev_selector from MT7612U_DEV before mt_open(), so the operator-facing spelling is unchanged. The library now reads no environment at all — the claim two comments made prematurely in the previous commit is finally true. The messages had to move with it. They named MT7612U_DEV, which the library no longer reads, so a consumer that is not bringup would have been told to set something it does not use. They now name "the selector" and point bringup users at the variable. tests/api_link.c covers the new entry point (26 now, still compiled as C), which is the test's whole purpose: a public declaration with no definition fails there rather than at the first caller. Verified on hardware, two adapters attached: MT7612U_DEV=2-1 and =5-1 each open their own unit (distinct EEPROM MACs 40:a5:ef:50:27:a1 and 40:a5:ef:5a:32:f8), and with no selector it still takes the first and warns. 59/59 ctest in all three configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
qodo's second finding on OpenIPC#421. I first deferred this to the backend PR on the grounds that a sink has nothing to connect to yet. That was inconsistent: I had just fixed the MT7612U_DEV getenv precisely because DEVOURER_MT7612U links this code into libdevourer and changes its exposure, and the same argument applies here with the same force. mt_diag() reimplemented devourer's logger rather than using it. It formats "devourer [%c] mt7612u: " and does one fwrite + fflush for per-line atomicity -- the same shape as Logger::emit and for the same stated reason -- while honouring none of that class's three controls: - set_diag_stream(): a host that redirects diagnostics still gets 79 call sites' worth on raw stderr. - set_level(): a host at Error still gets every Info line. - DEVOURER_LOG_MAX_LEVEL: compile-time stripping does not reach them. And on Android it is worse than cosmetic. Logger::emit has an __android_log_write branch; these writes do not, so on the platform devourer actually ships to they land nowhere a user can see. mt7612u_set_log_sink(fn, user) diverts every line. The sink gets the level letter and the BARE message, so a host applies its own prefix and nothing double-prefixes -- a devourer consumer forwards to Logger::info/warn/error and gets level gating, stream redirection and logcat for free. The prefix and the stderr write now live in one built-in default sink, which is the only place this library names stderr at all. Default stays stderr rather than silence. qodo suggested the reverse -- have applications install stderr explicitly -- but devourer's own Logger defaults to stderr too, so silencing this subtree by default would make it quieter than every other devourer component, and bringup would lose its output. A host that wants silence installs a no-op sink. Easy to flip if you would rather. tests/log_sink is the guard, and it tests the property that actually matters: that installing a sink DIVERTS rather than copies. A hook that receives a copy while stderr keeps getting the original looks identical in casual use and fixes nothing, so every case asserts the sink saw the line AND that stderr did not. Mutation-tested three ways -- sink-gets-a-copy, sink-gets-the-prefixed-line, and NULL-silences-instead-of-restoring -- each fails it. api_link covers the new entry point (27 now, still compiled as C). Verified on hardware: bringup's output is unchanged through the default sink, and an 8 s async RX run (11151 frames, 1391/s, rx_err=0) exercises the sink from the event thread. 59/59 ctest in all three configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
58f508b to
cb356bb
Compare
…on a MediaTek adapter (#422) > **Stacked on #421**, which is stacked on #420. The diff shows all three; the > six commits belonging to this one are `b346c41`, `6cb1013`, `a43983c`, > `67ba793`, `17631f7`, `ed59854`. Merge the parents and this rebases to just > those. Happy to retarget if you would rather they went in together. Third block of #419: the `IRadio` backend. `CreateRadio` stops refusing MediaTek adapters and constructs one, so a devourer binary opens, receives and transmits on this part. ## It works, on hardware, both directions ``` Creating Mt7612uRadio (0e8d:7612) MT7612U firmware from .../firmware mt7612u: firmware running MT7612U up: ASIC 0x76120044 MT7612U monitor RX on channel 36 MT7612U RX stopped after 12000 frames {"ev":"rx.pkt","n":1,"len":238,"rate":4,"rssi":58} ``` `rate 4` is DESC_RATE6M, right for a 5 GHz beacon; `rssi 58` is −52 dBm through the 110 bias. A raw cast — the bug that cost the earlier attempt at this a debugging cycle — prints 193. Transmit is proven with devourer's own matcher rather than my inference. An RTL8812AU running `rxdemo` on ch36 while this backend transmitted: ``` {"ev":"rx.txhit","hits":19,"len":104,"rate":4,"crc":0,"rssi":72} ``` `rx.txhit` fires only on the canonical txdemo source address, so those are our frames and nothing else's — clean decode at −38 dBm. Worth recording that my **first** TX attempt was not evidence and I nearly reported it as such: comparing ambient frame counts on a third adapter gave 1807 frames without the transmitter and 2166 with it. A 20% rise coincident with TX, and no way to attribute a single frame, because neither tool filters by source address. ## Structure `Mt7612uRadio` derives from `IRadio` and **not** `IRtlRadio` — the Realtek-only members describe a register plane this silicon does not have. It does not use `RtlAdapter` either: that transport is 16-bit Realtek registers and Realtek bulk endpoints, while this part is 32-bit registers over EP0 vendor requests plus an in-band MCU on EP8/EP5, with firmware to upload before any of it answers. The C subtree is the transport, and the class owns one `mt7612u_dev`, **adopting** the handle `WiFiDriver` already opened, reset and claimed — reopening would race the caller's lock, and `libusb_reset_device()` would invalidate the caller's handle. Three orderings, each of which has cost hardware time: 1. **Ring first, receiver second**, and the mirror image on teardown — `mt7612u_rx_quiesce()` then `mt7612u_rx_stop()`. Enabling MAC RX with nothing draining bulk-IN wedges this part *below* the USB level, where only a physical replug recovers it. That quiesce is a new public entry point: `mt7612u_stop()` would also silence the receiver but stops TX with it. 2. **The monitor filter goes on AFTER `mt7612u_start()`**, which rewrites `MT_RX_FILTR_CFG` to mt76's managed-station value. Measured against the bring-up harness in the same minute on the same silicon: 0 OFDM frames of 244 with the managed filter, 103 of 402 with the monitor filter. A single-path test would have called 244 beacons a working receiver. 3. **The 1 Hz PHY tick** runs on a thread the class owns, not inside `StartRxLoop`, because a transmit-only consumer needs it too. Instrumented to confirm it actually fires: 10 ticks in a 12 s window. ## What review changed, because it is most of the value here Four reviewers over two rounds. They found two use-after-frees, a way to wedge the hardware, four wrong numbers and a CI break. The ones worth your time: **RX was delivered on the C library's event thread.** That thread is the sole servicer of both RX and TX completions, so a packet processor that transmits parks it waiting for a TX slot only that same thread can free — MAC RX stays on, EP4 stops draining, and the part wedges. `examples/chanmig` already calls `send_packet` from its RX callback, so this was reachable from a shipped consumer. I had documented the divergence and dismissed it with "the guarantee that matters is preserved", having picked the wrong guarantee: the one that matters is the one every Realtek backend keeps, that the processor runs on the thread which called `StartRxLoop`. It does now, via a bounded hand-off queue; the loop that used to sleep 20 ms does the delivering. Overflow drops the newest frame and **counts** it — 0 un-instrumented, 32 under TSan where the processor is slowed, and the teardown line says so. **A use-after-free on teardown, then the same class again in its own fix.** `StopRxLoop` let a second caller return while the first was still inside `mt7612u_rx_stop()`, so `Stop()` freed the device under it. Fixed with a teardown lock — and the fix then reintroduced it one function up by moving `mt7612u_rx_stop()` out of `_mu` in `StartRxLoop`'s failure arm without extending that lock over it. **`snr[1]` was chain A's SNR.** `info.snr_db` is `rssi[0] - noise`, so writing it into every slot reported two identical per-chain SNRs — which is exactly how a dead chain-B antenna hides, its RSSI dropping while its SNR appears to track chain A. And the unit was whole dB where every consumer reads half-dB (`LinkHealth` divides by two), so every link was published at half its SNR with the derived noise floor ~20 dB high. My selftest asserted the wrong value **and pinned it** — written against my implementation instead of the consumers' contract. **`mt7612uprobe` would have failed CI on the first push.** It is in `all`, the matrix passes `DEVOURER_MT7612U=ON` on the MSVC cell, and `bringup.cpp` has `<unistd.h>`, `<sys/resource.h>` and `getrusage()` with zero `_WIN32` guards. Gated on UNIX, and the workflow comment I wrote claiming the subtree was MSVC-clean now says which part is not. Also folded: `GetTxStats().failed` double-counted and went *backwards* across an RX restart; `SetTxPowerOffsetQdb` returned an offset the rail clamp had not applied; the TID was read at a fixed offset 24, which on a 4-address QoS frame is the low nibble of Address 4; `desc_rate` ran unbounded so a 6-bit HT index above 31 reported garbage as a real VHT rate; `Init`/`InitWrite` left the device open on a throw, and the obvious fix for that deadlocks (`Stop()` joins the tick, the tick wants `_mu`). ## ThreadSanitizer, and the honest scale of what it found Ran under TSan against real hardware, because the interleavings here cannot be checked by reading. Plain RX is clean — 8751 frames, 0 reports. Under **concurrent retune** (`DEVOURER_RX_SWEEP`, main thread retuning while frames arrive) there are 3, of which one is real and in the C library, not in this class: `mt_read_rx_gain()` rewrites `lna_gain`/`rssi_offset[]` on every tune while `mt_rx_parse()` reads them to correct each frame's RSSI, so a frame parsed mid-retune gets a mixed correction. It is in the Open list with the fix it wants. The attribution matters more than the count. Three arms, same tool: | | reports | |---|---| | bring-up harness (sets the channel *before* starting a ring) | **0** over 10667 frames | | MT7612U via this backend, concurrent retune | 3 | | **RTL8812AU, shipping today, identical stress** | **8**, all in `RtlJaguarDevice.cpp` / `RtlAdapter.h` | So the backend is what makes retune-during-RX reachable on MediaTek, but retune-during-RX is not race-free anywhere in this project today. A shared gap, not a MediaTek regression — and TSan reported nothing inside `Mt7612uRadio` itself. ## Refused rather than faked Per the issue's "implement or refuse loudly": `SetTxMode` (the C library has no session-default rate, so a rate-less frame airs at OFDM 6 Mbps — my own on-air proof shows it, every `rx.txhit` came back `rate 4`), `SetAmpduMode` (works on this part, measured 2.21× at 200 B, but not wired through `send_packet`), `SetCcaMode(true)` (the ED-CCA enable exists, no on-air carrier-sense measurement backs it), `SetTxPowerIndexOverride` (no TXAGC index — TX power here is an absolute dBm limit). `SetCcaMode(false)` is quiet: asking for the state the chip is already in is not an error. `cfg.rx.ack_responder` is armed at bring-up and a refusal is fatal — a green init with a session that answers nothing sends the operator to debug the RF link. `tx.ack_timeout_us`, `tuning.disable_cca` and `tx.usb_agg_max` warn once per bring-up rather than being dropped silently. Capabilities corrected to what is measured: `per_packet_txpower` false (the radiotap path parses `DBM_TX_POWER` and discards it, the session route is gated on a field nothing assigns, and this backend never calls `mt7612u_tx()`); `ldpc_rx_vht` false (the one LDPC-RX measurement is an HT frame, and HT and VHT are separate decoder paths — which is why `AdapterCaps` splits them); `characterized_*` invalid (register equality at one channel is not dBm across a band). ## Verification - 60/60 ctest in Release, `DEVOURER_MT7612U=ON`, and the `address+undefined` sanitizer build. `make -C src/mt7612u check` green, `api_link` 28 C entry points against the C++ objects. - Two new ctest cells: `mt7612u_mapping` (RSSI bias, per-chain signal, rate codes, TID, widths — each mutation-tested, several pinning bugs that actually happened) and `mt7612u_usb_ids` from the previous block. - Hardware: 12000 frames RX with correct rate/RSSI decode; 19 `rx.txhit` on an independent radio; 984 TX submitted, 0 failed; clean under ASan+UBSan; TSan as above. ## Still open, deliberately No `GetRxQuality`, no JSONL `tx.fail`, and firmware is a searched path rather than embedded — the blob ships in linux-firmware under its own licence and is zstd-compressed on most distributions, so it can be neither vendored nor assumed present. **Open question for you, flagged not blocked:** whether MediaTek's blob may be redistributed here; if yes, embedding it is a small change and I will make it. Block D covers the `regress.py` cell and the on-air figures for the README table. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s the backend was missing (#423) > **Stacked on #422**, which is stacked on #421, which is stacked on #420. The > diff shows all four; the two commits belonging to this one are `367d94e` and `03d6874`. > Merge the parents and it rebases to just those. Happy to retarget. Last block of #419: the Validation bullet. `regress.py` against the MediaTek kernel driver, more `ctest` cells, and the on-air figures — each, as the issue asks, with the adversarial counterpart in the same breath. ## It interoperates with the vendor driver, both directions, both bands Two MT7612U units, one SuperSpeed and one USB 2.0, `mt76x2u` on whichever side the cell calls "kernel": | **ch36** (UNII-1) | RX = devourer | RX = kernel `mt76x2u` | |---|---|---| | **TX = devourer** | 7000 / 9375 ✓ | 6758 / 9451 ✓ | | **TX = kernel** (scapy) | 400 / 477 ✓ | 474 / 475 ✓ | | **ch6** (2.4 GHz) | RX = devourer | RX = kernel `mt76x2u` | |---|---|---| | **TX = devourer** | 8600 / 9436 ✓ | 8631 / 9425 ✓ | | **TX = kernel** (scapy) | 400 / 474 ✓ | 450 / 474 ✓ | Twenty cells over five runs, every one green on its first attempt. The kernel driver decodes our frames as `6.0 Mb/s 5180 MHz 11a` on ch36 and `6.0 Mb/s 2437 MHz 11g` on ch6, with a per-antenna breakdown — an independent decoder rather than our own reading of our own transmit. Nothing was dropped at the RX hand-off queue in any cell that ran a devourer receiver. **The verdict repeats; the counts do not.** Across the three ch36 runs that one devourer-TX → kernel-RX cell read 9077, 8921 and 6758 against a near-constant ~9400 offered — 26 % spread, same pair, same command, minutes apart. Every run says the path works; none says how well. What it does **not** show is in `docs/mt7612u.md`, at the same length: a ✓ is one frame (the default threshold); a hit count is not a throughput number and this bench has no USRP; the kernel side is the host's in-tree `mt76x2u` rather than a build of `reference/mt76 @ be5ce79`, because loading that would replace the `mt76` core module this host's own Wi-Fi uses — though `regress.py`'s VM mode exists precisely to sidestep that, and simply was not set up for MediaTek, so it is "not done" rather than "not possible"; and `regress.py` exits 0 whatever the cells say, so it reports rather than gates. ## Three new ctest cells, five MediaTek total | cell | what it holds | |---|---| | `mt7612u_rx_queue` | the RX hand-off queue — the **first cell to cover backend behaviour** rather than a lookup | | `mt7612u_usb_ids_vs_mt76` | that the id table really is the complete `mt76x2u_device_table` from the pinned reference | | `mt7612u_initvals_generated` | wires up a `--check` that existed and ran nowhere | `mt7612u_usb_ids` proves the ids we list never collide with a Realtek one. It cannot see an id we **forgot** — that adapter just falls through to the Realtek path and is misdetected as a Jaguar1 — and the first draft of that header had 11 of 16, taken from the host's kernel tree rather than the pinned one. Hence a cell that compares the two tables directly, with the vendor file's SHA-256 pinned so "matches the vendor table" cannot quietly become "matches whatever is checked out". The queue moved into its own header to be testable. `Mt7612uRadio.cpp` is otherwise unchanged in behaviour — I diffed the extraction statement by statement, and so did a reviewer. ## Three bugs the running found **`_wlan_iface_for_dut` matched by VID:PID and took the first hit.** On a rig with two adapters of one model that is a coin flip, and the wrong side of it points a cell's kernel `tcpdump` at the very adapter devourer has claimed. Now addressed by sysfs id. The VM branch cannot do that — the guest's ids are not the host's — so it now refuses when it finds more than one match instead of picking one. **`--keep-logs` discarded the output it exists to preserve.** `print(md)` then `os._exit(0)` on a block-buffered pipe loses the whole markdown table. The encoding-matrix branch had already learned this and flushed; the other two had not. **`_count_tcpdump_hits` counted a trailing blank line**, so every kernel-RX figure this harness has ever published was one high — which is how an earlier baseline cell came to report a suspiciously perfect `459 / 459` when 458 of 459 arrived. The tables above are re-measured with the fix, not corrected by hand. ## What review changed Four reviewers over two rounds, and the two on this block were worth the wait. **Ten mutations of the queue are caught that were not.** I had mutation-tested five ways and thought that was thorough. A reviewer found that two truncating pushes — every frame short by one byte, and every frame cut to one byte — passed the cell, because nothing asserted the delivered payload's *length*, and the consumer's frame length comes only from `data.size()`. Also surviving: a `pop_begin` that short-circuited on `stop` and so discarded whatever was still queued at teardown (the concurrent block caught that one 39 times in 40, which is not the same as catching it); a `push` that stopped notifying, turning hand-off latency into the 20 ms poll period on a video link; and a `wake()` that notified one waiter instead of all, which "passed" only by letting the second waiter sit out a 30-second timeout. `pop_commit` now takes back the pointer it handed out. The pairing was otherwise enforced by nothing, and a second commit would advance the tail past an undelivered frame, silently and uncounted. **The README footnote I wrote was simply false.** I claimed injection on this part airs at OFDM 6 Mbps only. `docs/mt7612u.md` — the file I was editing — records HT MCS7 aired and decoded, and 34.03 / 44.55 Mbit/s measured at MCS7/20, two hundred lines above. Only `SetTxMode` is refused, and only `txdemo`'s rate-less frames air at 6 Mbps. The real reason that column is empty is that it is a USRP duty-cycle measurement and this bench has no USRP. Also folded: `preflight_mediatek` string-matched `DEVOURER_MT7612U:BOOL=ON` and so failed a build configured with the idiomatic `-DDEVOURER_MT7612U=1`; it gated on every plugged adapter rather than the selected pair, so one MediaTek stick on the bench blocked an all-Realtek matrix; the `mt76x2u` warning pointed at `--modes`, which the default matrix silently ignores; a duplicated id passed the new drift cell and was reported as "17 ids match" a 16-entry table; and the two parsers of that one table disagreed about whether the trailing comma was optional, so a reformat could drop the last entry with no warning at all. ## Verification - 63/63 `ctest` in Release with `DEVOURER_MT7612U=ON`; 63/63 with the option OFF; 53/53 MediaTek-only. The queue cell is clean under ThreadSanitizer and under `address+undefined`. - Every mutation above now fails the cell, and names the property it broke. - Hardware: five matrix runs — ch36 three times, ch6 twice — 20 cells, all green. ## Still open The wiring around the queue has no cell — deleting the `pop_commit()` compiles clean and passes the suite — and bring-up, teardown ordering, the tick and TX still have none either. `reset()` invalidates an outstanding popped slot; no shipped consumer can reach that today (`StopRxLoop` does not join the consumer, so it is guarded by convention rather than by construction), and it is now documented rather than enforced. Both are in `docs/mt7612u.md`. Two things this block measured that bear on threads still open on #422: the hand-off queue dropped **nothing** in any measured cell, including one carrying 21842 frames; and `TxStats::failed` read 0 everywhere, which is exactly the case where "counts refusals, not wire deaths" is indistinguishable from working. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ged subtree The MediaTek AP driver layer: a beacon the MAC auto-transmits from its reserved page at every TBTT, and the address match that makes it an AP rather than a beacon generator. mt_beacon_init / _write / _set_enable the beacon engine and slot 0 mt_ap_set_bssid the APC slot the MAC matches a BSS in regs.h MBSS mask correction, beacon regs tx.cpp MT_TXOPT_BEACON: hardware TSF + seq bringup: `beacon`, `ap` the two gates that measured it Device-verified 2026-09-08 (docs/mt7612u-ap-mode.md): beacon on air on both bands by a kernel station's `iw scan` and an independent RTL8812AU witness; timestamp advancing exactly 102400 us per beacon, so the MAC is inserting the live TSF; sequence +1 per beacon; and a real station's three auth frames arriving with 0 retried, which is the hardware auto-ACK - an un-ACKed frame comes back with FC Retry set. BCN_BYPASS_MASK is INVERTED: a set bit SUPPRESSES that slot. mt_beacon_init sets all sixteen and mt_beacon_write clears the one it loaded. Getting that backwards gives a running beacon timer, an advancing TSF, and nothing on air. Squashed with its own post-merge repair, because the four original commits predate the subtree's C++ migration (OpenIPC#421) and do not build on today's master: beacon.c was compiled by NOTHING (the Makefile globs *.cpp and the CMake list is explicit), ap_ctx used C11 _Atomic and the C11 free functions, ap_cb took its cookie through implicit void* conversions, and a memset ran over a no-longer-trivially-copyable type. Kept as one commit so the series bisects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
…nd WPA2-PSK verified on air (#424) Follows #419's integration (#420-#423). That gave devourer an MT7612U it can open, receive and transmit on. This gives it one it can be an **access point** on — open and WPA2-PSK — with no MediaTek-specific code in the AP layer. ## It works, and devourer's own harnesses are what prove it `tests/ap_responder.cpp` and `tests/ap_wpa2.cpp` against an MT7612U, with no change to their AP logic. Both already took an `IRadio*` — #415 made them genuinely backend-agnostic — so nothing in them knows this is MediaTek. (They each gained a dozen lines that silence the beacon before `_exit`; see below. Nothing in that is MediaTek-specific either.) ``` AP 5-1 station 2-1 (wlx40a5ef5027a1) ch36 (5180 MHz) == open network (ap_responder) == PASS open: beacon armed PASS open: beacon on air PASS open: station associated PASS open: data plane (rtt min/avg/max/mdev = 0.567/0.812/1.013/0.150) PASS open: hardware auto-ACK (auth at retry=0) PASS open: nothing left airing after exit == WPA2-PSK (ap_wpa2) == PASS wpa2: beacon armed PASS wpa2: 4-way complete (MIC verified, station keyed) PASS wpa2: encrypted data plane (rtt min/avg/max/mdev = 0.795/1.177/1.525/0.240) PASS wpa2: nothing left airing after exit == beacon lifecycle (StartBeacon / StopBeacon / re-arm) == PASS stop: armed - beacon on air PASS stop: stopped - beacon gone PASS stop: re-armed - beacon back PASS stop: local contract checks === 14 passed, 0 failed === ``` `retry=0` on the auth is the load-bearing one: an un-ACKed frame is retransmitted with FC Retry set, so retry=0 *is* the hardware ACK, and it is the only evidence that the port identity and the APC slot are both right. That output is `tests/mt7612u_ap_onair.sh`, added here, so the numbers are a command rather than a story. 14/14 on ch36 **and** 14/14 on ch6 — both bands carry association, the data plane, WPA2 and the auto-ACK, not just a beacon. ## What was actually missing Nothing in the AP layer, and no new driver primitives. `StartBeacon`, `UpdateBeaconPayload` and `StopBeacon` fell through to `IRadio`'s base-class `return false`, so a MediaTek adapter beaconed nothing. Everything underneath was already device-verified (`mt_beacon_init` / `_write` / `_set_enable`, `mt_ap_set_bssid`), and `SetAckResponder`, `ReadTsf` and `WriteTsf` were already implemented. Three public entry points, because the backend reaches the subtree through the public header and `api_link` holds that line (31 now): ``` mt7612u_beacon_start radiotap-framed or bare MPDU; moves the MAC identity (MT_MAC_ADDR *and* the MT_MAC_BSSID base) to addr2, publishes addr3 in the APC slot, arms TBTT mt7612u_beacon_update reload in place, engine untouched mt7612u_beacon_stop disarm, and retract the identity it took ``` **The identity is the whole trick.** `IRadio` says addr2/addr3 *set* the port MAC/BSSID, `RtlJaguarDevice` implements exactly that, and `ap_responder`'s own comment says "MACID = BSSID, set by StartBeacon". My first draft instead *refused* a BSSID that was not the adapter's own MAC — which would have made both shipped harnesses unusable on this part, since both hardcode `02:42:75:05:d6:00`. It now follows `mt76x02_mac_setaddr` — move `MT_MAC_ADDR` and the `MT_MAC_BSSID` base together, then derive the APC slot the way `mt76x02_util.c:310` does. Getting *that* wrong is the review finding below, and it is the reason the two registers move together rather than one of them. ## Also fixed, not MediaTek-specific `ap_responder`, `ap_wpa2` and `ul_trigger_ap` all arm a hardware beacon and then `_exit(0)`, skipping every destructor — so `StopBeacon` never ran and the beacon kept airing after the process was gone, until the adapter was power-cycled. Measured: a scan after exit found the SSID live at `last seen: 308 ms ago`. The beacon is hardware-autonomous on the Realtek parts too, and `IRadio.h` has warned about this since it was written; `beacon_update_probe.cpp` already called StopBeacon before its own `_exit`, these three did not. Twelve lines each, not one: `StopBeacon` became able to *fail* during this review (it could not before — see below), and #411 amended `IRadio.h` the same day to say such a failure "must be retried (or followed by hardware shutdown) before its shared port is reused". So each harness retries up to three times and warns if the beacon is still armed, rather than calling it once and discarding the result. ## What review changed Five rounds, two of which returned DO-NOT-MERGE and were right to. The headline finding is still the one worth reading: **The APC slot index rested on a premise that is false in this codebase.** I derived it as `(ta[0] & 2) ? 1 : 0`, arguing mt76's `1 + (((macaddr[0] ^ addr[0]) >> 2) & 7)` collapses because the identity had just been retargeted. It does not. `dev->macaddr` is written once, from the EEPROM, and `MT_MAC_BSSID` — the register carrying `MBSS_MODE`, and the base the hardware derives the index from — is written once at init from the factory MAC; the ACK-responder retarget moves only `MT_MAC_ADDR`. So the hardware was indexing off a different address than the host assumed, and the constant was correct only where `1 + (((factory[0] ^ ta[0]) >> 2) & 7)` happens to be 1. This bench is exactly such a case (`40:a5:ef:…`, so the XOR term is 0), which means **every on-air run agreed with the wrong reasoning**. On an adapter whose first byte is `0xe8` the AP would beacon perfectly and acknowledge nobody. The bring-up gate had refused that case; the ABI flattened it to a constant. Fixed by making the retarget a real `setaddr`, so the premise is true by construction. Second: **`mt7612u_beacon_stop` could not fail** — `mt_clear` reports only its read half and the `mt_ap_set_bssid` returns were dropped — which made `StopBeacon`'s failure branch unreachable and the new harness's assertion for it vacuous, while the real hazard (an EP0 stall leaving the MAC beaconing) reported success. Also fixed: the reserved-page copy could fail mid-frame and report success (a *torn* beacon airs, worse than none); `UpdateBeaconPayload` did not raise the suppression guard mt76 explicitly brackets for that reason; `Stop()` discarded the result while promising a retry that did not exist; and the public header still described the pre-fix behaviour. The earlier round found eight more, one reproduced with a probe against the real object file: `beacon_split` left four of nine `mt7612u_tx_rate` fields indeterminate, and `sgi`/`ldpc`/`stbc` go straight into the rate word the MAC transmits verbatim while `power_adj` short-circuits the derived TX power. The last two rounds were about **fixes that moved the bug rather than removing it**, which is worth calling out because it happened three times: - The identity retract was added, and then the two failure paths *inside the block that moves the identity* were the ones not routed through it. - Ownership of `MT_MAC_ADDR` was claimed before the call that transfers ownership away, so the claim was immediately clobbered and the **success** path stopped restoring. - The retry that the retract relies on could not reach `MT_MAC_ADDR` at all: `mt7612u_clear_ack_responder` cleared its own "restore owed" flag unconditionally and early-returns on it, so attempts two and three were no-ops against a still-leaked port MAC. Both halves of the retry had to agree before either worked. - And fixing *that* widened `beacon_start`'s `io_err` bracket over a `mt_set(MT_AUTO_RSP_EN)` the code itself documents as a no-op (the gate is already on from `mac_reset`). `mt_set` is `mt_rmw`, which bumps `io_err` and skips its write when its *read* half stalls, and its return is ignored because the readback below is the real check — so one transient EP0 read stall tore down a verified-good arm and returned `-2` for a beacon that was on the air. The last of those was found by an adversarial pass over the diff, not by the harness: every one of these failures leaves the beacon airing perfectly, so no amount of watching the air finds them. Relatedly, `-1` no longer escapes from inside the identity block. It is contracted as "nothing was touched, whatever was airing still is", and the two exits returning it were reachable only *after* `MT_MAC_ADDR` had moved — and the unwind restores the **factory** address, the only one saved anywhere. A failed re-arm over a live beacon therefore retracted an identity that beacon never had while its page and timers kept airing it, under a return value promising nothing changed. There is no atomic re-arm to offer (one `MT_MAC_ADDR`, one save slot, previous occupant not in it), so those exits take the AP down deliberately and return `-2`. `-1` now belongs to the input refusals above the first hardware write. ## Rebase notes The AP work predates the C++ migration (#421) and the backend (#422). Rebasing surfaced that `beacon.c` was compiled by **nothing** — the subtree Makefile globs `*.cpp` and the CMake list is explicit — plus C11 `_Atomic`, implicit `void*` conversions, and a `memset` over a no-longer-trivially-copyable type. The original four AP commits and that repair are squashed into one, because the first three do not build on today's master and a bisect through them returns build failures rather than the commit you are looking for. ## What this does not show In `docs/mt7612u-ap-mode.md` at the same length as the numbers: - **Hardware CCMP is untested.** The 4-way above is devourer's *software* CCMP, the same code the Realtek backends use. `MT_WCID_KEY` is not even defined in this tree and the key path is unreached, so "crypto becomes hardware on MediaTek" remains a claim. It is more than a flag: `MT_WCID_KEY` and `MT_WCID_IV` are both absent, `mt_tx_build()` sets `MT_TXD_INFO_WIV` unconditionally (meaning "not encrypted") where mt76 gates it on the WCID having a key, and RX would have to strip the IV/PN itself. Opened as its own issue rather than guessed at here, and deliberately **not** proposing an `IRadio` key surface — per your guidance, that contract should be designed against two backends rather than one. - **The encryption was not independently captured.** No third radio sniffed the Protected bit; "encrypted" rests on the verified MIC plus traffic reaching a CCMP-only station. - **The station is the same silicon** (MT7612U on `mt76x2u`), so it is not an independent-generation witness. The RTL8812AU witness in the earlier Stage A/B section is. - **One AP, one station, ~20 cm apart; longest run 70 s.** No soak, no second station, no rekey, no roaming, no channel change while beaconing. ## Verification - 63/63 `ctest` with `DEVOURER_MT7612U=ON`, and with it OFF; `make -C src/mt7612u check` green (4 binaries, `api_link` at 31 entry points); no warnings in either build. - **Every commit builds**, both build systems — checked per commit, because the original series did not (the first three predate the C++ migration and their repair came fourth; they are squashed into one). - On-air, after the review fixes: `tests/mt7612u_ap_onair.sh` 14/14 on ch36 and 14/14 on ch6. - `tests/regress.py` 4/4 on the injector path — devourer↔devourer, devourer↔kernel both directions — so the AP changes did not disturb the transmit/receive behaviour #419 shipped. - The addr2/addr3 update guard has a positive control as well as two negative ones, run against a live beacon: an unchanged payload must be *accepted*, so a guard that refused everything would not pass. Only the addr3 arm discriminates against the pre-fix code — addr2 was already guarded — and the test says which is which rather than counting both as new coverage. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second block of #419. Takes the two scope items that do not need a radio: the
DEVOURER_MT7612Uoption with the portability work behind it, and the factorygate. There is still no
IRadiobackend —CreateRadiorecognises the MediaTekids only to refuse them.
The bug the gate fixes, measured
CreateRadioends in an unconditional Jaguar1 construction, andread_chip_id()discarded its libusb return code. So a device that does not answer the Realtek
SYS_CFG2vendor read arrived there as chip-id0x00and was brought up as anRTL8812AU. Both arms of the same control transfer, on this bench:
The MediaTek arm ran with a
0xAApoison byte in the destination and itsurvived, so the
0that reached the dispatch was the caller's own initialiser,never a reading.
read_chip_id's own comment already described the consequence— "Returns 0 on a failed read, which falls through to the Jaguar1 path" — this
just makes it not happen.
Two guards, both ahead of the Realtek read:
Mt7612uUsbIds.h— the complete 16-entrymt76x2u_device_table, taken fromthe pinned
reference/mt76 @ be5ce79so it is re-verifiable from a freshcheckout, gated the way Kestrel gates.
read_chip_idreturnsstd::optional—nulloptwhen the transferfailed. A successful read of
0x00still returns a value and keeps itsexisting fall-through, so no cold-boot transient changes behaviour.
Why the gate is vid:pid, and not what the issue asked for literally
The scope item says "an unrecognised non-Realtek VID must refuse". I did not
implement that, because it would refuse working adapters. Six vendor ids ship
both silicon families, and they do not merely coexist — they interleave inside
one vendor's product-id range:
0x0846Netgear9014,90539051,9052,90540x056eELECOM400a4007,400b,400d,400e, …0x0b05ASUS17eb,180b,183317d2,1817,1852,1853,1a620x2357TP-Link01370101,0103,0106,010d, …0x7392Edimaxb7116822,a811,a812,a813, …0x2c4eMercury01030127The pairs, by contrast, are disjoint: none of the 16 MediaTek ids matches any
of the 91 Realtek ids devourer can serve. That is the same property the
Kestrel gate already relies on. The transfer-failure guard then covers the intent
behind the scope item without the cost — it refuses every non-Realtek part,
named or not. Say the word if you want the literal vendor-id rule as well; I
think this pair is strictly better, and the table above is why.
Portability
The subtree is now C++ (
.cpp), so there is one compilation mode rather than asecond path that can rot unnoticed. Cost measured by compiling each file with
g++ -std=c++20 -Wall -Wextrabefore changing anything: 12 sites in thelibrary (11 implicit
void*conversions, 1uint8_t→enum) and 40 inbringup,32 of which cascaded from three
_Atomicdeclarations. No C++ keywordcollisions, no VLAs, no compound literals, no tentative definitions; both
designated initialisers were already in declaration order.
condition_variable_anyrather thancondition_variableso it waits on thebare mutex and every lock site keeps its original shape — a much smaller diff
through code whose teardown ordering is documented as a use-after-free hazard.
Those waits are teardown and TX back-pressure, never a hot path.
The exported surface is still C.
include/mt7612u/mt7612u.halready had anextern "C"guard andtests/api_link.cis deliberately still compiled by$(CC), so the ABI is tested rather than asserted: it resolves 25 entry pointsagainst the C++ objects, and
nmshows the public symbols unmangled.Two things fell out that are worth naming:
std::recursive_mutexmember retires theio_lock_readyflag. A zeroed
pthread_mutex_tis a valid NON-recursive lock, so an openpath that skipped the explicit init self-deadlocked the PHY tick — the adopt
path did exactly that once. That state is now unrepresentable.
pthread_cond_timedwaitdeadline arithmetic became onewait_for, dropping aCLOCK_REALTIMEdependence where a wall-clock stepcould stretch or skip the 2 s budget.
The two structs holding those members moved from
calloc/freetonew/delete—callocnever runs a constructor.{}still zeroes every scalar.-Wclass-memaccessthen caught a real defect that introduced: amemsetover aconstructed device in
frame_shape. Its loop case now declares the deviceinside the loop, preserving the per-iteration reset.
Not ported: the
flockadapter lock is_WIN32-guarded rather thanmirrored. It works by contending for the same lock file
UsbDeviceLockuses,but on Windows that class is a named mutex — so a file lock there would exclude
nobody, and mirroring the mutex would duplicate what the devourer path already
owns. Unprotected on Windows is a bare
mt7612u_open()with no devourer aroundit, which is the bench tool's case, and the bench is Linux.
Capability fallout
bw_mask_for_generationends in a catch-all returning 20/40/80 pluskBw5|kBw10, so the new enumerator silently inherited narrowband:MT_RATE_BWencodes nothing narrower than 20 MHz, and this issue lists thatamong the part's known limits. Nothing calls it with
Mt7612utoday, which iswhy it was worth fixing now — the first caller is the backend, and a capability
claimed there is claimed silently.
adapter_caps_selftestpins the value andthe narrowband bits.
What review changed
Two reviewers went at this, and both found things I had wrong. Recording them
because they are the substance of the branch, not footnotes.
The selftest could not see Jaguar. It cross-checked the MediaTek set against
KestrelUsbIds.handRtl8733bUsbIds.h— 27 ids. Jaguar1/2/3 dispatch bychip-id and have no table in devourer, so 64 more were invisible, and every
interleaved vendor id above lives in that 64. Adding
{0x0b05, 0x17d2}— anASUS USB-AC56, a real RTL8812AU — passed the old test while refusing a genuine
Jaguar1 adapter. The test now carries a 64-entry witness list from
reference/rtl8812au, checks all 91, and asserts the coverage count so a futuretrim cannot silently restore the blindness. Both attacks now fail it:
My table was 11 of 16, from the wrong source — this machine's kernel tree
rather than the pinned submodule. Recomputing against the complete table also
turned "three shared vendor ids" into six, which is the table above.
The library read the environment and wrote to stderr. qodo raised both, and
both are things this PR's own
DEVOURER_MT7612Ucreated by linking the subtreeinto
libdevourer: a bench tool that readsMT7612U_DEVis a quirk, a librarythat picks its adapter from ambient process state is a defect, and the same for
one that writes diagnostics to a stream its host does not control.
mt7612u_open_selected()takes the selector andmt7612u_set_log_sink()divertsthe diagnostics; the library now reads no environment and names stderr in exactly
one place.
mt_diag()turned out to be reimplementingLogger— same format,same fwrite+fflush atomicity, none of
set_level/set_diag_stream/DEVOURER_LOG_MAX_LEVEL— and on AndroidLogger::emithas an__android_log_writebranch these writes did not, so the subtree's diagnosticslanded nowhere visible on the platform devourer ships to.
Two exceptions could cross the
extern "C"boundary, both proved by proberather than argued:
catch (const std::system_error &)is narrower than whatstd::thread's constructor throws (libstdc++ allocates the thread state with athrowing
new, so OOM arrives asbad_alloc), andnew (std::nothrow) T{}is not nothrow for these types, because
std::condition_variable_anyholds ashared_ptr<mutex>that allocates in its constructor. Both are nowtry/catch (...)returning the existing-1/NULL.Also folded: three comments still said
mt_dev_state_init/destroymanageio_lockafter this branch made them empty; one comment quoted aread_chip_idnote the same commit deleted; and
mt_usleepis no longer interruptible(measured: a 200 ms request under 100 Hz
SIGALRMreturned after 10 ms withnanosleep, 201 ms withsleep_for) and now says so.Verification
Local:
cell is
mt7612u_usb_ids).mt7612u+jaguar1config builds, 49/49.make -C src/mt7612u checkpasses (four binaries now,log_sinkadded);api_linkresolves 27 C entry points against the C++ objects.address+undefined, RelWithDebInfo) with the option ON: 59/59.On hardware, the gate end to end — the same adapter, the same command, across
the parent commit and this branch:
The parent really does construct an
RtlJaguarDevicefor a MediaTek adapter, sothe bug is demonstrated rather than inferred. Control, unchanged on both:
Creating RtlJaguarDevice (PID 0x8812, chip-id 0x04)for the RTL8812AU.And the library itself, on an MT7612U at
0e8d:7612:bringup regs—MT_ASIC_VERSION 0x76120044, CFG- and MAC-space write /read-back / restore, EEPROM MAC. GATE A: PASS, and again after the RX run,
so nothing wedged.
bringup arx 1 10— ROM patch + ILM/DLM load, ring up, 7918 frames in 10 s(790/s), rx_err=0 rx_invalid=0 rx_dropped=0. The callback count and the
driver's ring count agree exactly, which is what exercises the new
std::atomicand mutex paths against each other.detect_leaks=1: 2623 frames (sanitizerscost most of the rate), no ASan report, no UBSan report, no leak — the
part that actually tests the ring's
new/deleteteardown.CI is what verifies the Windows claim, since none of it builds locally.
DEVOURER_MT7612U=ONis now set in the multi-platform matrix (gcc, clang, MSVCcl, macOS), the mingw job, andbuild-sanitizers, plus one strippedbuild-configscell so the subtree cannot quietly acquire a dependency on aRealtek chip's sources.
Blast radius
For anyone who never sets the option, the compiled delta is confined to
WiFiDriver.cppand is exactly the two refusals above, plus an unusedChipGenerationenumerator. No Realtek dispatch path changes: a chip thatanswers
SYS_CFG2reaches precisely the branch it reached before.Still open, deliberately
IRadiobackend, no transport adoption, no firmware embedding — nextblock.
MT7612U_DEVis still agetenvinopen_selected(). mt7612u: a MediaTek backend, measured — is it in scope? #412 deferred itbecause there is no public way to pass a selector (
mt7612u_open()allocatesthe device and the struct is opaque). I corrected two comments that claimed
the library reads no environment, since it does.
FATAL_ERRORlist: with no backend, an MT7612U-only build would produce alibrary that can open nothing. It joins when the backend lands.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj