From 5b27bee1146fe47b4fb6bd978f673b3be9f1188b Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:48:17 +0300 Subject: [PATCH] T1: canary_diff.py + hosted-CI pytest suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the host-side half of the canary cross-validation oracle: a Python differ that compares kernel/devourer canary dumps with runtime-ephemeral register masking, plus 13 unit tests exercising the parser, mask logic, channel-aware capture-state filter, and exit-code contract. Masking rules baked in: - MAC 0x040 / 0x550 / 0x560 — counters that tick continuously - RF[A|B] 0x42 — thermal-meter sample - BB 0xc1c / 0xe1c bits 31:21 — phydm TX-swing thermal index - BB 0xc20 / 0xe20 at 5G — CCK regs that the long-lived kernel iface retains from prior 2.4G activity, while devourer captures from a fresh process; suppressed only when --channel > 14 Exit codes: 0 clean / 1 real divergences / 2 parse error or register-set mismatch. A dedicated path-scoped workflow runs the pytest suite on every PR that touches the differ or its tests — no libusb, no VM, ~1 s. Hardware-side canary capture stays in regress.py / the self-hosted rig. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/canary-diff-tests.yml | 33 ++++ tests/canary_diff.py | 233 ++++++++++++++++++++++++ tests/test_canary_diff.py | 163 +++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 .github/workflows/canary-diff-tests.yml create mode 100755 tests/canary_diff.py create mode 100644 tests/test_canary_diff.py diff --git a/.github/workflows/canary-diff-tests.yml b/.github/workflows/canary-diff-tests.yml new file mode 100644 index 0000000..6bfc1c8 --- /dev/null +++ b/.github/workflows/canary-diff-tests.yml @@ -0,0 +1,33 @@ +name: Canary diff unit tests + +on: + push: + branches: [ "master" ] + paths: + - 'tests/canary_diff.py' + - 'tests/test_canary_diff.py' + - '.github/workflows/canary-diff-tests.yml' + pull_request: + branches: [ "master" ] + paths: + - 'tests/canary_diff.py' + - 'tests/test_canary_diff.py' + - '.github/workflows/canary-diff-tests.yml' + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install pytest + run: pip install pytest + + - name: Run canary_diff tests + run: python -m pytest tests/test_canary_diff.py -v diff --git a/tests/canary_diff.py b/tests/canary_diff.py new file mode 100755 index 0000000..93f318c --- /dev/null +++ b/tests/canary_diff.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""canary_diff.py — diff two devourer/kernel canary dumps with +runtime-ephemeral register masking. + +A clean way to compare: + - `tools/canary_kernel_dump.sh [chip]` output + (kernel side via iwpriv) + - `DEVOURER_DUMP_CANARY=1 ./build/WiFiDriverDemo` block extracted + by `awk '/DEVOURER_DUMP_CANARY \\(post channel-set ch=N\\)/, + /END DEVOURER_DUMP_CANARY/' | sed 's/^//'` + +Both files contain `KIND 0xADDR = 0xVALUE` lines in the same order +(the kernel script mirrors devourer's emit order exactly per +`RadioManagementModule::phy_SwChnlAndSetBwMode8812`). + +Usage: + tests/canary_diff.py \\ + [--channel N] [--strict] [--show-clean] + +Exit status: + 0 — no real init-drift divergence + 1 — divergences found in non-ephemeral registers + 2 — file parse error / register-set mismatch + +Why this isn't just `diff`: + +Several registers shift on every capture for reasons that aren't +init drift — they're runtime state the kernel or devourer keep +updating after init completes. Listing them as divergences would +drown out real bugs. The mask: + + - MAC 0x040, 0x550, 0x560: per-queue / beacon-window / TBTT + counters that increment continuously. + - RF[A] 0x42: thermal-meter sample register; reads vary with + chip temperature so each capture shows a slightly different + value. The thermal value is also the input to phydm's TX + BB-swing tracking (see BB 0xc1c[31:21] below). + - BB 0xc1c bits 31:21 / 0xe1c bits 31:21: TX BB-swing + `tx_scaling_table_jaguar` index, written by `PowerTracking8812a` + (and the kernel's phydm watchdog) based on the thermal-meter + sample. Same drift class as RF[A] 0x42. Other bits of 0xc1c + (AGC table select [11:8], static base bits) ARE checked. + +There's also a known capture-state asymmetry: the kernel iface is +long-lived (CCK regs at 5G retain values written during prior 2.4G +activity), while devourer captures from a fresh process per run +(BB-init defaults). At 5G channels we therefore skip `BB 0xc20` +(rTxAGC_A_CCK11_CCK1_JAguar) because it's CCK-only — never written +at 5G by either side, but reflects different histories. Add more +to `CAPTURE_STATE_5G_ARTIFACTS` if new ones surface. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# Registers always masked (runtime ephemeral on both sides). Per-bit +# masks: if a register has only some bits that drift, give a bit mask +# that we should IGNORE — the diff compares (a & ~mask) vs (b & ~mask). +RUNTIME_EPHEMERAL: dict[tuple[str, int], int] = { + # MAC counters that advance on every TBTT / queue tick. + ("MAC", 0x040): 0xFFFFFFFF, + ("MAC", 0x550): 0xFFFFFFFF, + ("MAC", 0x560): 0xFFFFFFFF, + # RF thermal-meter sample — varies with chip temperature. + ("RF[A]", 0x42): 0xFFFFFFFF, + ("RF[B]", 0x42): 0xFFFFFFFF, + # BB TX-swing thermal pwrtrk — only bits 31:21 (the + # tx_scaling_table_jaguar index) are thermal-tracked. + ("BB", 0xc1c): 0xFFE00000, + ("BB", 0xe1c): 0xFFE00000, +} + +# Capture-state artifacts at 5GHz only — registers that aren't +# written at 5G but retain prior 2.4G state on a long-lived kernel +# iface, while devourer captures from a fresh process. Skip +# entirely when --channel > 14. +CAPTURE_STATE_5G_ARTIFACTS: set[tuple[str, int]] = { + ("BB", 0xc20), # rTxAGC_A_CCK11_CCK1_JAguar + ("BB", 0xe20), # path-B mirror +} + +LINE_RE = re.compile( + r"^(BB|MAC|RF\[[AB]\])\s+0x([0-9a-fA-F]+)\s*=\s*0x([0-9a-fA-F]+)\s*$" +) + + +@dataclass(frozen=True) +class Reading: + kind: str # "BB", "MAC", "RF[A]", "RF[B]" + addr: int + value: int + + +def parse_canary(path: Path) -> dict[tuple[str, int], int]: + """Parse a canary file into {(kind, addr): value}. Skips lines + outside the `=== DEVOURER_DUMP_CANARY ===` envelope (header, + log noise, etc.).""" + readings: dict[tuple[str, int], int] = {} + in_block = False + for raw in path.read_text().splitlines(): + if "DEVOURER_DUMP_CANARY (post channel-set" in raw: + in_block = True + continue + if "END DEVOURER_DUMP_CANARY" in raw: + in_block = False + continue + if not in_block: + continue + m = LINE_RE.match(raw.strip()) + if not m: + continue + kind, addr_s, val_s = m.groups() + readings[(kind, int(addr_s, 16))] = int(val_s, 16) + return readings + + +def diff( + kernel: dict[tuple[str, int], int], + devourer: dict[tuple[str, int], int], + channel: int, + strict: bool, +) -> tuple[list[tuple[tuple[str, int], int, int, str]], list[tuple[str, int]]]: + """Returns (real_divergences, masked_divergences).""" + is_5g = channel > 14 + real: list[tuple[tuple[str, int], int, int, str]] = [] + masked: list[tuple[str, int]] = [] + common_keys = set(kernel.keys()) & set(devourer.keys()) + for key in sorted(common_keys, key=lambda k: (k[0], k[1])): + k_val = kernel[key] + d_val = devourer[key] + if is_5g and key in CAPTURE_STATE_5G_ARTIFACTS and not strict: + if k_val != d_val: + masked.append(key) + continue + ignore_mask = RUNTIME_EPHEMERAL.get(key, 0) if not strict else 0 + keep_mask = 0xFFFFFFFF & ~ignore_mask + if (k_val & keep_mask) == (d_val & keep_mask): + continue + if ignore_mask: + tag = f"masked-bits=0x{ignore_mask:x}" + else: + tag = "real" + real.append((key, k_val, d_val, tag)) + return real, masked + + +def report_set_mismatch( + kernel: dict[tuple[str, int], int], + devourer: dict[tuple[str, int], int], +) -> bool: + """Return True if the register sets disagree.""" + k_only = set(kernel) - set(devourer) + d_only = set(devourer) - set(kernel) + if not k_only and not d_only: + return False + if k_only: + sys.stderr.write("Registers in kernel dump but not devourer:\n") + for kind, addr in sorted(k_only): + sys.stderr.write(f" {kind} 0x{addr:x}\n") + if d_only: + sys.stderr.write("Registers in devourer dump but not kernel:\n") + for kind, addr in sorted(d_only): + sys.stderr.write(f" {kind} 0x{addr:x}\n") + return True + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("kernel", type=Path, help="kernel canary dump") + ap.add_argument("devourer", type=Path, help="devourer canary dump") + ap.add_argument("--channel", type=int, default=6, + help="channel the captures are from (controls 5GHz " + "capture-state masking)") + ap.add_argument("--strict", action="store_true", + help="disable all masking — report every divergence") + ap.add_argument("--show-clean", action="store_true", + help="print 'CLEAN' line even if no divergences") + args = ap.parse_args() + + kernel = parse_canary(args.kernel) + devourer = parse_canary(args.devourer) + + if not kernel: + sys.stderr.write(f"no canary readings parsed from {args.kernel}\n") + return 2 + if not devourer: + sys.stderr.write(f"no canary readings parsed from {args.devourer}\n") + return 2 + + if report_set_mismatch(kernel, devourer): + sys.stderr.write( + "register sets diverge — kernel/devourer canary " + "lists are out of sync\n") + return 2 + + real, masked = diff(kernel, devourer, args.channel, args.strict) + + if not real: + if masked: + print(f"Capture-state-artifact registers masked (5G only): " + f"{', '.join(f'{kind} 0x{addr:x}' for kind, addr in masked)}") + if args.show_clean: + print(f"CLEAN ({len(kernel)} regs compared, " + f"{len(masked)} masked at ch={args.channel})") + return 0 + + print(f"Canary diff ({len(kernel)} regs, ch={args.channel}):") + width = max(len(f"{kind} 0x{addr:x}") for (kind, addr), *_ in real) + print(f" {'Register':<{width}} Kernel Devourer Notes") + print(f" {'-'*width} ----------- ----------- -----") + for (kind, addr), k_val, d_val, tag in real: + reg = f"{kind} 0x{addr:x}" + notes = "" if tag == "real" else tag + print(f" {reg:<{width}} 0x{k_val:08x} 0x{d_val:08x} {notes}") + + if masked: + print() + print(f"Capture-state-artifact registers masked (5G only): " + f"{', '.join(f'{kind} 0x{addr:x}' for kind, addr in masked)}") + + print() + print(f"FAIL: {len(real)} real divergence(s)") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_canary_diff.py b/tests/test_canary_diff.py new file mode 100644 index 0000000..0e3aa33 --- /dev/null +++ b/tests/test_canary_diff.py @@ -0,0 +1,163 @@ +"""Unit tests for tests/canary_diff.py. + +Runs on any hosted CI runner — no USB hardware, no VM. Exercises the +parser, mask logic, channel-aware capture-state filter, and exit-code +contract. Hardware-side canary capture stays in regress.py / the +self-hosted rig. +""" + +from __future__ import annotations + +import sys +import subprocess +import textwrap +from pathlib import Path + +HERE = Path(__file__).parent +SCRIPT = HERE / "canary_diff.py" + + +def run_diff(kernel: str, devourer: str, *extra_args: str, + tmp_path: Path) -> subprocess.CompletedProcess: + kf = tmp_path / "kernel.canary" + df = tmp_path / "dev.canary" + kf.write_text(kernel) + df.write_text(devourer) + return subprocess.run( + [sys.executable, str(SCRIPT), str(kf), str(df), *extra_args], + capture_output=True, text=True, + ) + + +CANARY_HEADER = "=== DEVOURER_DUMP_CANARY (post channel-set ch=6) ===" +CANARY_FOOTER = "=== END DEVOURER_DUMP_CANARY ===" + + +def wrap(body: str) -> str: + return f"{CANARY_HEADER}\n{textwrap.dedent(body).strip()}\n{CANARY_FOOTER}\n" + + +def test_clean_diff_exits_zero(tmp_path: Path) -> None: + canary = wrap(""" + BB 0x808 = 0x3E028233 + MAC 0x040 = 0x000C0000 + RF[A] 0x00 = 0x33EA9 + """) + res = run_diff(canary, canary, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_real_divergence_exits_one(tmp_path: Path) -> None: + kernel = wrap("BB 0x808 = 0x3E028233") + devourer = wrap("BB 0x808 = 0x3E028299") + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 1, res.stdout + res.stderr + assert "BB 0x808" in res.stdout + assert "FAIL: 1 real divergence" in res.stdout + + +def test_ephemeral_mac_counter_masked(tmp_path: Path) -> None: + """MAC 0x550 is a beacon-window counter; differences should be + masked out and exit 0.""" + kernel = wrap("MAC 0x550 = 0x00001019") + devourer = wrap("MAC 0x550 = 0x00001010") + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_ephemeral_rf_thermal_masked(tmp_path: Path) -> None: + """RF[A] 0x42 is the thermal-meter sample register.""" + kernel = wrap("RF[A] 0x42 = 0x0B160") + devourer = wrap("RF[A] 0x42 = 0x098F8") + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_bb_swing_thermal_bits_masked(tmp_path: Path) -> None: + """BB 0xc1c bits 31:21 are thermal-tracked; the rest (e.g. AGC + table select bits 11:8) should still diff. Kernel 0x23E in + high bits = 0x47C00003, devourer 0x200 in high bits = + 0x40000003 — same low bits, diff masked → clean.""" + kernel = wrap("BB 0xc1c = 0x47C00003") + devourer = wrap("BB 0xc1c = 0x40000003") + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_bb_swing_lower_bits_still_diffed(tmp_path: Path) -> None: + """BB 0xc1c bits 0-20 are NOT masked — a real divergence + in the AGC-table-select bits should fail the diff.""" + # Same upper bits, different lower bits. + kernel = wrap("BB 0xc1c = 0x47C00003") + devourer = wrap("BB 0xc1c = 0x47C00103") # bit 8 differs + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 1, res.stdout + res.stderr + + +def test_strict_disables_masking(tmp_path: Path) -> None: + kernel = wrap("MAC 0x550 = 0x00001019") + devourer = wrap("MAC 0x550 = 0x00001010") + res = run_diff(kernel, devourer, "--strict", tmp_path=tmp_path) + assert res.returncode == 1, res.stdout + res.stderr + + +def test_5g_capture_state_artifact_masked(tmp_path: Path) -> None: + """BB 0xc20 is CCK-only — never written at 5G by either side, but + kernel iface (long-lived) retains a 2.4G value while devourer + starts fresh. At ch100 the diff should be masked.""" + kernel = wrap("BB 0xc20 = 0x2F2F2F2F") + devourer = wrap("BB 0xc20 = 0x12121212") + res = run_diff(kernel, devourer, "--channel", "100", tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + # The masked-artifact note should mention 0xc20. + assert "0xc20" in res.stdout + + +def test_5g_capture_state_artifact_NOT_masked_at_2g(tmp_path: Path) -> None: + """Same divergence at ch6 is treated as a real difference (CCK is + active at 2.4G; if it diverges there it's a real init drift).""" + kernel = wrap("BB 0xc20 = 0x2F2F2F2F") + devourer = wrap("BB 0xc20 = 0x12121212") + res = run_diff(kernel, devourer, "--channel", "6", tmp_path=tmp_path) + assert res.returncode == 1, res.stdout + res.stderr + + +def test_register_set_mismatch_exits_two(tmp_path: Path) -> None: + kernel = wrap(""" + BB 0x808 = 0x3E028233 + BB 0x80c = 0x12131113 + """) + devourer = wrap("BB 0x808 = 0x3E028233") # 0x80c missing + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 2, res.stdout + res.stderr + assert "BB 0x80c" in res.stderr + + +def test_empty_file_exits_two(tmp_path: Path) -> None: + res = run_diff("", wrap("BB 0x808 = 0x3E028233"), tmp_path=tmp_path) + assert res.returncode == 2 + assert "no canary readings parsed" in res.stderr + + +def test_lines_outside_envelope_ignored(tmp_path: Path) -> None: + """Devourer logs `...` lines that the awk extractor + strips. If extra noise leaks in via a different path, the parser + should still only consider the envelope.""" + kernel = wrap("BB 0x808 = 0x3E028233") + # Extra junk before + after the envelope. + devourer = ( + "spurious log line that should be ignored\n" + "BB 0x808 = 0xDEADBEEF\n" # outside envelope — ignored + "MAC 0x040 = 0xCAFEBABE\n" # outside envelope — ignored + + wrap("BB 0x808 = 0x3E028233") + + "trailing junk\n" + ) + res = run_diff(kernel, devourer, tmp_path=tmp_path) + assert res.returncode == 0, res.stdout + res.stderr + + +def test_show_clean_emits_clean_line(tmp_path: Path) -> None: + canary = wrap("BB 0x808 = 0x3E028233") + res = run_diff(canary, canary, "--show-clean", tmp_path=tmp_path) + assert res.returncode == 0 + assert "CLEAN" in res.stdout