Skip to content

mt7612u: DEVOURER_MT7612U option, C++ portability, and a factory gate that stops the Jaguar1 misroute - #421

Merged
josephnef merged 7 commits into
OpenIPC:masterfrom
snokvist:feat/mt7612u-factory-gate
Sep 10, 2026
Merged

josephnef merged 7 commits into
OpenIPC:masterfrom
snokvist:feat/mt7612u-factory-gate

Conversation

@snokvist

@snokvist snokvist commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #420. This branch is cut from that PR's head, so the diff shows
its four commits too (8c5dd0d, eb10389, 8dd504c, 31cb738 — the FCS
contract). The five that belong to this one are dde4766, cdbb72d,
20e0b7e, 9238f39, b839df3. Merge #420 first and this rebases to nothing;
happy to retarget it if you would rather they went in together.

Second block of #419. Takes the two scope items that do not need a radio: the
DEVOURER_MT7612U option with the portability work behind it, and the factory
gate. There is still no IRadio backend — CreateRadio recognises the MediaTek
ids only to refuse them.

The bug the gate fixes, measured

CreateRadio ends in an unconditional Jaguar1 construction, and read_chip_id()
discarded its libusb return code. So a device that does not answer the Realtek
SYS_CFG2 vendor read arrived there as chip-id 0x00 and was brought up as an
RTL8812AU. Both arms of the same control transfer, on this bench:

bmRequestType 0xC0, bRequest 5, wValue 0x00FC, 1 byte

  RTL8812AU  0bda:8812   rc=1                        chip_id=0x04
  MT7612U    0e8d:7612   rc=-7 LIBUSB_ERROR_TIMEOUT  destination byte UNTOUCHED
  MT7612U    0e8d:7612   rc=-7 LIBUSB_ERROR_TIMEOUT  (second adapter, same)

The MediaTek arm ran with a 0xAA poison byte in the destination and it
survived, so the 0 that 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-entry mt76x2u_device_table, taken from
    the pinned reference/mt76 @ be5ce79 so it is re-verifiable from a fresh
    checkout, gated the way Kestrel gates.
  • read_chip_id returns std::optionalnullopt when the transfer
    failed. A successful read of 0x00 still returns a value and keeps its
    existing 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
:

vendor MediaTek ids Realtek ids
0x0846 Netgear 9014, 9053 9051, 9052, 9054
0x056e ELECOM 400a 4007, 400b, 400d, 400e, …
0x0b05 ASUS 17eb, 180b, 1833 17d2, 1817, 1852, 1853, 1a62
0x2357 TP-Link 0137 0101, 0103, 0106, 010d, …
0x7392 Edimax b711 6822, a811, a812, a813, …
0x2c4e Mercury 0103 0127

The 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 a
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,
32 of which cascaded from three _Atomic declarations. No C++ keyword
collisions, no VLAs, no compound literals, no tentative definitions; both
designated initialisers were already in declaration order.

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 rather than 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.
Those waits are teardown and TX back-pressure, never a hot path.

The exported surface is still C. include/mt7612u/mt7612u.h already had an
extern "C" guard and tests/api_link.c is deliberately still compiled by
$(CC), so the ABI is tested rather than asserted: it resolves 25 entry points
against the C++ objects, and nm shows the public symbols unmangled.

Two things fell out that are worth naming:

  • A constructed std::recursive_mutex member retires the io_lock_ready
    flag
    . A zeroed pthread_mutex_t is a valid NON-recursive lock, so an open
    path that skipped the explicit init self-deadlocked the PHY tick — the adopt
    path did exactly that once. That state is now unrepresentable.
  • The teardown's pthread_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 /
deletecalloc never runs a constructor. {} still zeroes every scalar.
-Wclass-memaccess then caught a real defect that introduced: a memset over a
constructed device in frame_shape. Its loop case now declares the device
inside the loop, preserving the per-iteration reset.

Not ported: the flock adapter lock is _WIN32-guarded rather than
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 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 around
it, which is the bench tool's case, and the bench is Linux.

Capability fallout

bw_mask_for_generation ends in a catch-all returning 20/40/80 plus
kBw5|kBw10, so the new enumerator silently inherited narrowband:

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 nothing narrower than 20 MHz, and this issue lists that
among the part's known limits. Nothing calls it with Mt7612u today, which is
why it was worth fixing now — the first caller is the backend, and a capability
claimed there is claimed silently. adapter_caps_selftest pins the value and
the 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.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
interleaved vendor id above lives in that 64. Adding {0x0b05, 0x17d2} — an
ASUS 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 future
trim cannot silently restore the blindness. Both attacks now fail it:

mt7612u_usb_ids: FAIL 0b05:17d2 is Jaguar1/2/3 but the MediaTek gate claims it
mt7612u_usb_ids: FAIL 0846:9054 is Jaguar1/2/3 but the MediaTek gate claims 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_MT7612U created by linking the subtree
into libdevourer: a bench tool that reads MT7612U_DEV is a quirk, a library
that 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 and mt7612u_set_log_sink() diverts
the diagnostics; the library now reads no environment and names stderr in exactly
one place. mt_diag() turned out to be reimplementing Logger — same format,
same fwrite+fflush atomicity, none of set_level / set_diag_stream /
DEVOURER_LOG_MAX_LEVEL — and on Android Logger::emit has an
__android_log_write branch these writes did not, so the subtree's diagnostics
landed nowhere visible on the platform devourer ships to.

Two exceptions could cross the extern "C" boundary, both proved by probe
rather than argued: catch (const std::system_error &) is narrower than what
std::thread's constructor throws (libstdc++ allocates the thread state with a
throwing new, so OOM arrives as bad_alloc), and new (std::nothrow) T{}
is not nothrow for these types, because 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 folded: three comments still said mt_dev_state_init/destroy manage
io_lock after this branch made them empty; one comment quoted a read_chip_id
note the same commit deleted; and mt_usleep is no longer interruptible
(measured: a 200 ms request under 100 Hz SIGALRM returned after 10 ms with
nanosleep, 201 ms with sleep_for) and now says so.

Verification

Local:

  • Both CMake configs build warning-free, 59/59 ctest (58 before; the new
    cell is mt7612u_usb_ids).
  • The stripped mt7612u+jaguar1 config builds, 49/49.
  • make -C src/mt7612u check passes (four binaries now, log_sink added);
    api_link resolves 27 C entry points against the C++ objects.
  • Sanitized (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:

parent 31cb738:  Creating RtlJaguarDevice (PID 0x7612, chip-id 0x00)
this branch:     MediaTek MT7612U (0e8d:7612) detected; devourer has no MediaTek
                 radio backend yet — refusing rather than misdetecting it as Realtek
                 No driver for this chip in this build — exiting   (exit 1)

The parent really does construct an RtlJaguarDevice for a MediaTek adapter, so
the 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 regsMT_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::atomic and mutex paths against each other.
  • The same run under ASan+UBSan with detect_leaks=1: 2623 frames (sanitizers
    cost most of the rate), no ASan report, no UBSan report, no leak — the
    part that actually tests the ring's new/delete teardown.

CI is what verifies the Windows claim, since none of it builds locally.
DEVOURER_MT7612U=ON is now set in the multi-platform matrix (gcc, clang, MSVC
cl, macOS), the mingw job, and build-sanitizers, plus one stripped
build-configs cell so the subtree cannot quietly acquire a dependency on a
Realtek chip's sources.

Blast radius

For anyone who never sets the option, the compiled delta is confined to
WiFiDriver.cpp and is exactly the two refusals above, plus an unused
ChipGeneration enumerator. No Realtek dispatch path changes: a chip that
answers SYS_CFG2 reaches precisely the branch it reached before.

Still open, deliberately

  • No IRadio backend, no transport adoption, no firmware embedding — next
    block.
  • MT7612U_DEV is still a getenv in open_selected(). mt7612u: a MediaTek backend, measured — is it in scope? #412 deferred it
    because there is no public way to pass a selector (mt7612u_open() allocates
    the device and the struct is opaque). I corrected two comments that claimed
    the library reads no environment, since it does.
  • MT7612U deliberately does not join the "No chip support selected"
    FATAL_ERROR list: with no backend, an MT7612U-only build would produce a
    library that can open nothing. It joins when the backend lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj

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

Copy link
Copy Markdown

PR Summary by Qodo

Add MT7612U build support, safe factory gating, and FCS metadata

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Refuses MT7612U and failed Realtek probes before Jaguar1 fallback.
• Ports the MT7612U library to portable C++ and enables optional CMake builds.
• Propagates per-frame FCS presence through beamforming consumers and tests.
Diagram

graph TD
  MTLib["MT7612U Library"] -.->|"No backend"| Factory["Radio Factory"] --> Gate{"MT USB ID?"} -->|"No"| Probe{"SYS CFG2 OK?"} -->|"Yes"| Realtek["Realtek Backends"]
  Gate -->|"Yes"| Refuse["Safe Refusal"]
  Probe -->|"No"| Refuse
  RxMeta["FCS Metadata"] --> BF["BF Consumers"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Reject non-Realtek vendor IDs
  • ➕ Implements a simple vendor-level policy without maintaining a MediaTek product table.
  • ➖ Rejects supported Realtek silicon sold under shared OEM vendor IDs.
  • ➖ Cannot handle interleaved MediaTek and Realtek product IDs safely.
2. Keep the subtree in C with portability shims
  • ➕ Minimizes source-language conversion and retains the original pthread implementation.
  • ➖ Requires a custom Windows threading and timing abstraction.
  • ➖ Creates a second compilation model that can diverge from the main C++ build.
  • ➖ Retains manual construction hazards for mutex-containing objects.
3. Model FCS presence as an adapter capability
  • ➕ Stores the property once when hardware behavior is generation-wide.
  • ➕ Avoids adding metadata to every received packet.
  • ➖ Free-function consumers receive only frame bytes and lengths, not adapter capabilities.
  • ➖ Cannot represent future per-frame variation or mixed capture sources.
  • ➖ Encourages callers to recover device context instead of honoring the packet contract.

Recommendation: Keep the PR's VID:PID gate plus transfer-failure guard: it prevents the demonstrated Jaguar1 misroute without excluding OEM Realtek devices. The C++ conversion aligns the subtree with the project's supported platforms while preserving its C ABI, and per-frame FCS metadata is the most reliable boundary because all consumers can access it directly. Review should focus particularly on synchronization equivalence, exception containment across the C ABI, and completeness of USB-ID collision coverage.

Files changed (36) +1047 / -272

Enhancement (4) +128 / -9
BfReportDetect.hEmit and honor beamforming FCS metadata +7/-2

Emit and honor beamforming FCS metadata

• Conditionally excludes a trailing FCS from CSI output and records FCS presence in beamforming events. Tightens the minimum report length before parsing.

examples/common/BfReportDetect.h

AdapterCaps.hDefine MT7612U generation capabilities +15/-1

Define MT7612U generation capabilities

• Adds the MT7612U chip generation and name. Explicitly limits it to 20/40/80 MHz so it cannot inherit unsupported narrowband capabilities.

src/AdapterCaps.h

RxPacket.hAdd per-frame FCS presence metadata +27/-6

Add per-frame FCS presence metadata

• Adds rx_pkt_attrib::fcs_present with a backward-compatible true default. Revises the Packet data contract to require consumers to consult the flag before trimming bytes.

src/RxPacket.h

Mt7612uUsbIds.hAdd the complete MT7612U USB-ID table +79/-0

Add the complete MT7612U USB-ID table

• Defines the 16 VID:PID pairs from the pinned mt76 reference and provides a header-only lookup. Documents why exact pairs, rather than vendor IDs, are required for safe pre-probe gating.

src/mt7612u/Mt7612uUsbIds.h

Bug fix (9) +188 / -56
main.cppAccount for stripped FCS in airtime +7/-3

Account for stripped FCS in airtime

• Adds four bytes to the on-air frame length when receive metadata reports that the MAC stripped the FCS. This preserves channel-occupancy calculations across backend behaviors.

examples/chanscout/main.cpp

main.cppPreserve FCS metadata through sensing calibration +16/-7

Preserve FCS metadata through sensing calibration

• Passes FCS presence into report parsing and stores it beside calibration frames for later reparsing. Raw report events now expose the same metadata.

examples/sense/main.cpp

BfReportDecode.hParse reports with optional trailing FCS +12/-3

Parse reports with optional trailing FCS

• Extends parse_report with an FCS-presence argument that defaults to the historical Realtek behavior. Length checks and angle slicing now reserve four bytes only when an FCS exists.

src/BfReportDecode.h

WiFiDriver.cppBlock MT7612U and failed probes before Jaguar1 +67/-8

Block MT7612U and failed probes before Jaguar1

• Checks known MT7612U VID:PID pairs before issuing Realtek vendor requests and refuses them because no backend exists. Returns nullopt for failed SYS_CFG2 transfers so unknown non-Realtek devices cannot reach the Jaguar1 fallback.

src/WiFiDriver.cpp

init.cppConstruct and destroy MT7612U devices safely +25/-11

Construct and destroy MT7612U devices safely

• Replaces calloc/free with exception-contained new/delete so mutex members are constructed correctly. Uses steady_clock for statistics timing and preserves existing C error returns.

src/mt7612u/init.cpp

frame_shape.cppAvoid clearing constructed mutex objects +12/-4

Avoid clearing constructed mutex objects

• Value-initializes device fixtures instead of applying memset over std::recursive_mutex members. Recreates loop-local devices to preserve the original per-case reset behavior.

src/mt7612u/tests/frame_shape.cpp

bf_report_decode.pyDecode FCS-less beamforming captures correctly +41/-14

Decode FCS-less beamforming captures correctly

• Carries FCS presence from structured events into frame and MU-SNR parsing and adds --no-fcs for bare MediaTek captures. Preserves legacy defaults and skips malformed events safely.

tools/bf_report_decode.py

bf_waterfall.pyConsume FCS metadata in live waterfall decoding +4/-3

Consume FCS metadata in live waterfall decoding

• Adapts to the report parser's tuple result and passes each event's FCS-presence flag into frame decoding.

tools/bf_waterfall.py

bf_waterfall_svg.pyConsume FCS metadata in SVG waterfall decoding +4/-3

Consume FCS metadata in SVG waterfall decoding

• Passes FCS presence from report events into offline frame parsing so FCS-less captures are not truncated.

tools/bf_waterfall_svg.py

Refactor (12) +222 / -147
async.cppPort asynchronous USB rings to standard C++ +66/-56

Port asynchronous USB rings to standard C++

• Replaces pthread mutexes, condition variables, and threads with standard C++ primitives and uses new/delete for constructed objects. Catches all allocation and thread-construction exceptions before they can cross the C ABI.

src/mt7612u/async.cpp

caps.cppCompile capability implementation as C++ +0/-0

Compile capability implementation as C++

• Moves the MT7612U capability implementation into the subtree's unified C++ compilation mode without changing its behavior.

src/mt7612u/caps.cpp

eeprom.cppCompile EEPROM implementation as C++ +0/-0

Compile EEPROM implementation as C++

• Moves the EEPROM implementation into the unified C++ build without functional changes.

src/mt7612u/eeprom.cpp

fw.cppMake firmware loading C++-compatible +9/-8

Make firmware loading C++-compatible

• Adds explicit allocation and libusb enum conversions required by C++ compilation. Firmware transfer behavior remains unchanged.

src/mt7612u/fw.cpp

internal.hReplace POSIX synchronization members with C++ types +35/-14

Replace POSIX synchronization members with C++ types

• Defines standard mutex, recursive mutex, condition variable, thread, and timing dependencies. Removes the io_lock_ready state by making the recursive lock an always-constructed device member.

src/mt7612u/internal.h

mcu.cppUse the constructed recursive MCU lock +3/-3

Use the constructed recursive MCU lock

• Replaces pthread lock operations with the device's std::recursive_mutex while retaining nested transaction semantics.

src/mt7612u/mcu.cpp

phy.cppUse the C++ recursive lock for PHY ticks +2/-2

Use the C++ recursive lock for PHY ticks

• Ports PHY tick locking to std::recursive_mutex without changing calibration or gain-update ordering.

src/mt7612u/phy.cpp

radiotap.cppAdd C++-safe packet pointer conversion +1/-1

Add C++-safe packet pointer conversion

• Adds the explicit void-pointer conversion required when compiling radiotap packet handling as C++.

src/mt7612u/radiotap.cpp

rx.cppCompile receive parsing as C++ +0/-0

Compile receive parsing as C++

• Moves the MT7612U receive implementation into the subtree's unified C++ compilation mode without functional changes.

src/mt7612u/rx.cpp

bringup.cppPort bring-up counters and casts to C++ +30/-25

Port bring-up counters and casts to C++

• Replaces C atomics with std::atomic and adds explicit pointer and enum conversions. The hardware gate behavior and relaxed counter semantics remain unchanged.

src/mt7612u/tools/bringup.cpp

tx.cppMake transmit construction C++-safe +2/-2

Make transmit construction C++-safe

• Adds explicit frame-pointer and bandwidth-enum conversions required by C++ compilation without changing generated transmit descriptors.

src/mt7612u/tx.cpp

usb.cppPort USB timing, locking, and platform guards +74/-36

Port USB timing, locking, and platform guards

• Uses standard C++ sleep and monotonic timing, ports recursive locking, and guards POSIX file-lock APIs on Windows. Device locks are now constructed with the owning object, eliminating the partial-initialization state.

src/mt7612u/usb.cpp

Tests (5) +295 / -0
bf_report_decode_selftest.cppTest tight FCS-less beamforming reports +46/-0

Test tight FCS-less beamforming reports

• Adds equivalent FCS-present and FCS-less report cases. Verifies that incorrectly assuming an FCS rejects a tight MU report instead of silently truncating angle data.

examples/sense/bf_report_decode_selftest.cpp

field_macros.cppCompile field macro tests as C++ +0/-0

Compile field macro tests as C++

• Moves the field-macro self-test into the same C++ mode as the library sources.

src/mt7612u/tests/field_macros.cpp

adapter_caps_selftest.cppPin MT7612U generation and bandwidth capabilities +13/-0

Pin MT7612U generation and bandwidth capabilities

• Verifies the MT7612U generation name and its 20/40/80 MHz-only capability mask, including explicit rejection of 5 and 10 MHz.

tests/adapter_caps_selftest.cpp

bf_report_fcs_selftest.pyTest Python FCS-aware report decoding +88/-0

Test Python FCS-aware report decoding

• Covers event metadata, legacy bare-hex defaults, malformed-event handling, and equivalent FCS-present and FCS-less decoding. Also protects the MU SNR parser's historical FCS-present behavior.

tests/bf_report_fcs_selftest.py

mt7612u_usb_ids_selftest.cppVerify MediaTek and Realtek USB IDs remain disjoint +148/-0

Verify MediaTek and Realtek USB IDs remain disjoint

• Checks all tabled MediaTek IDs and cross-validates them against at least 91 Realtek identities, including Jaguar witnesses. Pins exact-pair behavior and shared-vendor edge cases that make a vendor-only gate unsafe.

tests/mt7612u_usb_ids_selftest.cpp

Documentation (3) +93 / -44
logging.mdDocument FCS fields in beamforming events +2/-2

Document FCS fields in beamforming events

• Adds the per-frame fcs field to the documented bf.report_raw and bf.csi event schemas.

docs/logging.md

mt7612u.mdDocument build integration and FCS contract +32/-18

Document build integration and FCS contract

• Updates the MT7612U status to describe optional CMake compilation, explicit factory refusal, and per-frame FCS metadata. Clarifies remaining backend work and CI limitations.

docs/mt7612u.md

README.mdDocument C++ portability and optional integration +59/-24

Document C++ portability and optional integration

• Describes the optional CMake build, preserved C ABI, and absence of an IRadio backend. Records the standard-library synchronization conversion and Windows adapter-lock behavior.

src/mt7612u/README.md

Other (3) +121 / -16
cmake-multi-platform.ymlCompile MT7612U across CI configurations +18/-1

Compile MT7612U across CI configurations

• Enables DEVOURER_MT7612U in the multi-platform, MinGW, sanitizer, and selected stripped-build configurations. This makes portability and lifetime regressions visible outside the standalone Linux Makefile.

.github/workflows/cmake-multi-platform.yml

CMakeLists.txtAdd optional MT7612U sources and headless tests +67/-0

Add optional MT7612U sources and headless tests

• Introduces the default-off DEVOURER_MT7612U option and conditionally compiles the C++ subtree into libdevourer. Registers USB-ID and Python FCS self-tests while deliberately excluding MT7612U from backend availability checks.

CMakeLists.txt

MakefileBuild the MT7612U subtree as C++ +36/-15

Build the MT7612U subtree as C++

• Switches library, tools, and most tests to C++20 compilation and linking while keeping api_link.c compiled as C. This preserves direct validation of the exported C ABI.

src/mt7612u/Makefile

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

qodo-free-for-open-source-projects Bot commented Sep 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Callers can open the wrong adapter ✓ Resolved 📎 Requirement gap ≡ Correctness
Description
open_selected() reads MT7612U_DEV inside the library even though the newly integrated
mt7612u_open() API provides no explicit device selector. When multiple adapters are connected or
the process inherits a stale value, construction can claim hardware other than the adapter the
caller intended to use.
Code

CMakeLists.txt[382]

+        src/mt7612u/usb.cpp
Evidence
Rules 9 and 20 require device selection to enter through configuration or an explicit API rather
than a library environment read. The PR adds usb.cpp to libdevourer; the exported
mt7612u_open() path reaches open_selected(), which reads MT7612U_DEV and uses it to choose
among devices.

Configure firmware and device selection without environment reads
CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables: CLAUDE.md: Keep Library Configuration Independent of Environment Variables
CMakeLists.txt[370-390]
src/mt7612u/init.cpp[466-483]
src/mt7612u/usb.cpp[557-597]
src/mt7612u/include/mt7612u/mt7612u.h[103-127]

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

## Issue description
Enabling `DEVOURER_MT7612U` now links an exported open path into `libdevourer` that reads `MT7612U_DEV` internally, making adapter selection depend on ambient process state.
## Fix Focus Areas
- src/mt7612u/include/mt7612u/mt7612u.h[103-127]
- src/mt7612u/usb.cpp[557-597]
- src/mt7612u/init.cpp[466-483]
## Recommended Fix
Add an explicit selector parameter or open-with-selector API, pass it through to `open_selected()`, and remove `getenv("MT7612U_DEV")` from library code. Move any environment parsing needed by the standalone bring-up application into that application before it calls the library.

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


2. Radio diagnostics bypass the logger ✓ Resolved 📎 Requirement gap ◔ Observability
Description
mt_diag() writes every subtree diagnostic directly to stderr, and the exported C surface
provides no sink through which devourer's Logger can receive those messages. Once
DEVOURER_MT7612U adds usb.cpp and the other sources to libdevourer, initialization, firmware,
USB, RX and TX diagnostics all continue through this separate output path.
Code

CMakeLists.txt[R380-382]

+        src/mt7612u/rx.cpp
+        src/mt7612u/tx.cpp
+        src/mt7612u/usb.cpp
Evidence
Rule 10 requires a library sink connected to devourer's Logger and explicitly forbids subtree
diagnostics from continuing directly to stderr. The PR activates these sources in libdevourer,
while LOG, WARN, and ERR still call mt_diag(), whose only output is fwrite(..., stderr)
followed by fflush(stderr).

Integrate MT7612U diagnostics and JSONL events with devourer logging
CMakeLists.txt[370-390]
src/mt7612u/usb.cpp[21-48]
src/mt7612u/internal.h[365-372]

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

## Issue description
The newly integrated MT7612U library emits diagnostics directly to `stderr` and exposes no hook that can connect those messages to devourer's structured logging infrastructure.
## Fix Focus Areas
- src/mt7612u/usb.cpp[21-48]
- src/mt7612u/internal.h[365-372]
- src/mt7612u/include/mt7612u/mt7612u.h[103-127]
## Recommended Fix
Expose a C-compatible diagnostic sink callback, route `mt_diag()` through it, and connect the sink to devourer's `Logger` in the integrated path. If standalone tools still need terminal diagnostics, have those applications explicitly install their own stderr sink rather than writing there inside the library.

ⓘ 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 turn these tips off under Display preferences

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread CMakeLists.txt
Comment thread CMakeLists.txt
snokvist added a commit to snokvist/devourer that referenced this pull request Sep 9, 2026
…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
snokvist added a commit to snokvist/devourer that referenced this pull request Sep 9, 2026
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 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.

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. Mt7612uUsbIdsSelftest cross-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_id returning nullopt on 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:

  1. mt_async_stop() clears a->running after the drain loop but does not cv.notify_all(), so a thread parked in mt_async_tx_submit's slot wait never wakes — and delete a then 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.

  2. mt_async_start increments rx_inflight after libusb_submit_transfer succeeds, so rx_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.

snokvist and others added 7 commits September 10, 2026 21:07
…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
@josephnef
josephnef force-pushed the feat/mt7612u-factory-gate branch from 58f508b to cb356bb Compare September 10, 2026 18:07
@josephnef
josephnef merged commit 957fe91 into OpenIPC:master Sep 10, 2026
22 checks passed
josephnef pushed a commit that referenced this pull request Sep 10, 2026
…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>
josephnef pushed a commit that referenced this pull request Sep 10, 2026
…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>
snokvist added a commit to snokvist/devourer that referenced this pull request Sep 10, 2026
…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
josephnef pushed a commit that referenced this pull request Sep 11, 2026
…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>
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