diff --git a/examples/common/stream_stdin.h b/examples/common/stream_stdin.h index 4d49f37..a5aea3d 100644 --- a/examples/common/stream_stdin.h +++ b/examples/common/stream_stdin.h @@ -14,10 +14,22 @@ // truncates the first PSDU ("short read on stdin (76/269)") before a // single frame is transmitted. // -// 2. read_exact() — the length-prefixed record reader, returning a tri-state -// so each caller keeps its own short-read policy (the TX demo aborts on a -// truncated record; the duplex demo just stops its TX thread and lets RX -// run on). +// 2. read_exact() — the byte reader, returning a tri-state so each caller +// keeps its own short-read policy (the TX demo aborts on a truncated +// record; the duplex demo just stops its TX thread and lets RX run on). +// +// 3. read_record() — one whole record, which is what +// every caller actually wanted. The little-endian assembly and the +// zero/oversize check were re-typed in four demos before this existed, +// and they had already drifted. Callers that need the length word before +// the body — the duplex demo escapes to a control TLV on its top bit — +// compose read_length() and read_body() instead. +// +// The result states are deliberately finer than "it failed": a producer that +// closed cleanly between records is an ordinary end of run, one that closed +// with a length word already written has lost a record, and a length out of +// range is a producer bug rather than a stream end. Each demo maps them to its +// own policy; none of them has to re-derive them. // // StreamStdinSelftest + tests/stream_stdin_test.cmake exercise this header // headlessly (no libusb, no hardware), so a regression in the _WIN32 gate @@ -27,6 +39,7 @@ #include #include #include +#include #if defined(_WIN32) #include @@ -71,4 +84,63 @@ inline ReadResult read_exact(std::FILE *f, void *buf, std::size_t n) { return ReadResult::Ok; } +// Read the 4-byte little-endian length prefix. `len` is untouched unless Ok. +inline ReadResult read_length(std::FILE *f, std::uint32_t &len) { + std::uint8_t b[4]; + const ReadResult r = read_exact(f, b, sizeof(b)); + if (r == ReadResult::Ok) + len = static_cast(b[0]) | + (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); + return r; +} + +// Read `n` body bytes into `out`, sizing it to match. A zero-length body is Ok +// and leaves `out` empty — callers that forbid one check the length first. +inline ReadResult read_body(std::FILE *f, std::vector &out, + std::size_t n) { + out.resize(n); + return n ? read_exact(f, out.data(), n) : ReadResult::Ok; +} + +enum class RecordResult { + Ok, // a complete record is in `out` + Eof, // clean close: nothing at all where a record would have started + Short, // the stream ended part-way through a record + EofMidBody, // the length word arrived, then the stream closed before a byte + // of its body — one record lost, cleanly + BadLength, // zero, or larger than the caller's bound +}; + +// Read one whole record. `max` bounds the body. When +// `raw_len` is given it receives the length word verbatim even on BadLength, +// so a caller with its own convention in the high bits can inspect it. +inline RecordResult read_record(std::FILE *f, std::vector &out, + std::size_t max, + std::uint32_t *raw_len = nullptr) { + std::uint32_t len = 0; + switch (read_length(f, len)) { + case ReadResult::Eof: + return RecordResult::Eof; + case ReadResult::Short: + return RecordResult::Short; + case ReadResult::Ok: + break; + } + if (raw_len) + *raw_len = len; + if (len == 0 || len > max) + return RecordResult::BadLength; + switch (read_body(f, out, len)) { + case ReadResult::Ok: + return RecordResult::Ok; + case ReadResult::Eof: + return RecordResult::EofMidBody; + case ReadResult::Short: + return RecordResult::Short; + } + return RecordResult::Short; // unreachable; keeps every toolchain quiet +} + } // namespace stream_stdin diff --git a/examples/common/stream_stdin_selftest.cpp b/examples/common/stream_stdin_selftest.cpp index 7b5b779..07224b2 100644 --- a/examples/common/stream_stdin_selftest.cpp +++ b/examples/common/stream_stdin_selftest.cpp @@ -5,8 +5,9 @@ // put into binary mode; the gate has to be `_WIN32` (not `_MSC_VER`) so it also // fires under mingw/GCC. A regression there is invisible to a build-only CI job // — it compiles fine and only corrupts bytes at runtime — so this binary -// exercises the exact set_stdin_binary() + read_exact() path the demos use, with -// no libusb and no radio. +// exercises the exact set_stdin_binary() + read_record() path the demos use, +// with no libusb and no radio. It also pins what each record state means, +// since the four demos map those states onto four different policies. // // StreamStdinSelftest --gen writes the canonical stream to stdout (binary) // StreamStdinSelftest reads that stream from stdin and round-trips it @@ -38,6 +39,10 @@ static const std::vector> &canonical_records() { return recs; } +// Bound for read_record. Comfortably above the canonical records, so a +// BadLength here means the stream desynced, never that the cap was too tight. +static const std::size_t kMaxRecord = 4096; + static void put_u32_le(uint8_t *p, uint32_t v) { p[0] = static_cast(v & 0xFF); p[1] = static_cast((v >> 8) & 0xFF); @@ -62,38 +67,34 @@ static int do_check() { const auto &expected = canonical_records(); size_t got_records = 0, got_bytes = 0; for (const auto &exp : expected) { - uint8_t len_bytes[4]; - auto r = stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)); - if (r != stream_stdin::ReadResult::Ok) { + // read_record is the composed path every demo now uses: the length + // assembly and the range check live in the header rather than four times + // over, so this exercises what the demos actually run. + std::vector body; + uint32_t len = 0; + const auto r = stream_stdin::read_record(stdin, body, kMaxRecord, &len); + if (r == stream_stdin::RecordResult::BadLength) { std::fprintf(stderr, - "stream_stdin_selftest: FAIL — len-prefix read for record %zu " - "returned %d (binary stdin likely broken: a 0x1A in a prior " - "record was read as EOF)\n", + "stream_stdin_selftest: FAIL — record %zu length %u out of " + "range (stream desynced; likely CRLF/Ctrl-Z translation)\n", + got_records, len); + return 3; + } + if (r != stream_stdin::RecordResult::Ok) { + std::fprintf(stderr, + "stream_stdin_selftest: FAIL — record %zu returned %d " + "(binary stdin likely broken: a 0x1A in a prior record was " + "read as EOF)\n", got_records, static_cast(r)); return 2; } - uint32_t len = static_cast(len_bytes[0]) - | (static_cast(len_bytes[1]) << 8) - | (static_cast(len_bytes[2]) << 16) - | (static_cast(len_bytes[3]) << 24); if (len != exp.size()) { std::fprintf(stderr, "stream_stdin_selftest: FAIL — record %zu length %u != " "expected %zu (stream desynced; likely CRLF/Ctrl-Z " "translation)\n", got_records, len, exp.size()); - return 3; - } - std::vector body(len); - if (len) { - r = stream_stdin::read_exact(stdin, body.data(), len); - if (r != stream_stdin::ReadResult::Ok) { - std::fprintf(stderr, - "stream_stdin_selftest: FAIL — body read for record %zu " - "returned %d\n", - got_records, static_cast(r)); - return 4; - } + return 4; } if (body != exp) { std::fprintf(stderr, @@ -120,7 +121,110 @@ static int do_check() { return 0; } +// Every demo maps read_record's states onto its own policy — streamtx warns on +// one and exits 2 on another, txdemo counts a lost shard, duplex stops its TX +// thread and leaves RX running. So the states have to mean exactly what they +// say. Driven through tmpfile() rather than the pipe, since these are the cases +// a well-formed stream never produces. +static int check_state(const char *what, const std::vector &bytes, + std::size_t max, stream_stdin::RecordResult want, + uint32_t want_len) { + std::FILE *f = std::tmpfile(); + if (!f) { + std::fprintf(stderr, "stream_stdin_selftest: tmpfile() failed\n"); + return 7; + } + if (!bytes.empty()) std::fwrite(bytes.data(), 1, bytes.size(), f); + std::rewind(f); + std::vector body; + uint32_t len = 0; + const auto got = stream_stdin::read_record(f, body, max, &len); + std::fclose(f); + if (got != want) { + std::fprintf(stderr, + "stream_stdin_selftest: FAIL — %s returned %d, expected %d\n", + what, static_cast(got), static_cast(want)); + return 7; + } + if (want_len && len != want_len) { + std::fprintf(stderr, + "stream_stdin_selftest: FAIL — %s reported length %u, " + "expected %u\n", what, len, want_len); + return 7; + } + return 0; +} + +static int do_states() { + using R = stream_stdin::RecordResult; + auto rec = [](uint32_t len, std::size_t body_bytes) { + std::vector v(4); + put_u32_le(v.data(), len); + v.insert(v.end(), body_bytes, 0x1A); // 0x1A: EOF to a text-mode read + return v; + }; + struct Case { + const char *what; + std::vector bytes; + std::size_t max; + R want; + uint32_t want_len; + }; + const std::vector cases = { + {"a complete record", rec(3, 3), 16, R::Ok, 3}, + {"nothing at all", {}, 16, R::Eof, 0}, + {"a truncated length prefix", {0x05, 0x00}, 16, R::Short, 0}, + {"a length with no body at all", rec(4, 0), 16, R::EofMidBody, 4}, + {"a body cut short", rec(8, 3), 16, R::Short, 8}, + {"a zero length", rec(0, 0), 16, R::BadLength, 0}, + {"a length past the bound", rec(99, 0), 16, R::BadLength, 99}, + }; + for (const auto &c : cases) { + const int rc = check_state(c.what, c.bytes, c.max, c.want, c.want_len); + if (rc) return rc; + } + + /* The duplex demo escapes to a control TLV on the length's top bit, and + * bounds that body at 256 rather than at its PSDU maximum — so it reads the + * length, masks it, and only then reads the body. That composition is the + * reason read_length and read_body are public at all, and nothing else + * covers it: a record whose length has the top bit set must arrive + * verbatim, and must not be mistaken for an oversize PSDU. */ + { + std::vector bytes(4); + const uint32_t escaped = 0x80000000u | 3u; + put_u32_le(bytes.data(), escaped); + const std::vector want = {0x01, 0x1A, 0x0D}; + bytes.insert(bytes.end(), want.begin(), want.end()); + std::FILE *f = std::tmpfile(); + if (!f) { + std::fprintf(stderr, "stream_stdin_selftest: tmpfile() failed\n"); + return 7; + } + std::fwrite(bytes.data(), 1, bytes.size(), f); + std::rewind(f); + uint32_t len = 0; + std::vector body; + const bool ok = + stream_stdin::read_length(f, len) == stream_stdin::ReadResult::Ok && + (len & 0x80000000u) != 0 && + stream_stdin::read_body(f, body, len & 0x7fffffffu) == + stream_stdin::ReadResult::Ok && + body == want; + std::fclose(f); + if (!ok) { + std::fprintf(stderr, "stream_stdin_selftest: FAIL — the control-opcode " + "escape (top length bit) did not round-trip\n"); + return 7; + } + } + std::fprintf(stdout, "stream_stdin_selftest: %zu record states OK\n", + cases.size()); + return 0; +} + int main(int argc, char **argv) { if (argc > 1 && std::strcmp(argv[1], "--gen") == 0) return do_gen(); + if (const int rc = do_states()) return rc; return do_check(); } diff --git a/examples/duplex/main.cpp b/examples/duplex/main.cpp index fd6a7ab..2b5da8b 100644 --- a/examples/duplex/main.cpp +++ b/examples/duplex/main.cpp @@ -196,25 +196,23 @@ static void tx_thread(TxArgs args) { long tx_count = 0; while (!args.should_stop->load()) { - uint8_t len_bytes[4]; - if (stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)) != - stream_stdin::ReadResult::Ok) { + // This demo reads the length itself rather than using read_record: its top + // bit escapes to a control TLV with its own, much smaller bound, so the + // length has to be inspected before the body may be read. + uint32_t len = 0; + if (stream_stdin::read_length(stdin, len) != stream_stdin::ReadResult::Ok) { // Clean EOF or short read — TX side done. RX keeps running. devourer::Ev(*g_ev, "stream.eof").f("tx_count", tx_count); break; } - uint32_t len = static_cast(len_bytes[0]) - | (static_cast(len_bytes[1]) << 8) - | (static_cast(len_bytes[2]) << 16) - | (static_cast(len_bytes[3]) << 24); // Control-opcode escape: top bit set -> the body is a control TLV (the // adaptive link's live knobs), not a PSDU. . if (len & 0x80000000u) { uint32_t clen = len & 0x7fffffffu; if (clen == 0 || clen > 256) break; - std::vector ctl(clen); - if (stream_stdin::read_exact(stdin, ctl.data(), clen) != + std::vector ctl; + if (stream_stdin::read_body(stdin, ctl, clen) != stream_stdin::ReadResult::Ok) break; uint8_t op = ctl[0]; @@ -241,8 +239,8 @@ static void tx_thread(TxArgs args) { args.max_psdu); break; } - std::vector psdu(len); - if (stream_stdin::read_exact(stdin, psdu.data(), len) != + std::vector psdu; + if (stream_stdin::read_body(stdin, psdu, len) != stream_stdin::ReadResult::Ok) { /* EOF mid-PSDU: `bytes` = the expected PSDU length that was cut short. */ devourer::Ev(*g_ev, "stream.eof").f("tx_count", tx_count).f("bytes", len); diff --git a/examples/streamtx/main.cpp b/examples/streamtx/main.cpp index 08f180b..06905d9 100644 --- a/examples/streamtx/main.cpp +++ b/examples/streamtx/main.cpp @@ -363,33 +363,22 @@ int main(int argc, char **argv) { std::chrono::high_resolution_clock::now().time_since_epoch().count()); std::vector sync_buf; while (true) { - uint8_t len_bytes[4]; + std::vector psdu; + uint32_t len = 0; { - auto r = stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)); - if (r == stream_stdin::ReadResult::Eof) break; // clean stdin close - if (r == stream_stdin::ReadResult::Short) { - logger->error("short read on stdin len-prefix; record truncated"); - std::exit(2); - } - } - uint32_t len = static_cast(len_bytes[0]) - | (static_cast(len_bytes[1]) << 8) - | (static_cast(len_bytes[2]) << 16) - | (static_cast(len_bytes[3]) << 24); - if (len == 0 || len > max_psdu) { - logger->error("PSDU length {} out of range (max {}); stopping", len, - max_psdu); - break; - } - std::vector psdu(len); - { - auto r = stream_stdin::read_exact(stdin, psdu.data(), len); - if (r == stream_stdin::ReadResult::Eof) { + const auto r = stream_stdin::read_record(stdin, psdu, max_psdu, &len); + if (r == stream_stdin::RecordResult::Eof) break; // clean stdin close + if (r == stream_stdin::RecordResult::EofMidBody) { logger->warn("EOF mid-PSDU (expected {} bytes)", len); break; } - if (r == stream_stdin::ReadResult::Short) { - logger->error("short read mid-PSDU (expected {} bytes); record " + if (r == stream_stdin::RecordResult::BadLength) { + logger->error("PSDU length {} out of range (max {}); stopping", len, + max_psdu); + break; + } + if (r != stream_stdin::RecordResult::Ok) { + logger->error("short read on stdin (expected {} bytes); record " "truncated", len); std::exit(2); } diff --git a/examples/svctx/main.cpp b/examples/svctx/main.cpp index 504d78f..d957aa4 100644 --- a/examples/svctx/main.cpp +++ b/examples/svctx/main.cpp @@ -206,14 +206,12 @@ int main(int argc, char** argv) { // Read all length-prefixed NALs up front. std::vector> nals; while (true) { - uint8_t lb[4]; - if (stream_stdin::read_exact(stdin, lb, 4) != stream_stdin::ReadResult::Ok) - break; - uint32_t len = lb[0] | (lb[1] << 8) | (lb[2] << 16) | ((uint32_t)lb[3] << 24); - if (len == 0 || len > 200000) break; - std::vector nal(len); - if (stream_stdin::read_exact(stdin, nal.data(), len) != - stream_stdin::ReadResult::Ok) + std::vector nal; + /* Any incomplete record ends the read-ahead: this loop drains a whole clip + * before transmitting anything, so a truncated tail is simply where the + * clip stops. */ + if (stream_stdin::read_record(stdin, nal, 200000) != + stream_stdin::RecordResult::Ok) break; nals.push_back(std::move(nal)); } diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index 5dab4c5..a24ba32 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -1999,40 +1999,32 @@ int main(int argc, char **argv) { if (hop_probe_slot) { tx_buf.assign(stdin_hdr.begin(), stdin_hdr.end()); } else { - uint8_t len_bytes[4]; - auto r = stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)); - if (r == stream_stdin::ReadResult::Eof) { + uint32_t len = 0; + const auto r = + stream_stdin::read_record(stdin, stdin_body, stdin_max, &len); + switch (r) { + case stream_stdin::RecordResult::Ok: + /* Progress, not just a final tally: a harness that scores one phase + * of a longer session needs the body count AT the phase boundary, + * and the process is still running then. */ + if (++stdin_bodies % 100 == 0) + devourer::Ev(*g_ev, "tx.stdin") + .f("bodies", (unsigned long long)stdin_bodies) + .f("final", 0); + break; + case stream_stdin::RecordResult::Eof: stdin_done = true; - } else if (r != stream_stdin::ReadResult::Ok) { - logger->error("DEVOURER_TX_STDIN — truncated length prefix after {} " - "bodies", stdin_bodies); + break; + case stream_stdin::RecordResult::BadLength: + logger->error("DEVOURER_TX_STDIN — body length {} out of range " + "(max {})", len, stdin_max); stdin_done = stdin_truncated = true; - } else { - const uint32_t len = static_cast(len_bytes[0]) | - (static_cast(len_bytes[1]) << 8) | - (static_cast(len_bytes[2]) << 16) | - (static_cast(len_bytes[3]) << 24); - if (!len || len > stdin_max) { - logger->error("DEVOURER_TX_STDIN — body length {} out of range " - "(max {})", len, stdin_max); - stdin_done = stdin_truncated = true; - } else { - stdin_body.resize(len); - if (stream_stdin::read_exact(stdin, stdin_body.data(), len) != - stream_stdin::ReadResult::Ok) { - logger->error("DEVOURER_TX_STDIN — truncated body ({} bytes) " - "after {} bodies", len, stdin_bodies); - stdin_done = stdin_truncated = true; - } else { - /* Progress, not just a final tally: a harness that scores one - * phase of a longer session needs the body count AT the phase - * boundary, and the process is still running then. */ - if (++stdin_bodies % 100 == 0) - devourer::Ev(*g_ev, "tx.stdin") - .f("bodies", (unsigned long long)stdin_bodies) - .f("final", 0); - } - } + break; + default: /* Short, EofMidBody — a shard the producer never finished */ + logger->error("DEVOURER_TX_STDIN — truncated record ({} bytes) after " + "{} bodies", len, stdin_bodies); + stdin_done = stdin_truncated = true; + break; } if (stdin_done) break; /* out of the TX loop, into the ordinary teardown */ diff --git a/tests/duplex_ctl_onair.sh b/tests/duplex_ctl_onair.sh new file mode 100755 index 0000000..0e0cd75 --- /dev/null +++ b/tests/duplex_ctl_onair.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# On-air verification of the duplex demo's stdin control-opcode escape. +# +# duplex reads a stream, except that a length with the top +# bit set escapes to a control TLV — — carrying the adaptive +# link's live knobs (SET_PWR, SET_RATE, SET_CHAN). That escape is the one place +# any stdin demo inspects the length before reading the body, and it had no +# coverage of its own: tests/adaptive_onair.sh exercises it, but adaptive_link.py +# consumes duplex's event stream on a pipe and drops everything it is not +# looking for, so nothing observes whether the TLV was read or acted on. +# +# Here the stream is scripted instead of controller-driven, duplex's events are +# captured, and a second adapter witnesses the air: +# +# PSDUs at the base rate -> SET_RATE MCS5 -> PSDUs -> SET_PWR -> PSDUs +# +# Passing means all three of: duplex logged a stream.ctl for each TLV (it read +# them), the witness decoded a rate change at the right point (it ACTED on +# one), and the PSDU count came out whole (the record stream stayed in sync +# across the escapes — an off-by-one there would desync every later frame). +# +# Usage: sudo -v && tests/duplex_ctl_onair.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +TX_VID="${TX_VID:-0x0bda}"; TX_PID="${TX_PID:-0x8812}" # RTL8812AU +RX_VID="${RX_VID:-0x0bda}"; RX_PID="${RX_PID:-0xc812}" # RTL8812CU witness +CH="${CH:-6}" +# The TLV switches DOWN to a more robust rate, never up. Switching up looks +# identical to not switching at all: the frames fly at the new rate and the +# witness simply cannot decode them, so the histogram shows one rate and the +# frame count quietly drops. Measured here at ~13 dB SNR, an MCS1 -> MCS5 +# switch read as "SET_RATE never took effect" while it was working perfectly. +BASE_RATE="${BASE_RATE:-MCS1}" # before the SET_RATE (HT, DESC_RATE 13) +CTL_RATE="${CTL_RATE:-6M}" # after it (legacy, DESC_RATE 4) +NPSDU="${NPSDU:-500}" # per segment; three segments +# duplex paces at --interval-ms (default 2), so an unpaced run drains in a +# couple of seconds and a witness barely overlaps it. Stretch the run instead: +# the point is to see the rate change BETWEEN segments, which needs the +# segments to be far enough apart in time to be told apart at all. +INTERVAL_MS="${INTERVAL_MS:-10}" +PSDU_BYTES="${PSDU_BYTES:-200}" +OUT="${OUT:-/tmp/devourer-duplex-ctl}" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$TX_VID" "$TX_PID" || { echo "SKIP: TX $TX_VID:$TX_PID not plugged"; exit 77; } +plugged "$RX_VID" "$RX_PID" || { echo "SKIP: RX $RX_VID:$RX_PID not plugged"; exit 77; } + +cleanup() { + sudo pkill -INT -x duplex 2>/dev/null || true + sudo pkill -INT -x rxdemo 2>/dev/null || true + sleep 1 + sudo pkill -x duplex 2>/dev/null || true + sudo pkill -x rxdemo 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# In-tree rtw88 auto-probes these dongles; temp-unbind whatever holds them. +unbind() { + local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do + p=$(cat "$d" 2>/dev/null) || continue + [ "$p" = "${pid#0x}" ] || continue + for i in "$(dirname "$d")":*; do + [ -e "$i/driver" ] && sudo sh -c "echo '$(basename "$i")' > '$i/driver/unbind'" 2>/dev/null || true + done + done +} + +echo "== build ==" +cmake --build "$ROOT/build" -j --target duplex rxdemo >/dev/null || exit 1 +unbind "$TX_PID"; unbind "$RX_PID" +mkdir -p "$OUT"; rm -f "$OUT"/*.log "$OUT"/*.bin + +# The scripted stream. Written by a generator rather than inline so the framing +# is in one readable place: data records carry a plain length, control records +# carry the same length word with the top bit set. +python3 - "$OUT/stream.bin" "$NPSDU" "$PSDU_BYTES" "$CTL_RATE" <<'PY' +import struct, sys +path, npsdu, nbytes, rate = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4] +def data(i): + body = bytes(((i + j) & 0xFF) for j in range(nbytes)) + return struct.pack(""$OUT/rx.log" 2>&1 & +sleep 12 + +echo "== duplex TX ($TX_VID:$TX_PID) on ch$CH, base rate $BASE_RATE ==" +# duplex's own events ride stdout; keep them, unlike the controller-driven path. +sudo env DEVOURER_VID="$TX_VID" DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE="$BASE_RATE" DEVOURER_LOG_LEVEL=info \ + "$ROOT/build/duplex" --interval-ms "$INTERVAL_MS" \ + <"$OUT/stream.bin" >"$OUT/tx.log" 2>&1 & +tx_pid=$! +wait "$tx_pid" 2>/dev/null || true +sleep 3 +cleanup + +echo "== verdicts (logs: $OUT) ==" +fails=0 +n_ctl=$(grep -c '"ev":"stream.ctl"' "$OUT/tx.log" 2>/dev/null) +echo " control TLVs read: ${n_ctl:-0}" +grep -o '"ev":"stream.ctl"[^}]*}' "$OUT/tx.log" 2>/dev/null | sed 's/^/ /' +if [ "${n_ctl:-0}" -eq 2 ]; then + echo "PASS: duplex read both control TLVs" +else + echo "FAIL: expected 2 stream.ctl events, saw ${n_ctl:-0}"; fails=$((fails+1)) +fi + +# The escape must not consume a data record, nor leave one behind: duplex airs +# exactly the PSDUs it was given. +sent=$(grep -o '"ev":"stream.eof"[^}]*"tx_count":[0-9]*' "$OUT/tx.log" 2>/dev/null | + tail -1 | sed -n 's/.*"tx_count":\([0-9]*\).*/\1/p') +want=$((NPSDU * 3)) +echo " PSDUs aired: ${sent:-0} of $want" +if [ "${sent:-0}" -eq "$want" ]; then + echo "PASS: every PSDU aired — the record stream stayed in sync across the escapes" +else + echo "FAIL: aired ${sent:-0} PSDUs, expected $want (a desync would lose the tail)" + fails=$((fails+1)) +fi + +# ...and the witness has to see the rate actually change, or the TLV was read +# and dropped on the floor. Both rates must be decodable at the bench's link +# margin for this to mean anything — see CTL_RATE above. +rates=$(grep -F '"ev":"rx.frame"' "$OUT/rx.log" 2>/dev/null | + sed -n 's/.*"rate":\([0-9]*\).*/\1/p' | sort -n | uniq -c | + awk '{printf "%s(x%s) ", $2, $1}') +echo " witnessed DESC_RATE values: ${rates:-none}" +n_rates=$(grep -F '"ev":"rx.frame"' "$OUT/rx.log" 2>/dev/null | + sed -n 's/.*"rate":\([0-9]*\).*/\1/p' | sort -nu | wc -l) +if [ "${n_rates:-0}" -ge 2 ]; then + echo "PASS: the witness decoded a rate change — SET_RATE was applied on air" +else + echo "FAIL: only ${n_rates:-0} distinct rate(s) on air; SET_RATE never took effect" + fails=$((fails+1)) +fi + +[ "$fails" -eq 0 ] && echo "== ALL PASS ==" || echo "== $fails FAILURES ==" +exit "$fails" diff --git a/tests/svctx_uep_witness.sh b/tests/svctx_uep_witness.sh new file mode 100755 index 0000000..1e89ae5 --- /dev/null +++ b/tests/svctx_uep_witness.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# On-air verification of svctx's per-layer rate ladder, witnessed by a devourer +# receiver instead of a kernel monitor. +# +# tests/svc_uep_onair.sh does the same job with an 8814AU in kernel monitor +# mode. This variant uses rxdemo as the witness — rx.frame already carries the +# decoded DESC_RATE, which is the only quantity the histogram needs — so the +# test runs on any two adapters and needs no kernel driver at all. +# +# Synthetic HEVC NALs (tests/gen_svc_nals.py) carry controlled temporal_ids; +# svctx maps each to its ladder rung (default CRIT=MCS0, T0=MCS1, T1=MCS4, +# T2=MCS7) and injects it. Passing means the witness decoded MORE THAN ONE +# distinct rate — i.e. the ladder reached the air rather than every layer +# flying at whatever the radio defaulted to. +# +# It doubles as the scope test for the shared radiotap path: svctx sets its +# rate per frame through build_stream_radiotap, the same call the duplex demo's +# SET_RATE uses. If the ladder flies here, that path works and a demo whose +# rate does not change has a fault of its own. +# +# Usage: sudo -v && tests/svctx_uep_witness.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +TX_VID="${TX_VID:-0x0bda}"; TX_PID="${TX_PID:-0x8812}" # RTL8812AU injector +RX_VID="${RX_VID:-0x0bda}"; RX_PID="${RX_PID:-0xc812}" # RTL8812CU witness +CH="${CH:-6}" +GOPS="${GOPS:-40}" +GAP_US="${GAP_US:-4000}" # svctx inter-fragment gap; spread the run out +SECS="${SECS:-30}" +OUT="${OUT:-/tmp/devourer-svctx-uep}" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$TX_VID" "$TX_PID" || { echo "SKIP: TX $TX_VID:$TX_PID not plugged"; exit 77; } +plugged "$RX_VID" "$RX_PID" || { echo "SKIP: RX $RX_VID:$RX_PID not plugged"; exit 77; } + +cleanup() { + sudo pkill -INT -x svctx 2>/dev/null || true + sudo pkill -INT -x rxdemo 2>/dev/null || true + sleep 1 + sudo pkill -x svctx 2>/dev/null || true + sudo pkill -x rxdemo 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +unbind() { + local pid="$1" d p i + for d in /sys/bus/usb/devices/*/idProduct; do + p=$(cat "$d" 2>/dev/null) || continue + [ "$p" = "${pid#0x}" ] || continue + for i in "$(dirname "$d")":*; do + [ -e "$i/driver" ] && sudo sh -c "echo '$(basename "$i")' > '$i/driver/unbind'" 2>/dev/null || true + done + done +} + +echo "== build ==" +cmake --build "$ROOT/build" -j --target svctx rxdemo >/dev/null || exit 1 +unbind "$TX_PID"; unbind "$RX_PID" +mkdir -p "$OUT"; rm -f "$OUT"/*.log "$OUT"/*.bin + +python3 "$ROOT/tests/gen_svc_nals.py" "$GOPS" >"$OUT/nals.bin" 2>"$OUT/gen.log" +[ -s "$OUT/nals.bin" ] || { echo "FAIL: NAL generation produced nothing"; cat "$OUT/gen.log"; exit 1; } +echo " $(wc -c <"$OUT/nals.bin") bytes of synthetic NALs" + +echo "== witness (rxdemo $RX_VID:$RX_PID) on ch$CH ==" +sudo env DEVOURER_VID="$RX_VID" DEVOURER_PID="$RX_PID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_STREAM_OUT=1 DEVOURER_LOG_LEVEL=info "$ROOT/build/rxdemo" \ + >"$OUT/rx.log" 2>&1 & +sleep 12 + +echo "== svctx ($TX_VID:$TX_PID) on ch$CH, default UEP ladder ==" +sudo env DEVOURER_VID="$TX_VID" DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_LOG_LEVEL=info \ + "$ROOT/build/svctx" --gap-us "$GAP_US" <"$OUT/nals.bin" \ + >"$OUT/tx.log" 2>&1 & +sleep "$SECS" +cleanup + +echo "== verdicts (logs: $OUT) ==" +fails=0 +grep -E "SVC UEP policy|-> MCS|NALs," "$OUT/tx.log" 2>/dev/null | head -6 | sed 's/^/ /' + +frames=$(grep -c '"ev":"rx.frame"' "$OUT/rx.log" 2>/dev/null) +echo " witnessed frames: ${frames:-0}" +if [ "${frames:-0}" -lt 50 ]; then + echo "FAIL: only ${frames:-0} frames witnessed — nothing to judge the ladder on" + fails=$((fails+1)) +fi + +rates=$(grep -F '"ev":"rx.frame"' "$OUT/rx.log" 2>/dev/null | + sed -n 's/.*"rate":\([0-9]*\).*/\1/p' | sort -n | uniq -c | + awk '{printf "%s(x%s) ", $2, $1}') +echo " DESC_RATE histogram: ${rates:-none}" +echo " (DESC_RATE 12+N = HT MCS N; the default ladder spans MCS0/1/4/7)" +n_rates=$(grep -F '"ev":"rx.frame"' "$OUT/rx.log" 2>/dev/null | + sed -n 's/.*"rate":\([0-9]*\).*/\1/p' | sort -nu | wc -l) +if [ "${n_rates:-0}" -ge 2 ]; then + echo "PASS: $n_rates distinct rates on air — the per-frame ladder reached the radio" +else + echo "FAIL: ${n_rates:-0} distinct rate(s) — every layer flew at one rate, so the" + echo " per-frame radiotap rate is not reaching the air on this transmitter" + fails=$((fails+1)) +fi + +[ "$fails" -eq 0 ] && echo "== ALL PASS ==" || echo "== $fails FAILURES ==" +exit "$fails"