diff --git a/CMakeLists.txt b/CMakeLists.txt index a809c73..6712a0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,7 @@ add_library(WiFiDriver src/TxMode.h src/TxPower.cpp src/TxPower.h + src/TxStats.h src/ThermalStatus.h src/TxCaps.h src/LinkHealth.cpp diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index ceaeeba..c1abbd5 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -11,6 +11,7 @@ #include "TxCaps.h" #include "TxMode.h" #include "TxPower.h" +#include "TxStats.h" /* Packet is the parsed-RX type handed to the RX callback. Forward-declared here * (a reference in the std::function signature needs only an incomplete type) so @@ -148,6 +149,13 @@ class IRtlDevice { virtual void SetTxMode(const devourer::TxMode & /*mode*/) {} virtual void ClearTxMode() {} + /* TX submission health snapshot (see TxStats.h) — the driver-side drop / + * congestion signal an adaptive-link controller uses to detect a full TX FIFO + * (a bulk-OUT TIMEOUT = recoverable back-pressure) vs a hard error. Counted at + * the shared USB bulk-OUT layer, so every generation reports it. Default is an + * all-zero snapshot. */ + virtual devourer::TxStats GetTxStats() { return {}; } + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h) — the read side * of the DEVOURER_CW_TONE emitter, used for spectrum-sensing / interferer * detection. Reads the chip's phydm false-alarm + CCA counters, DIG/IGI, and diff --git a/src/RtlUsbAdapter.cpp b/src/RtlUsbAdapter.cpp index e922989..0f271c2 100644 --- a/src/RtlUsbAdapter.cpp +++ b/src/RtlUsbAdapter.cpp @@ -435,6 +435,14 @@ void RtlUsbAdapter::transfer_callback(struct libusb_transfer *transfer) { /* Flag the bulk-OUT as possibly halted so the next send_packet (on the TX * thread) re-clear_halts it before the following frame. */ self->_tx_wedged->store(true, std::memory_order_relaxed); + /* Async completion failure — the real drop for the async TX path. A + * TIMED_OUT status is the FIFO-full back-pressure (congestion); anything + * else is a hard error. Record the negated status as the rc. */ + self->_tx_stats->failed.fetch_add(1, std::memory_order_relaxed); + self->_tx_stats->last_rc.store(-transfer->status, std::memory_order_relaxed); + self->_tx_stats->last_timeout.store( + transfer->status == LIBUSB_TRANSFER_TIMED_OUT, + std::memory_order_relaxed); self->_logger->error("Failed to send packet, status: {}, actual length: {}", transfer->status, transfer->actual_length); } @@ -530,6 +538,9 @@ bool RtlUsbAdapter::send_packet(uint8_t *packet, size_t length) { * every completion with status=-2 (ENOENT/cancelled), data_len=0. */ transfer->flags |= LIBUSB_TRANSFER_ADD_ZERO_PACKET; auto start = std::chrono::high_resolution_clock::now(); + /* Count the submission here; async completion (incl. TIMED_OUT) is counted in + * transfer_callback, a submit error just below. */ + _tx_stats->submitted.fetch_add(1, std::memory_order_relaxed); int rc = rc = libusb_submit_transfer(transfer); auto end = std::chrono::high_resolution_clock::now(); std::chrono::duration elapsed = end - start; @@ -538,6 +549,10 @@ bool RtlUsbAdapter::send_packet(uint8_t *packet, size_t length) { elapsed.count()); return true; } else { + _tx_stats->failed.fetch_add(1, std::memory_order_relaxed); + _tx_stats->last_rc.store(rc, std::memory_order_relaxed); + _tx_stats->last_timeout.store(rc == LIBUSB_ERROR_TIMEOUT, + std::memory_order_relaxed); _logger->error("Failed to send packet, error code: {}", rc); libusb_free_transfer(transfer); return false; @@ -556,9 +571,14 @@ int RtlUsbAdapter::bulk_send_sync_ep(uint8_t ep, uint8_t *packet, size_t length, * normal TX-queue operation, not the per-send hot path. Resetting the * data toggle bit corrupts the chip's state machine. */ int actual = 0; + _tx_stats->submitted.fetch_add(1, std::memory_order_relaxed); int rc = libusb_bulk_transfer(_dev_handle, ep, packet, static_cast(length), &actual, timeout_ms); if (rc != LIBUSB_SUCCESS) { + _tx_stats->failed.fetch_add(1, std::memory_order_relaxed); + _tx_stats->last_rc.store(rc, std::memory_order_relaxed); + _tx_stats->last_timeout.store(rc == LIBUSB_ERROR_TIMEOUT, + std::memory_order_relaxed); _logger->error("bulk_send EP {} FAIL rc={} got {}/{}", (int)ep, rc, actual, (int)length); return rc; diff --git a/src/RtlUsbAdapter.h b/src/RtlUsbAdapter.h index 8c531d3..d22291b 100644 --- a/src/RtlUsbAdapter.h +++ b/src/RtlUsbAdapter.h @@ -15,6 +15,7 @@ #include "drv_types.h" #include "hal_com_reg.h" #include "logger.h" +#include "TxStats.h" namespace devourer { class UsbDeviceLock; @@ -72,6 +73,19 @@ class RtlUsbAdapter { std::shared_ptr> _tx_wedged = std::make_shared>(false); + /* TX submission counters (the driver-drop / congestion signal, see + * TxStats.h). Heap-shared for the same reason as _tx_wedged: RtlUsbAdapter is + * a copyable value type, and the async transfer_callback runs on whichever + * copy submitted — all copies must increment the one set of counters. */ + struct TxStatsCounters { + std::atomic submitted{0}; + std::atomic failed{0}; + std::atomic last_rc{0}; + std::atomic last_timeout{false}; + }; + std::shared_ptr _tx_stats = + std::make_shared(); + /* Exclusive per-adapter USB lock, acquired by WiFiDriver::CreateRtlDevice and * held for the device's lifetime (see UsbDeviceLock.h). shared_ptr because * RtlUsbAdapter is a copyable value type copied into every sub-manager @@ -125,6 +139,18 @@ class RtlUsbAdapter { void bulk_clear_halt(uint8_t ep) { libusb_clear_halt(_dev_handle, ep); } int bulk_send_sync_ep(uint8_t ep, uint8_t *packet, size_t length, int timeout_ms); + + /* Snapshot of the TX submission counters (see TxStats.h). Safe from any + * thread — the fields are read from atomics (a torn read across the four is + * harmless for a monitoring counter). */ + devourer::TxStats GetTxStats() const { + devourer::TxStats s; + s.submitted = _tx_stats->submitted.load(std::memory_order_relaxed); + s.failed = _tx_stats->failed.load(std::memory_order_relaxed); + s.last_error_rc = _tx_stats->last_rc.load(std::memory_order_relaxed); + s.last_was_timeout = _tx_stats->last_timeout.load(std::memory_order_relaxed); + return s; + } /* Raw bulk-IN read on the discovered bulk-IN endpoint, returning bytes read * (or a negative libusb error). For chip families whose RX descriptor is not * the Jaguar1 layout (e.g. Jaguar3), the caller parses the buffer itself. */ diff --git a/src/TxStats.h b/src/TxStats.h new file mode 100644 index 0000000..a0a1cf7 --- /dev/null +++ b/src/TxStats.h @@ -0,0 +1,30 @@ +#ifndef DEVOURER_TX_STATS_H +#define DEVOURER_TX_STATS_H + +#include + +namespace devourer { + +/* TX submission health — the driver-side drop signal an adaptive-link + * controller uses to detect congestion (the "xtx" cut-bitrate trigger). Counted + * at the USB bulk-OUT layer, so it is family-agnostic (all three HALs push TX + * through the same RtlUsbAdapter). + * + * `submitted` counts every frame handed to the USB stack; `failed` counts those + * that did not complete OK — either a synchronous submit/transfer error or an + * async URB completion with a non-OK status. The distinction that matters for + * congestion vs. a dead path is `last_was_timeout`: a bulk-OUT TIMEOUT + * (LIBUSB_ERROR_TIMEOUT / LIBUSB_TRANSFER_TIMED_OUT) is the chip NAKing because + * its TX FIFO is full — recoverable back-pressure, the xtx case — whereas a + * hard error (NO_DEVICE, pipe stall, ...) is a broken link. `last_error_rc` is + * the raw libusb code of the most recent failure (0 = none yet). */ +struct TxStats { + uint64_t submitted = 0; + uint64_t failed = 0; + int last_error_rc = 0; /* raw libusb rc / negated transfer status */ + bool last_was_timeout = false; +}; + +} // namespace devourer + +#endif /* DEVOURER_TX_STATS_H */ diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 8331354..dbd507e 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -162,6 +162,7 @@ class RtlJaguarDevice : public IRtlDevice { void ClearTxMode(); bool send_packet(const uint8_t* packet, size_t length) override; + devourer::TxStats GetTxStats() override { return _device.GetTxStats(); } SelectedChannel GetSelectedChannel() override; /* Runtime RX-chain selection — the adaptive-link spatial-diversity lever diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 08d2fa0..27af370 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -58,6 +58,7 @@ class RtlJaguar2Device : public IRtlDevice { void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; bool send_packet(const uint8_t *packet, size_t length) override; + devourer::TxStats GetTxStats() override { return _device.GetTxStats(); } SelectedChannel GetSelectedChannel() override; void Stop() override; void SetTxMode(const devourer::TxMode &mode) override; diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index f5505d4..528637b 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -60,6 +60,7 @@ class RtlJaguar3Device : public IRtlDevice { void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; bool send_packet(const uint8_t *packet, size_t length) override; + devourer::TxStats GetTxStats() override { return _device.GetTxStats(); } SelectedChannel GetSelectedChannel() override; void Stop() override; diff --git a/tests/tx_stats_congestion.sh b/tests/tx_stats_congestion.sh new file mode 100755 index 0000000..814eaff --- /dev/null +++ b/tests/tx_stats_congestion.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Validate GetTxStats () — the driver-drop / congestion signal. +# Back-to-back TX (DEVOURER_TX_GAP_US=0) fills the on-chip TX FIFO, so the USB +# bulk-OUT starts NAKing (LIBUSB_ERROR_TIMEOUT / LIBUSB_TRANSFER_TIMED_OUT) and +# `failed` climbs with was_timeout=1 — the recoverable back-pressure an adaptive +# controller reads as "cut bitrate". A paced link (GAP=2000, ~500 fps) drains +# fine, so failed stays ~0. The counter must distinguish the two. +# +# Runs per plugged DUT (each family so both the async send_packet and the sync +# bulk_send_sync_ep counting paths are exercised). No SDR / no second adapter. +# +# Usage: sudo -v && tests/tx_stats_congestion.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${TXSTATS_OUT:-/tmp/devourer-txstats}" +CH="${CH:-36}" +DUR="${DUR:-12}" +mkdir -p "$OUT" + +PASS=0; FAIL=0; SKIP=0 +pass() { echo " PASS: $*"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); } +skip() { echo " SKIP: $*"; SKIP=$((SKIP+1)); } +cleanup() { pkill -x WiFiDriverTxDem 2>/dev/null||true; true; } +trap cleanup EXIT INT TERM +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo >/dev/null || exit 1 + +# pid vid label +DUTS=( + "0x8812 0x0bda 8812AU-J1" + "0x012d 0x2357 8822BU-J2" + "0xa81a 0x0bda 8822EU-J3" +) + +# One TX cell; echoes "submitted failed was_timeout last_rc" from the final line. +run_cell() { # $1=pid $2=vid $3=gap $4=tag + local log="$OUT/$4.log" + sudo -n env DEVOURER_PID="$1" DEVOURER_VID="$2" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE=MCS3 DEVOURER_TX_GAP_US="$3" \ + timeout "$DUR" "$ROOT/build/WiFiDriverTxDemo" >"$log" 2>&1 || true + local line + line=$(grep "" "$log" | tail -1) + echo "$line" | sed -E 's/.*submitted=([0-9]+) failed=([0-9]+) was_timeout=([0-9]+) last_rc=(-?[0-9]+).*/\1 \2 \3 \4/' +} + +for dut in "${DUTS[@]}"; do + read -r PID VID LABEL <<<"$dut" + if ! plugged "$PID" "$VID"; then skip "$LABEL not plugged"; continue; fi + echo "== $LABEL ==" + read -r p_sub p_fail p_to p_rc <<<"$(run_cell "$PID" "$VID" 2000 "$LABEL-paced")" + read -r b_sub b_fail b_to b_rc <<<"$(run_cell "$PID" "$VID" 0 "$LABEL-b2b")" + p_sub=${p_sub:-0}; p_fail=${p_fail:-0}; b_sub=${b_sub:-0}; b_fail=${b_fail:-0} + b_to=${b_to:-0}; b_rc=${b_rc:-0} + echo " paced (gap=2000): submitted=$p_sub failed=$p_fail" + echo " back-to-back (gap=0): submitted=$b_sub failed=$b_fail was_timeout=$b_to last_rc=$b_rc" + if [ "$p_sub" -lt 100 ]; then + fail "$LABEL: paced cell barely transmitted ($p_sub) — TX path issue, inconclusive" + continue + fi + # Congestion signature: back-to-back drives materially more failures than + # paced (either a real climb, or the was_timeout flag set). Some chips never + # fill the FIFO at USB2 speed — then failed stays ~0 in BOTH, which is a + # valid "no congestion observed" (the counter is still correct: it reports + # what happened). We only hard-fail if the counter is obviously broken + # (submitted not counting). + if [ "$b_sub" -lt 100 ]; then + fail "$LABEL: back-to-back submitted=$b_sub not counting" + elif [ "$b_fail" -gt "$p_fail" ] || [ "$b_to" -eq 1 ]; then + pass "$LABEL: congestion visible — b2b failed=$b_fail (was_timeout=$b_to) > paced failed=$p_fail" + else + pass "$LABEL: counter working, no FIFO congestion at this rate (b2b failed=$b_fail == paced) — reported honestly" + fi +done + +echo +echo "== tx-stats congestion: PASS=$PASS FAIL=$FAIL SKIP=$SKIP ==" +[ "$FAIL" -eq 0 ] diff --git a/txdemo/main.cpp b/txdemo/main.cpp index 40d34e6..8e0f701 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -972,6 +972,14 @@ int main(int argc, char **argv) { frames_in_dwell = 0; if (tx_count <= 10 || tx_count % 500 == 0) { printf("TX #%ld rc=%d\n", tx_count, rc); + /* TX submission health — the driver-drop / congestion feed (xtx). A + * climbing failed with was_timeout=1 is a full TX FIFO (recoverable + * back-pressure); a hard rc is a broken path. */ + auto ts = rtlDevice->GetTxStats(); + printf("submitted=%llu failed=%llu was_timeout=%d " + "last_rc=%d\n", + (unsigned long long)ts.submitted, (unsigned long long)ts.failed, + ts.last_was_timeout ? 1 : 0, ts.last_error_rc); fflush(stdout); } /* Thermal telemetry via the generation-agnostic GetThermalStatus