From f39f47d6aaa6a210bdca947edd804a3c9d37bc0c Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Mon, 25 May 2026 18:18:03 +0300 Subject: [PATCH] tests: add VHT encoding combos + tests/sniff_air.py radiotap verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `tests/regress.py --encoding-matrix` (PR #39) with two follow-ups that together close the loop on chip-specific RX encoding limitations. ## What this is 1. **VHT combos in `ENCODING_COMBOS`.** Adds `VHT-BCC` and `VHT-LDPC` (radiotap bit 21, 22-byte VHT info field — single-user MCS 0 / NSS 1 / 20 MHz) alongside the existing HT combos (radiotap bit 19). The motivating case is RTL8821AU: its reported LDPC-RX-no limitation (per Eachine Sphere Link via @RomanLut) is on the VHT path, not HT. The chip's HT and VHT decoders are separate code paths in silicon, so an HT-LDPC test passing doesn't tell us anything about VHT-LDPC. Matrix grows from 16 → 24 cells per run (6 combos × 4 driver modes). 2. **`tests/sniff_air.py`.** Standalone helper that captures on a monitor-mode iface (intended use: AR9271, per kaeru ref `AR9271 as peer-sniffer for devourer TX validation`) and decodes each captured frame's radiotap to report what encoding actually flew. Filters on the canonical injection SA. Answers the "did mac80211 actually emit what inject_beacon.py requested, or strip the flags before the air" question that the encoding-matrix itself can't. Internal radiotap parser handles MCS info (bit 19) and VHT info (bit 21) — same fields the inject/txdemo sides emit. Round-trip tested: every combo emitted by inject_beacon.py parses back to the same (kind, mcs, nss, ldpc, stbc, bw) tuple in sniff_air.py. ## Implementation - `tests/inject_beacon.py`: `_build_radiotap_vht` helper (hand-built 22-byte VHT radiotap), `--vht / --vht-mcs / --vht-nss / --bandwidth 20|40|80|160` flags. Existing `--ldpc / --stbc / --mcs` work for both HT and VHT modes — they map to the appropriate field for whichever mode is active. - `txdemo/main.cpp`: when `DEVOURER_TX_VHT=1`, replaces the 13-byte HT radiotap prefix with a 22-byte VHT radiotap header built dynamically from `DEVOURER_TX_VHT_MCS / _VHT_NSS / _LDPC / _STBC / _BW` env vars. Refactored TX buffer to `std::vector` so it can hold either prefix length. Cross-checked against `inject_beacon.py`'s builder for byte-identical output. - `tests/regress.py`: `ENCODING_COMBOS` extended with VHT-BCC / VHT-LDPC. `_devourer_env` + `_spawn_kernel_tx` pass `vht / vht_mcs / nss` through. No new flags. - `tests/sniff_air.py`: new file (~270 lines). Standalone, runs via `sudo python3 tests/sniff_air.py --iface --channel N --duration N`. Sets iface to monitor mode, captures via tcpdump, parses pcap manually (no scapy.rdpcap dependency — scapy's radiotap parser is hit-or-miss for LDPC/STBC bits), groups frames by encoding, prints distribution. - `tests/README.md`: documents both pieces. Replaces the "HT-only, validated didn't reproduce LDPC asymmetry" caveat with a more general "kernel-TX encoding flags may not always reach the air, use sniff_air.py to prove what flew" paragraph. ## Validation Built and ran end-to-end on the lab rig 2026-05-25, channel 100, RTL8814AU TX → RTL8821AU RX, VM mode (devourer-testrig). `--encoding-matrix` table (24 cells, all ran cleanly): | Mode | HT-BCC | HT-LDPC | HT-STBC=1 | HT-LDPC+STBC | VHT-BCC | VHT-LDPC | |---|---|---|---|---|---|---| | k/k | 466 ✓ | 455 ✓ | 462 ✓ | 443 ✓ | 435 ✓ | 453 ✓ | | d/k | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | | k/d | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | | d/d | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | VHT-LDPC `k/d` still flat at ~400 (same as VHT-BCC and HT-LDPC). 8821AU RX accepted every cell. Two reads: 1. Kernel-TX path strips radiotap LDPC bit regardless of HT vs VHT mode → would explain why the chip never has to refuse an LDPC frame. `tests/sniff_air.py` on an AR9271 would prove or disprove this. 2. 8821AU does not actually have the LDPC-RX-no limitation, or it only triggers under different conditions (specific MCS / NSS / bandwidth / aggregation pattern). Without an AR9271 in the rig today, can't pick between (1) and (2) in this commit. The infrastructure to answer it is now in place. `d/k` and `d/d` rows blank because 8814 TX is broken on master (known issue, separate from this PR). They are the ground-truth path for devourer-side encoding — `WiFiDriverTxDemo` writes radiotap directly into the chip bulk-OUT buffer, no kernel filtering. Sniffer parser unit-tested against every combo `inject_beacon.py` can emit (HT-BCC / HT-LDPC / HT-STBC=1 / VHT-BCC / VHT-LDPC / VHT-LDPC+STBC) — round-trips with byte-identical results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/README.md | 76 ++++++---- tests/inject_beacon.py | 77 ++++++++-- tests/regress.py | 27 +++- tests/sniff_air.py | 310 +++++++++++++++++++++++++++++++++++++++++ txdemo/main.cpp | 110 ++++++++++++--- 5 files changed, 546 insertions(+), 54 deletions(-) create mode 100644 tests/sniff_air.py diff --git a/tests/README.md b/tests/README.md index 6ad1a88..2184886 100644 --- a/tests/README.md +++ b/tests/README.md @@ -160,37 +160,61 @@ sudo python3 tests/regress.py --encoding-matrix \ --vm-name devourer-testrig --vm-ssh @ ``` -Encoding combos iterated (with MCS 1, 20 MHz): `BCC`, `LDPC`, `STBC=1`, -`LDPC+STBC=1`. Add more in `ENCODING_COMBOS` at the top of `regress.py` -if you need other mixes (e.g. MCS 7, 40 MHz, or VHT). +Encoding combos iterated by default — 6 cells per driver mode × 4 driver +modes = 24 cells total per run: -The underlying knobs are also usable standalone for one-off targeted -TX: - -- Devourer TX: `DEVOURER_TX_LDPC=1`, `DEVOURER_TX_STBC=1`, - `DEVOURER_TX_MCS=N`, `DEVOURER_TX_BW=20|40` env vars read by - `WiFiDriverTxDemo` to patch the radiotap MCS field at startup. -- Kernel-side scapy TX: `--ldpc`, `--stbc N`, `--mcs N`, `--bandwidth - 20|40` flags on `tests/inject_beacon.py`. - -#### Known caveat: kernel-TX encoding flags may not reach the air +| Combo | Radiotap bit | Notes | +|---|---|---| +| `HT-BCC` | 19 (MCS info) | MCS 1, BCC FEC, 20 MHz | +| `HT-LDPC` | 19 | MCS 1, FEC=LDPC | +| `HT-STBC=1` | 19 | MCS 1, 1 STBC stream | +| `HT-LDPC+STBC` | 19 | MCS 1, FEC=LDPC + 1 STBC stream | +| `VHT-BCC` | 21 (VHT info) | VHT MCS 0, NSS 1, BCC, 20 MHz | +| `VHT-LDPC` | 21 | VHT MCS 0, NSS 1, FEC=LDPC | + +VHT (802.11ac) coverage exists because some chips' LDPC decoder limitation +is on the VHT path only — the silicon's HT-LDPC and VHT-LDPC are separate +blocks. RTL8821AU is the motivating case (HT-LDPC tests pass, VHT-LDPC +reportedly fails at RX). Add more in `ENCODING_COMBOS` at the top of +`regress.py` if you need MCS 7, 40/80 MHz, or higher NSS. + +The underlying knobs are also usable standalone for one-off targeted TX: + +- **Devourer TX:** `DEVOURER_TX_MCS=N`, `DEVOURER_TX_LDPC=1`, + `DEVOURER_TX_STBC=N`, `DEVOURER_TX_BW=20|40|80|160` env vars read by + `WiFiDriverTxDemo`. Default mode is HT; `DEVOURER_TX_VHT=1` switches to + a VHT radiotap header (22 bytes) and exposes `DEVOURER_TX_VHT_MCS=N` + + `DEVOURER_TX_VHT_NSS=N`. `_LDPC` / `_STBC` / `_BW` apply to whichever + mode is active. +- **Kernel-side scapy TX:** `--mcs N` / `--ldpc` / `--stbc N` / `--bandwidth + 20|40|80|160` on `tests/inject_beacon.py`, plus `--vht` / `--vht-mcs N` + / `--vht-nss N` for VHT mode. + +#### Caveat: kernel-TX encoding flags may not always reach the air mac80211 + the `aircrack-ng/88XXau` driver don't necessarily honour the radiotap MCS flags on TX — the chip's own rate-selection logic may override them. So the `k/k` and `k/d` rows of the table reflect what -the *kernel* driver chose to transmit, which may collapse all four -encoding columns onto the chip's default. Validated 2026-05-25 with -`--encoding-matrix --tx-pid 0x8813 --rx-pid 0x0120`: the `k/d` row was -flat across all four columns (`~400 hits`), consistent with the kernel -stripping the LDPC/STBC bits before they reached the air rather than -the 8821AU RX accepting LDPC frames. - -The `d/k` and `d/d` rows are not affected — `WiFiDriverTxDemo` writes -the radiotap header directly into the chip's bulk-OUT buffer, so the -`DEVOURER_TX_*` env vars are the ground truth for what flies. To -validate kernel-TX encoding made it on-air, run a third adapter as -sniffer (e.g. AR9271 via tcpdump on the same channel) and inspect the -MCS flags in the captured radiotap. +the *kernel* driver chose to transmit, which may collapse encoding +columns onto the chip's default. + +To prove what actually flew, run `tests/sniff_air.py` on a third +adapter (AR9271 is the canonical sniffer — it speaks vanilla radiotap +without driver-side filtering) on the same channel, in parallel with +the matrix. Output reports each captured frame's decoded radiotap +MCS / VHT info, including LDPC bit + STBC streams. If a `--ldpc` +injection comes back tagged as BCC, the kernel stripped it before the +air. + +```bash +sudo python3 tests/sniff_air.py --iface wlan0mon --channel 100 \ + --duration 60 +``` + +The `d/k` and `d/d` rows are not affected by the kernel-TX caveat — +`WiFiDriverTxDemo` writes the radiotap header directly into the +chip's bulk-OUT buffer, so the `DEVOURER_TX_*` env vars are ground +truth for what flies on devourer TX. ## Supported DUTs diff --git a/tests/inject_beacon.py b/tests/inject_beacon.py index 2f71aba..b463dfe 100755 --- a/tests/inject_beacon.py +++ b/tests/inject_beacon.py @@ -30,11 +30,12 @@ # https://www.radiotap.org/. _RT_TX_FLAGS = 1 << 15 _RT_MCS = 1 << 19 +_RT_VHT = 1 << 21 def _build_radiotap_mcs(*, mcs: int, ldpc: bool, stbc: int, bandwidth: int, tx_flags: int = 0x0008) -> bytes: - """Hand-rolled radiotap header with TX Flags + MCS info. + """Hand-rolled radiotap header with TX Flags + HT MCS info. Scapy's RadioTap layer doesn't expose first-class LDPC / STBC fields, and its `MCS` post-field plumbing is brittle across versions. Easier to emit @@ -55,8 +56,41 @@ def _build_radiotap_mcs(*, mcs: int, ldpc: bool, stbc: int, bandwidth: int, ) +def _build_radiotap_vht(*, vht_mcs: int, nss: int, ldpc: bool, stbc: bool, + bandwidth: int, tx_flags: int = 0x0008) -> bytes: + """Hand-rolled radiotap header with TX Flags + VHT info (radiotap bit 21). + + VHT is 802.11ac's encoding spec; required for testing chips like RTL8821AU + whose LDPC behaviour at HT (bit 19) doesn't reflect their VHT path. Layout + (22 bytes total): 8-byte header (version/pad/it_len/it_present), 2-byte TX + Flags, then 12 bytes of VHT info — u16 known, u8 flags, u8 bandwidth, + u8[4] mcs_nss (user 0..3 each = mcs<<4 | nss), u8 coding (LDPC nibble per + user), u8 group_id, u16 partial_aid. Single-user only (users 1-3 zeroed). + """ + bw_code = {20: 0, 40: 1, 80: 4, 160: 11}.get(bandwidth, 0) + # known: bit 0 STBC, bit 2 GI, bit 6 bandwidth known. + known = (1 << 0) | (1 << 2) | (1 << 6) + flags = 0 + if stbc: + flags |= 0x01 # bit 0: STBC enabled + mcs_nss = bytes([((vht_mcs & 0xF) << 4) | (nss & 0xF), 0, 0, 0]) + coding = 0x01 if ldpc else 0x00 # user-0 nibble: 0=BCC, 1=LDPC + vht_info = ( + struct.pack(' int: + return (offset + align - 1) & ~(align - 1) + + +def _parse_radiotap(frame: bytes): + """Decode radiotap MCS (bit 19) / VHT (bit 21) info from one captured + frame. Returns dict with keys: kind ('HT' | 'VHT' | 'legacy'), mcs, + ldpc, stbc, bw, nss (where applicable).""" + if len(frame) < 8: + return None + version, _pad, it_len = struct.unpack_from(" len(frame): + return None + + # Walk all it_present words (continue while ext bit set). + presence: list[int] = [] + off = 4 + while off + 4 <= it_len: + word = struct.unpack_from(" it_len: + return None + if bit == 19: # MCS info + mcs_known, mcs_flags, mcs_idx = struct.unpack_from( + "> 4) & 0xF + nss = parsed["vht_mcs_nss"][0] & 0xF + bw_map = {0: 20, 1: 40, 4: 80, 11: 160} + bw = bw_map.get(parsed["vht_bw"], parsed["vht_bw"]) + return { + "kind": "VHT", + "mcs": mcs, + "nss": nss, + "ldpc": bool(parsed["vht_coding"] & 0x01), + "stbc": bool(parsed["vht_flags"] & 0x01), + "bw": bw, + "rt_len": it_len, + } + if "mcs_idx" in parsed: + bw_lo = parsed["mcs_flags"] & 0x03 + bw = 40 if bw_lo == 1 else 20 + return { + "kind": "HT", + "mcs": parsed["mcs_idx"], + "nss": 1, # HT doesn't carry NSS in radiotap, default to 1 + "ldpc": bool(parsed["mcs_flags"] & 0x10), + "stbc": (parsed["mcs_flags"] >> 5) & 0x3, + "bw": bw, + "rt_len": it_len, + } + return {"kind": "legacy", "rt_len": it_len} + + +def _frame_sa(frame: bytes) -> bytes | None: + """Extract the 802.11 frame's SA (addr2) from a captured frame. + Frame layout: radiotap header (variable, length in bytes 2-3 LE), + then 802.11 frame starts with FC u16 / Duration u16 / addr1[6] / + addr2[6] ...""" + if len(frame) < 4: + return None + it_len = struct.unpack_from(" len(frame): + return None + return frame[sa_offset:sa_offset + 6] + + +def _set_monitor(iface: str, channel: int) -> None: + """Tear iface down, retype it to monitor, set channel, bring it up. + Same sequence as regress.py's iface_to_monitor.""" + cmds = [ + ["ip", "link", "set", iface, "down"], + ["iw", "dev", iface, "set", "type", "monitor"], + ["ip", "link", "set", iface, "up"], + ["iw", "dev", iface, "set", "channel", str(channel)], + ] + for c in cmds: + subprocess.run(c, check=True) + + +def _capture(iface: str, duration: float, pcap_path: Path) -> int: + """Run tcpdump filtered on the canonical SA for `duration` seconds. + Writes a pcap to `pcap_path`. Returns frames-written count from tcpdump + stderr ('N packets captured').""" + sa_str = ":".join(f"{b:02x}" for b in CANONICAL_SA) + proc = subprocess.run( + ["timeout", "--signal=INT", "--kill-after=2", str(duration), + "tcpdump", "-i", iface, "-w", str(pcap_path), "-U", "-nn", + f"ether src {sa_str}"], + capture_output=True, text=True, + ) + # tcpdump prints "N packets captured" to stderr after exit on SIGINT. + for line in (proc.stderr or "").splitlines(): + if "packets captured" in line: + try: + return int(line.split()[0]) + except ValueError: + pass + return 0 + + +def _read_pcap_frames(pcap_path: Path): + """Minimal pcap-savefile reader. Yields each frame's raw bytes. + Avoids depending on scapy.rdpcap so this can run on hosts without + the full scapy install.""" + with pcap_path.open("rb") as f: + gh = f.read(24) + if len(gh) != 24: + return + magic = struct.unpack_from(" #include #include +#include #if defined(_MSC_VER) #include @@ -205,35 +206,112 @@ int main(int argc, char **argv) { 0x8f, 0x28, 0x6f, 0x10, 0xb0, 0xa9, 0x5d, 0xbf, 0xcb, 0x6f}; /* Radiotap MCS info lives at beacon_frame[10..12]: known mask, flags, idx. - * Defaults encode MCS 1 / 20 MHz / long GI / BCC / no STBC. Env knobs let - * tests/regress.py --encoding-matrix exercise LDPC and STBC paths — needed - * to surface chip-specific asymmetries like the RTL8821AU LDPC-RX-no - * limitation (the chip can TX LDPC but its decoder can't RX LDPC frames). */ + * Defaults encode HT MCS 1 / 20 MHz / long GI / BCC / no STBC. Env knobs + * let tests/regress.py --encoding-matrix exercise LDPC and STBC paths — + * needed to surface chip-specific asymmetries like the RTL8821AU + * LDPC-RX-no limitation. DEVOURER_TX_VHT=1 switches to a VHT (802.11ac) + * radiotap header instead (radiotap bit 21, 22-byte length) — required + * for chips whose LDPC RX limitation only appears on the VHT path. */ + bool tx_vht = std::getenv("DEVOURER_TX_VHT") != nullptr; if (const char *m = std::getenv("DEVOURER_TX_MCS")) { beacon_frame[12] = static_cast(std::strtoul(m, nullptr, 0) & 0x7F); - logger->info("DEVOURER_TX_MCS — MCS index set to {}", beacon_frame[12]); + logger->info("DEVOURER_TX_MCS — HT MCS index set to {}", beacon_frame[12]); } uint8_t mcs_flags = beacon_frame[11]; - if (std::getenv("DEVOURER_TX_LDPC")) { - mcs_flags |= 0x10; /* MCS flags bit 4 = FEC type LDPC */ - logger->info("DEVOURER_TX_LDPC — FEC=LDPC"); + bool tx_ldpc = std::getenv("DEVOURER_TX_LDPC") != nullptr; + if (tx_ldpc && !tx_vht) { + mcs_flags |= 0x10; /* HT MCS flags bit 4 = FEC type LDPC */ + logger->info("DEVOURER_TX_LDPC — FEC=LDPC (HT)"); } + int tx_stbc = 0; if (const char *s = std::getenv("DEVOURER_TX_STBC")) { - int n = std::atoi(s) & 0x3; - mcs_flags = static_cast((mcs_flags & ~0x60) | (n << 5)); - logger->info("DEVOURER_TX_STBC — {} STBC stream(s)", n); + tx_stbc = std::atoi(s) & 0x3; + if (!tx_vht) { + mcs_flags = static_cast((mcs_flags & ~0x60) | (tx_stbc << 5)); + } + logger->info("DEVOURER_TX_STBC — {} STBC stream(s)", tx_stbc); } + int tx_bw = 20; if (const char *bw = std::getenv("DEVOURER_TX_BW")) { - int b = std::atoi(bw); - uint8_t code = (b == 40) ? 0x01 : 0x00; - mcs_flags = static_cast((mcs_flags & ~0x03) | code); - logger->info("DEVOURER_TX_BW — {} MHz", b); + tx_bw = std::atoi(bw); + if (!tx_vht) { + uint8_t code = (tx_bw == 40) ? 0x01 : 0x00; + mcs_flags = static_cast((mcs_flags & ~0x03) | code); + } + logger->info("DEVOURER_TX_BW — {} MHz", tx_bw); } beacon_frame[11] = mcs_flags; + /* Build the final TX buffer. Default: send beacon_frame[] verbatim (the + * existing HT path, with the in-place patches above already applied). VHT + * mode: swap the first 13 bytes (HT radiotap) for a 22-byte VHT radiotap, + * keep the 802.11 frame body unchanged. */ + std::vector tx_buf; + if (tx_vht) { + int vht_mcs = 0; + int vht_nss = 1; + if (const char *vm = std::getenv("DEVOURER_TX_VHT_MCS")) { + vht_mcs = std::atoi(vm) & 0xF; + } + if (const char *vn = std::getenv("DEVOURER_TX_VHT_NSS")) { + vht_nss = std::atoi(vn) & 0xF; + } + uint8_t bw_code = 0; + switch (tx_bw) { + case 40: bw_code = 1; break; + case 80: bw_code = 4; break; + case 160: bw_code = 11; break; + default: bw_code = 0; break; + } + /* VHT radiotap layout (22 bytes): header(8) + TX Flags(2) + VHT info(12). + * VHT info: u16 known, u8 flags, u8 bw, u8[4] mcs_nss, u8 coding, + * u8 group_id, u16 partial_aid. Mirrors tests/inject_beacon.py's + * _build_radiotap_vht. */ + const uint16_t known = (1u << 0) | (1u << 2) | (1u << 6); /* STBC|GI|BW */ + const uint8_t vht_info_flags = tx_stbc ? 0x01 : 0x00; + const uint8_t mcs_nss_user0 = + static_cast(((vht_mcs & 0xF) << 4) | (vht_nss & 0xF)); + const uint8_t coding = tx_ldpc ? 0x01 : 0x00; /* user-0 nibble */ + /* it_present = (1<<15) TX Flags | (1<<21) VHT */ + const uint32_t it_present = (1u << 15) | (1u << 21); + const uint16_t it_len = 22; + const uint16_t tx_flags = 0x0008; + tx_buf.reserve(22 + sizeof(beacon_frame) - 13); + /* radiotap header */ + tx_buf.push_back(0); /* version */ + tx_buf.push_back(0); /* pad */ + tx_buf.push_back(static_cast(it_len & 0xFF)); + tx_buf.push_back(static_cast((it_len >> 8) & 0xFF)); + tx_buf.push_back(static_cast(it_present & 0xFF)); + tx_buf.push_back(static_cast((it_present >> 8) & 0xFF)); + tx_buf.push_back(static_cast((it_present >> 16) & 0xFF)); + tx_buf.push_back(static_cast((it_present >> 24) & 0xFF)); + /* TX Flags */ + tx_buf.push_back(static_cast(tx_flags & 0xFF)); + tx_buf.push_back(static_cast((tx_flags >> 8) & 0xFF)); + /* VHT info */ + tx_buf.push_back(static_cast(known & 0xFF)); + tx_buf.push_back(static_cast((known >> 8) & 0xFF)); + tx_buf.push_back(vht_info_flags); + tx_buf.push_back(bw_code); + tx_buf.push_back(mcs_nss_user0); + tx_buf.push_back(0); tx_buf.push_back(0); tx_buf.push_back(0); /* users 1-3 */ + tx_buf.push_back(coding); + tx_buf.push_back(0); /* group_id */ + tx_buf.push_back(0); tx_buf.push_back(0); /* partial_aid LE */ + /* 802.11 frame body (skip the original 13-byte HT radiotap). */ + tx_buf.insert(tx_buf.end(), + beacon_frame + 13, beacon_frame + sizeof(beacon_frame)); + logger->info( + "DEVOURER_TX_VHT — VHT radiotap: mcs={} nss={} ldpc={} stbc={} bw={}MHz", + vht_mcs, vht_nss, tx_ldpc ? 1 : 0, tx_stbc, tx_bw); + } else { + tx_buf.assign(beacon_frame, beacon_frame + sizeof(beacon_frame)); + } + long tx_count = 0; while (true) { - rc = rtlDevice->send_packet(beacon_frame, sizeof(beacon_frame)); + rc = rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); ++tx_count; if (tx_count <= 10 || tx_count % 500 == 0) { printf("TX #%ld rc=%d\n", tx_count, rc);