diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index e2fe95d..b8a8eed 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -101,3 +101,51 @@ jobs: # Execute tests defined by the CMake configuration. Note that --build-config is needed because the default Windows generator is a multi-config generator (Visual Studio generator). # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail run: ctest --build-config ${{ matrix.build_type }} + + # mingw-w64 (GCC on Windows). NOT covered by the matrix above (that Windows + # cell is MSVC + vcpkg). This is the toolchain the stream-link TX demos' + # binary-stdin fix targets: mingw defines _WIN32 but not _MSC_VER, and it + # gets libusb from pkg-config with VCPKG_ROOT unset. Build-only would miss a + # text-mode stdin regression, so this also runs ctest (the StreamStdinSelftest + # round-trip catches it). + build-mingw: + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + + - name: Set up MSYS2 / mingw-w64 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + mingw-w64-x86_64-gcc + mingw-w64-x86_64-cmake + mingw-w64-x86_64-ninja + mingw-w64-x86_64-libusb + mingw-w64-x86_64-pkgconf + + - name: Configure CMake (mingw, pkg-config libusb, no vcpkg) + # VCPKG_ROOT is unset, so CMakeLists takes the pkg-config path — the + # exact configuration the mingw binary-stdin fix exists for. + run: > + cmake -B build -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_C_COMPILER=gcc + -DCMAKE_CXX_COMPILER=g++ + + - name: Build (library + stream demos + self-test) + # WiFiDriverDemo / WiFiDriverTxDemo / PrecoderDemo use POSIX-only APIs + # (e.g. fork() in txdemo/main.cpp for the DEVOURER_TX_WITH_RX path) and + # are not mingw targets. This job guards the stdin-driven stream demos + # and their headless self-test — what the binary-stdin fix touches. + run: > + cmake --build build --target + WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest + + - name: Test + working-directory: build + run: ctest --output-on-failure diff --git a/CLAUDE.md b/CLAUDE.md index 663e186..d0d64b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,10 +32,14 @@ cmake --build build -j libusb-1.0 is required: `pkg-config` on Linux/macOS, vcpkg on Windows (`VCPKG_ROOT` must be set so the toolchain file resolves). CI matrix builds -across GCC/Clang/MSVC on Ubuntu/macOS/Windows -(`.github/workflows/cmake-multi-platform.yml`). `ctest` runs in CI but no CMake -tests are registered — regression testing happens out-of-band via -`tests/regress.py`. +across GCC/Clang/MSVC on Ubuntu/macOS/Windows, plus a separate `build-mingw` +job (mingw-w64 via MSYS2, libusb from pkg-config) covering the Windows-GCC +toolchain the MSVC matrix cell doesn't +(`.github/workflows/cmake-multi-platform.yml`). `ctest` runs in every CI job; +the one registered test (`stream_stdin_binary`) round-trips the stream demos' +binary-stdin framing (`txdemo/stream_stdin.h`) headlessly, so a Windows +text-mode regression fails CI instead of only surfacing on a radio. Hardware +regression testing happens out-of-band via `tests/regress.py`. ## Regression testing diff --git a/CMakeLists.txt b/CMakeLists.txt index cadf7ad..5eb9f39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,8 @@ endif() project(Rtl8812auNet) +enable_testing() + # Find pkg-config and then use it to locate libusb. find_package(PkgConfig REQUIRED) pkg_check_modules(libusb REQUIRED IMPORTED_TARGET libusb-1.0) @@ -116,6 +118,8 @@ add_executable(StreamTxDemo txdemo/stream_tx_demo/main.cpp ) target_link_libraries(StreamTxDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) +# stream_stdin.h (shared binary-stdin framing) lives in txdemo/. +target_include_directories(StreamTxDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) # Stream-link DUPLEX: one binary, one chip, both directions. Combines the RX # loop from WiFiDriverDemo and the stdin-driven TX from StreamTxDemo — @@ -125,3 +129,22 @@ add_executable(StreamDuplexDemo txdemo/stream_duplex_demo/main.cpp ) target_link_libraries(StreamDuplexDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) +target_include_directories(StreamDuplexDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) + +# Headless regression guard for the binary-stdin framing shared by the two +# stream demos above (txdemo/stream_stdin.h). No libusb, no radio — just the +# set_stdin_binary() + read_exact() path, so a text-mode regression (e.g. the +# _setmode gate slipping back to _MSC_VER, which is invisible to a build-only +# check) fails `ctest` on the Windows/mingw jobs. See tests/stream_stdin_test.cmake. +add_executable(StreamStdinSelftest + txdemo/stream_stdin_selftest.cpp +) +target_compile_features(StreamStdinSelftest PRIVATE cxx_std_20) +target_include_directories(StreamStdinSelftest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) + +add_test( + NAME stream_stdin_binary + COMMAND ${CMAKE_COMMAND} + -DSELFTEST_EXE=$ + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/stream_stdin_test.cmake +) diff --git a/tests/stream_stdin_test.cmake b/tests/stream_stdin_test.cmake new file mode 100644 index 0000000..5931199 --- /dev/null +++ b/tests/stream_stdin_test.cmake @@ -0,0 +1,44 @@ +# ctest driver for StreamStdinSelftest — see txdemo/stream_stdin_selftest.cpp. +# +# Pipes the self-test's --gen output (the canonical stream, +# which embeds 0x1A / 0x0D / 0x0A) straight into a second invocation that reads +# it back through the binary-stdin path and round-trips it. The anonymous pipe +# CMake sets up is byte-exact; the only thing that can corrupt the stream is a +# process putting its own stdio into text mode. So: +# +# * correct (_WIN32-gated) binary mode -> reader prints "...OK", exit 0, PASS +# * regressed (_MSC_VER gate on mingw) -> 0x1A read as EOF / CRLF mangled +# -> reader exits non-zero, FAIL +# +# Off Windows there is no text mode, so the test always passes there; its bite +# is on the Windows (MSVC) and mingw CI jobs. Invoked with -DSELFTEST_EXE=. + +if(NOT SELFTEST_EXE) + message(FATAL_ERROR "SELFTEST_EXE not set") +endif() + +execute_process( + COMMAND "${SELFTEST_EXE}" --gen + COMMAND "${SELFTEST_EXE}" + OUTPUT_VARIABLE check_out + ERROR_VARIABLE check_err + RESULTS_VARIABLE all_rc) + +# RESULTS_VARIABLE is a ;-list of each pipeline stage's exit code. +foreach(rc IN LISTS all_rc) + if(NOT rc EQUAL 0) + message(FATAL_ERROR + "StreamStdinSelftest pipeline stage exited ${rc} — binary stdin/stdout is " + "broken (a 0x1A or CRLF byte corrupted the length-prefixed stream). Check " + "set_stdin_binary()/set_stdout_binary() in txdemo/stream_stdin.h.\n" + "stdout:\n${check_out}\nstderr:\n${check_err}") + endif() +endforeach() + +if(NOT check_out MATCHES "records=5 bytes=20 OK") + message(FATAL_ERROR + "StreamStdinSelftest produced unexpected output:\n${check_out}\n" + "stderr:\n${check_err}") +endif() + +message(STATUS "stream_stdin round-trip OK: ${check_out}") diff --git a/txdemo/stream_duplex_demo/main.cpp b/txdemo/stream_duplex_demo/main.cpp index d7ff0e2..c29b5cb 100644 --- a/txdemo/stream_duplex_demo/main.cpp +++ b/txdemo/stream_duplex_demo/main.cpp @@ -63,6 +63,7 @@ #include "RtlUsbAdapter.h" #include "WiFiDriver.h" #include "logger.h" +#include "stream_stdin.h" #define USB_VENDOR_ID 0x0bda @@ -93,22 +94,6 @@ static std::vector build_dot11_probe_req() { return h; } -static bool read_exact(FILE *f, void *buf, size_t n) { - size_t got = 0; - auto *p = static_cast(buf); - while (got < n) { - size_t r = std::fread(p + got, 1, n - got, f); - if (r == 0) { - if (got == 0 && std::feof(f)) return false; - std::fprintf(stderr, - "stream_duplex_demo: short stdin read (%zu/%zu)\n", got, n); - return false; - } - got += r; - } - return true; -} - // RX callback — emits `` on canonical-SA matches. Wrapped in // a mutex against the TX thread's printf calls so the two log streams don't // interleave mid-line. (The TX thread only writes to stderr, RX to stdout, so @@ -181,7 +166,8 @@ static void tx_thread(TxArgs args) { while (!args.should_stop->load()) { uint8_t len_bytes[4]; - if (!read_exact(stdin, len_bytes, sizeof(len_bytes))) { + if (stream_stdin::read_exact(stdin, len_bytes, sizeof(len_bytes)) != + stream_stdin::ReadResult::Ok) { // Clean EOF or short read — TX side done. RX keeps running. std::fprintf(stderr, "tx EOF after %ld PSDUs\n", tx_count); break; @@ -197,7 +183,8 @@ static void tx_thread(TxArgs args) { break; } std::vector psdu(len); - if (!read_exact(stdin, psdu.data(), len)) { + if (stream_stdin::read_exact(stdin, psdu.data(), len) != + stream_stdin::ReadResult::Ok) { std::fprintf(stderr, "tx EOF mid-PSDU (%u bytes)\n", len); break; } @@ -239,11 +226,10 @@ int main(int argc, char **argv) { } } -#if defined(_WIN32) - // mingw/GCC defines _WIN32 but not _MSC_VER, yet still has _setmode — make - // stdin binary so a 0x1A/CRLF doesn't corrupt the length-prefixed PSDU stream. - _setmode(_fileno(stdin), _O_BINARY); -#endif + // Make stdin binary so a 0x1A/CRLF doesn't corrupt the length-prefixed PSDU + // stream. Gated on _WIN32 (not _MSC_VER) in the shared helper — see + // txdemo/stream_stdin.h. + stream_stdin::set_stdin_binary(); libusb_context *context = nullptr; libusb_device_handle *handle = nullptr; diff --git a/txdemo/stream_stdin.h b/txdemo/stream_stdin.h new file mode 100644 index 0000000..b14b2b4 --- /dev/null +++ b/txdemo/stream_stdin.h @@ -0,0 +1,74 @@ +// Shared stdin framing for the stdin-driven stream demos (StreamTxDemo, +// StreamDuplexDemo) and their headless regression self-test +// (StreamStdinSelftest). +// +// Centralises the two things that have to stay correct on every Windows +// toolchain, so there is a single source of truth instead of one copy per +// demo: +// +// 1. set_stdin_binary() — put stdin in binary mode so a 0x1A (Ctrl-Z, which +// text-mode stdin treats as EOF) or a CRLF byte in the binary +// stream isn't translated away. Gated on _WIN32, NOT +// _MSC_VER: mingw/GCC defines _WIN32 but not _MSC_VER, yet still ships +// _setmode. A _MSC_VER gate silently leaves mingw stdin in TEXT mode and +// 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). +// +// StreamStdinSelftest + tests/stream_stdin_test.cmake exercise this header +// headlessly (no libusb, no hardware), so a regression in the _WIN32 gate +// fails CI on the mingw job instead of only surfacing on a real radio. +#pragma once + +#include +#include +#include + +#if defined(_WIN32) + #include + #include +#endif + +namespace stream_stdin { + +// Put stdin into binary mode. No-op off Windows (POSIX has no text mode). +inline void set_stdin_binary() { +#if defined(_WIN32) + _setmode(_fileno(stdin), _O_BINARY); +#endif +} + +// Put stdout into binary mode. Only the self-test's --gen path needs this, but +// it lives here so all the toolchain-gated _setmode logic stays in one place. +inline void set_stdout_binary() { +#if defined(_WIN32) + _setmode(_fileno(stdout), _O_BINARY); +#endif +} + +enum class ReadResult { + Ok, // got all n bytes + Eof, // clean stream close: 0 bytes read with EOF before any byte + Short, // stream ended mid-record (truncation) +}; + +// Read exactly n bytes from f into buf. +inline ReadResult read_exact(std::FILE *f, void *buf, std::size_t n) { + std::size_t got = 0; + auto *p = static_cast(buf); + while (got < n) { + std::size_t r = std::fread(p + got, 1, n - got, f); + if (r == 0) { + if (got == 0 && std::feof(f)) return ReadResult::Eof; + return ReadResult::Short; + } + got += r; + } + return ReadResult::Ok; +} + +} // namespace stream_stdin diff --git a/txdemo/stream_stdin_selftest.cpp b/txdemo/stream_stdin_selftest.cpp new file mode 100644 index 0000000..7970f2d --- /dev/null +++ b/txdemo/stream_stdin_selftest.cpp @@ -0,0 +1,126 @@ +// StreamStdinSelftest — headless regression test for txdemo/stream_stdin.h. +// +// The stdin-driven stream demos (StreamTxDemo, StreamDuplexDemo) read a binary +// stream from stdin. On Windows that only works if stdin is +// 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. +// +// StreamStdinSelftest --gen writes the canonical stream to stdout (binary) +// StreamStdinSelftest reads that stream from stdin and round-trips it +// +// tests/stream_stdin_test.cmake pipes the first into the second. The canonical +// records deliberately contain 0x1A (Ctrl-Z = EOF to a text-mode read), 0x0D +// and 0x0A, so any text-mode translation truncates or mangles the stream and the +// reader reports FAIL with a non-zero exit. + +#include +#include +#include +#include + +#include "stream_stdin.h" + +// Canonical self-test stream. --gen and the reader share this table, so a +// byte-for-byte mismatch on read means binary mode is broken. Total = 5 records, +// 20 body bytes (5+3+4+1+7) — kept in lockstep with the expected string in +// tests/stream_stdin_test.cmake. +static const std::vector> &canonical_records() { + static const std::vector> recs = { + {0x1A, 0x0D, 0x0A, 0x00, 0xFF}, + {0x41, 0x1A, 0x42}, + {0x0D, 0x0A, 0x0D, 0x0A}, + {0x1A}, + {0x00, 0x1A, 0x0D, 0x0A, 0x1A, 0x7F, 0x80}, + }; + return recs; +} + +static void put_u32_le(uint8_t *p, uint32_t v) { + p[0] = static_cast(v & 0xFF); + p[1] = static_cast((v >> 8) & 0xFF); + p[2] = static_cast((v >> 16) & 0xFF); + p[3] = static_cast((v >> 24) & 0xFF); +} + +static int do_gen() { + stream_stdin::set_stdout_binary(); + for (const auto &rec : canonical_records()) { + uint8_t len_bytes[4]; + put_u32_le(len_bytes, static_cast(rec.size())); + std::fwrite(len_bytes, 1, sizeof(len_bytes), stdout); + if (!rec.empty()) std::fwrite(rec.data(), 1, rec.size(), stdout); + } + std::fflush(stdout); + return 0; +} + +static int do_check() { + stream_stdin::set_stdin_binary(); + 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) { + 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", + 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; + } + } + if (body != exp) { + std::fprintf(stderr, + "stream_stdin_selftest: FAIL — record %zu body differs from " + "source (text-mode translation corrupted the stream)\n", + got_records); + return 5; + } + ++got_records; + got_bytes += len; + } + // Confirm clean EOF right after the last record — no trailing corruption. + uint8_t extra; + if (stream_stdin::read_exact(stdin, &extra, 1) != stream_stdin::ReadResult::Eof) { + std::fprintf(stderr, + "stream_stdin_selftest: FAIL — expected EOF after %zu records " + "but more bytes followed\n", + got_records); + return 6; + } + std::fprintf(stdout, "stream_stdin_selftest: records=%zu bytes=%zu OK\n", + got_records, got_bytes); + std::fflush(stdout); + return 0; +} + +int main(int argc, char **argv) { + if (argc > 1 && std::strcmp(argv[1], "--gen") == 0) return do_gen(); + return do_check(); +} diff --git a/txdemo/stream_tx_demo/main.cpp b/txdemo/stream_tx_demo/main.cpp index c67d485..70e7c11 100644 --- a/txdemo/stream_tx_demo/main.cpp +++ b/txdemo/stream_tx_demo/main.cpp @@ -68,6 +68,7 @@ #include "RtlUsbAdapter.h" #include "WiFiDriver.h" #include "logger.h" +#include "stream_stdin.h" #define USB_VENDOR_ID 0x0bda @@ -97,27 +98,6 @@ static std::vector build_dot11_probe_req() { return h; } -// Read exactly `n` bytes from FILE *f into buf. Returns: -// true -> got all n bytes -// false -> clean EOF before any bytes (orderly stdin close) -// Terminates on short read mid-record (a stream framing bug we'd rather see). -static bool read_exact(FILE *f, void *buf, size_t n) { - size_t got = 0; - auto *p = static_cast(buf); - while (got < n) { - size_t r = std::fread(p + got, 1, n - got, f); - if (r == 0) { - if (got == 0 && std::feof(f)) return false; - std::fprintf(stderr, - "stream_tx_demo: short read on stdin (%zu/%zu); record " - "truncated mid-PSDU\n", got, n); - std::exit(2); - } - got += r; - } - return true; -} - int main(int argc, char **argv) { auto logger = std::make_shared(); @@ -140,11 +120,9 @@ int main(int argc, char **argv) { } } -#if defined(_WIN32) - // Make stdin binary so a 0x1A or CRLF doesn't corrupt PSDU bytes. - // (mingw/GCC defines _WIN32 but not _MSC_VER, yet still has _setmode.) - _setmode(_fileno(stdin), _O_BINARY); -#endif + // Make stdin binary so a 0x1A or CRLF doesn't corrupt PSDU bytes. Gated on + // _WIN32 (not _MSC_VER) inside the shared helper — see txdemo/stream_stdin.h. + stream_stdin::set_stdin_binary(); libusb_context *context = nullptr; libusb_device_handle *handle = nullptr; @@ -234,7 +212,16 @@ int main(int argc, char **argv) { long tx_count = 0; while (true) { uint8_t len_bytes[4]; - if (!read_exact(stdin, len_bytes, sizeof(len_bytes))) break; // clean EOF + { + 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) { + std::fprintf(stderr, + "stream_tx_demo: short read on stdin len-prefix; record " + "truncated\n"); + std::exit(2); + } + } uint32_t len = static_cast(len_bytes[0]) | (static_cast(len_bytes[1]) << 8) | (static_cast(len_bytes[2]) << 16) @@ -246,10 +233,19 @@ int main(int argc, char **argv) { break; } std::vector psdu(len); - if (!read_exact(stdin, psdu.data(), len)) { - std::fprintf(stderr, - "stream_tx_demo: EOF mid-PSDU (expected %u bytes)\n", len); - break; + { + auto r = stream_stdin::read_exact(stdin, psdu.data(), len); + if (r == stream_stdin::ReadResult::Eof) { + std::fprintf(stderr, + "stream_tx_demo: EOF mid-PSDU (expected %u bytes)\n", len); + break; + } + if (r == stream_stdin::ReadResult::Short) { + std::fprintf(stderr, + "stream_tx_demo: short read mid-PSDU (expected %u bytes); " + "record truncated\n", len); + std::exit(2); + } } tx_buf.clear();