Skip to content

Commit 63e737d

Browse files
josephnefclaude
andauthored
tests: cross-driver regression matrix harness (#32)
## What this is A manual-run Python orchestrator that compares devourer's userspace stack against the kernel driver (mainline `rtw88` / out-of-tree `aircrack-ng/rtl8812au`) on a host with two plugged-in USB Wi-Fi adapters. Emits a markdown table — designed to paste into PR review comments. ``` TX = devourer TX = kernel RX = devourer [end-to-end dvr] [does dvr RX a kernel-TX frame?] RX = kernel [does dvr emit [baseline / rig sanity] valid frames?] ``` Each cell injects/receives the canonical beacon (SA `57:42:75:05:d6:00`, matching `txdemo/main.cpp`) for `--duration` seconds and counts hits. ## Why now PRs like #30 (RTL8821AU partial bring-up) need cross-driver validation: \"does devourer's TX really emit valid frames?\" and \"can devourer RX a frame the kernel driver knows works?\". Running these checks manually is fiddly (modprobe / unbind / iw / tcpdump dance per cell); this script does it in one command and prints a structured result. This is **not** a 24x7 CI runner — too few PRs to justify the infrastructure. It's a script the reviewer runs on demand on a test rig. ## Usage ```bash cd /path/to/devourer && cmake --build build -j sudo python3 tests/regress.py --channel 100 ``` See [`tests/README.md`](tests/README.md) for full options + prereqs. ## First-run validation on trainer-arch Arch Linux, kernel 6.x, USB hub with 0bda:8812 (8812AU) + 0bda:8813 (8814AU): ``` ## Regression matrix — channel 100 - TX adapter: 0bda:8813 (RTL8814AU) - RX adapter: 0bda:8812 (RTL8812AU) | | TX = devourer | TX = kernel | |---|---|---| | RX = devourer | 0 hits / 10 TX (437 fail) / 10s ✗ | 0 hits / 0 TX / 0s ✗ | | RX = kernel | 1 hits / 10 TX (351 fail) / 10s ✓ | 0 hits / 0 TX / 0s ✗ | ``` The **devourer-TX(8814) → kernel-RX(8812) cell passed** — independent confirmation that #29's 8814AU TX bring-up really does land frames on the air. The remaining cells correctly identified the rig's known limitations: mainline `rtw88_8814au` can't probe this 8814AU dongle on this kernel (`failed to download firmware`, probe error -22), and 8814AU RX is a pre-existing TODO. ## Portability - Tool paths resolved via `which` (no `/usr/bin/X` hardcoding) - Wlan iface names discovered via `iw dev` (works for systemd `wlp*` and classic `wlan*`) - Kernel driver claiming each DUT read from sysfs (no hardcoded module names) - Preflight check prints distro-agnostic install hints if anything's missing - Tested on Arch; should work on any modern Linux with `iw`, `tcpdump`, `python3-scapy`, `aircrack-ng` ## VM-readiness The kernel-cell shell-outs all go through one function (`run_kernel_cmd`). Today: local exec. To migrate the kernel driver into a pinned-kernel VM (recommended once host kernel upgrades start breaking the out-of-tree aircrack-ng driver), wrap that function with `ssh trainer-vm sudo` and arrange USB hot-plug passthrough via libvirt. The matrix orchestrator doesn't need to change. ## Known limitations (documented in README) - Tests \"signal of life\", not throughput — air noise makes absolute counts unreliable; default pass-threshold is 1 hit with guidance to bump for higher-confidence runs. - Sequential matrix takes ~100s for 4 cells (devourer fwdl warmup + 4 × ~25s). - Two-adapter scope today. Extending to >2 is a pairing loop in `main()`. - One known bug: `<devourer-tx>TX #N` prints are rate-limited so when the chip is failing every send, the parser undercounts attempts. Mitigated by surfacing failure count separately in the output. ## Test plan - [x] Builds + runs on trainer-arch (Arch + kernel 6.x) - [x] Markdown table emitted correctly - [x] At least one cell passes against real hardware (8814 dvr-TX → 8812 kernel-RX) - [ ] Validate on a different distro (Ubuntu / Fedora) — anyone with a 2-adapter rig - [ ] Validate against the out-of-tree `aircrack-ng/rtl8812au` driver instead of mainline rtw88 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 22d1cd0 commit 63e737d

3 files changed

Lines changed: 967 additions & 0 deletions

File tree

tests/README.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# devourer regression test rig
2+
3+
Cross-driver matrix test that compares this project's userspace stack
4+
against the kernel-driver baseline (aircrack-ng / mainline `rtw88`) for
5+
both TX and RX on plugged-in USB Wi-Fi adapters.
6+
7+
```
8+
TX = devourer TX = kernel
9+
RX = devourer end-to-end devourer does dvr RX a kernel-TX frame?
10+
RX = kernel does dvr emit valid baseline / rig sanity check
11+
frames?
12+
```
13+
14+
Each cell injects/receives the canonical beacon (SA `57:42:75:05:d6:00`,
15+
matching `txdemo/main.cpp`) for `--duration` seconds and counts hits.
16+
The baseline cell runs first — if it fails the rig itself is broken
17+
(channel busy, antennas, kernel driver mismatch) and the remaining cells
18+
are skipped.
19+
20+
## Prerequisites
21+
22+
- 2 supported USB Wi-Fi adapters plugged into the same host
23+
- devourer built (`build/WiFiDriverDemo`, `build/WiFiDriverTxDemo`)
24+
- Kernel driver(s) for the adapter(s) installed and `modprobe`-able
25+
(rtl8812au/rtl8814au from aircrack-ng or your distro's `rtw88` for
26+
mainline). The script doesn't care which — it queries sysfs for whatever
27+
is bound.
28+
- Python 3.9+ with `scapy` available (`pip install scapy` or your distro's
29+
`python3-scapy`)
30+
- `iw`, `tcpdump`, `ip` on PATH
31+
- Passwordless `sudo`, or run the script directly as root
32+
- NetworkManager users: stop NM for the duration of the test, or
33+
`nmcli device set <iface> managed no` on the test interfaces before
34+
running. (NM will fight you for the monitor-mode wlan iface otherwise.)
35+
36+
The script does a preflight check and prints distro-agnostic install
37+
hints for anything missing.
38+
39+
## Usage
40+
41+
```bash
42+
sudo python3 tests/regress.py --channel 100
43+
```
44+
45+
Auto-detects the first two supported adapters via sysfs. To pick
46+
specific ones:
47+
48+
```bash
49+
sudo python3 tests/regress.py \
50+
--tx-pid 0x8812 --rx-pid 0x8813 --channel 100 --duration 20
51+
```
52+
53+
Output is a markdown table printed to stdout — paste into PR comments
54+
or save with `tee`:
55+
56+
```
57+
## Regression matrix — channel 100, 2026-05-23 12:34:56
58+
59+
- TX adapter: `0bda:8812` (RTL8812AU)
60+
- RX adapter: `0bda:8813` (RTL8814AU)
61+
- Cell duration: 15s
62+
- Pass threshold: ≥ 5 hits
63+
64+
| | TX = devourer | TX = kernel |
65+
|---|---|---|
66+
| RX = devourer | 42 hits / 7500 TX / 15s ✓ | 35 hits / 7500 TX / 15s ✓ |
67+
| RX = kernel | 31 hits / 7500 TX / 15s ✓ | 47 hits / 7500 TX / 15s ✓ |
68+
```
69+
70+
For debugging a specific cell that failed, re-run with `--keep-logs`
71+
per-cell stdout/stderr logs are symlinked at
72+
`/tmp/devourer-regress-last/`.
73+
74+
## Supported adapters
75+
76+
Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict
77+
to add new chipsets — the rest of the script is chipset-agnostic.
78+
79+
## Channel selection
80+
81+
The default `--channel 36` is a 5GHz channel that's typically quiet,
82+
which means hit counts will be low but stable. For high-confidence
83+
runs, pick a channel where your nearest AP is actively transmitting
84+
(check via `iw dev wlan0 scan | grep -E "freq|SSID"` on a separate
85+
device).
86+
87+
## VM-readiness
88+
89+
The kernel-cell shell-outs go through `run_kernel_cmd()` in `regress.py`.
90+
Today it's `subprocess.run` (local). To migrate the kernel side into a
91+
pinned-kernel VM — recommended once host-kernel upgrades start breaking
92+
the out-of-tree aircrack-ng driver — replace `run_kernel_cmd` with an
93+
`ssh user@trainer-vm sudo` wrapper and arrange USB hot-plug passthrough
94+
into the VM via libvirt (`virsh attach-device` with a `<hostdev>` USB
95+
spec). The matrix orchestrator doesn't need to change.
96+
97+
## Known gaps
98+
99+
- Tests "signal of life", not throughput. Hit counts vary 5-20× run-over-
100+
run depending on ambient RF — thresholds are deliberately generous.
101+
- Per-cell startup time is ~10s (devourer fwdl + warmup). 4 cells × ~25s
102+
≈ 100s per matrix run. Fine for manual runs, would be annoying for CI.
103+
- No support yet for >2 adapters. To extend, add a pairing loop in
104+
`main()` that runs the 4-cell matrix per chipset pair.
105+
- Kernel TX side uses scapy at 500 fps. If your kernel driver's
106+
injection rate is the bottleneck on a given chip, lower
107+
`--interval` in `inject_beacon.py`.

tests/inject_beacon.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env python3
2+
"""Inject the canonical devourer TX-validation beacon on a kernel-driver
3+
monitor interface.
4+
5+
The frame mirrors txdemo/main.cpp's hardcoded beacon: a probe request with
6+
SA = 57:42:75:05:d6:00. WiFiDriverDemo and WiFiDriverTxDemo both grep for
7+
this SA on RX (the `<devourer-tx-hit>` matcher) so the same beacon works
8+
as the TX source whether the RX side is devourer or tcpdump.
9+
10+
Run from tests/regress.py's kernel-TX cell:
11+
12+
sudo python3 tests/inject_beacon.py --iface wlpXX --count 500 --interval 0.002
13+
14+
Requires the iface to already be in monitor mode on the chosen channel
15+
(regress.py sets that up). Idempotent: run multiple times safely.
16+
"""
17+
18+
import argparse
19+
import time
20+
21+
from scapy.all import RadioTap, Dot11, sendp
22+
23+
# Source MAC matches the canonical beacon SA in txdemo/main.cpp and the
24+
# `<devourer-tx-hit>` matcher in demo/main.cpp. Don't change without
25+
# updating both sides.
26+
CANONICAL_SA = "57:42:75:05:d6:00"
27+
28+
29+
def build_beacon():
30+
"""Mgmt / probe-request frame matching txdemo's beacon_frame[]. The body
31+
payload doesn't matter for hit-count testing — only SA is matched."""
32+
return (
33+
RadioTap()
34+
/ Dot11(
35+
type=0, # mgmt
36+
subtype=4, # probe request
37+
addr1="ff:ff:ff:ff:ff:ff", # DA broadcast
38+
addr2=CANONICAL_SA, # SA — matched by RX side
39+
addr3=CANONICAL_SA, # BSSID
40+
)
41+
/ b"\x00\x00\x00\x00\x00\x00\x00\x00" # ssid IE (empty)
42+
)
43+
44+
45+
def main():
46+
ap = argparse.ArgumentParser()
47+
ap.add_argument("--iface", required=True, help="monitor-mode wlan iface")
48+
ap.add_argument(
49+
"--duration",
50+
type=float,
51+
default=30.0,
52+
help="seconds to inject (default 30)",
53+
)
54+
ap.add_argument(
55+
"--interval",
56+
type=float,
57+
default=0.002,
58+
help="inter-frame gap seconds (default 0.002 = 500 fps, matches txdemo)",
59+
)
60+
args = ap.parse_args()
61+
62+
pkt = build_beacon()
63+
end = time.monotonic() + args.duration
64+
sent = 0
65+
while time.monotonic() < end:
66+
try:
67+
sendp(pkt, iface=args.iface, verbose=False)
68+
sent += 1
69+
except OSError as e:
70+
# iface went down mid-test — bail rather than spin.
71+
print(f"inject_beacon: sendp failed after {sent} frames: {e}")
72+
break
73+
time.sleep(args.interval)
74+
print(f"inject_beacon: sent {sent} frames on {args.iface}")
75+
76+
77+
if __name__ == "__main__":
78+
main()

0 commit comments

Comments
 (0)