From 3b64c9337f43c7ccbee0dc0bc9c409f07a47bc9a Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:34:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20v2.13.0=20=E2=80=94=20Native=20Tran?= =?UTF-8?q?sport=20Rebuild:=20UDP/IP=20(Annex=20J)=20(=C2=A7Phase=2015=20m?= =?UTF-8?q?ilestone=2057)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds rcp/udp.hpp to carry real IEEE 1722 AVTPDU frames over UDP/IP, per ROADMAP.md milestone 57 and the Satellite Package Disposition table's entry for this file. The bespoke R/C-magic 16-byte Zone/Command/Response/Status frame this file used to carry (rcp/legacy_wire.hpp) is discarded outright, not adapted, matching the roadmap's own call that this transport "is the old wire format this whole roadmap replaces." udp::Frame/encode_frame/decode_frame compose rcp/avtp.hpp's NTSCF/TSCF header codec with rcp/acf.hpp's ACF_ABB/ACF_GBB message codec into one AVTPDU, carried as the UDP datagram payload unmodified — this implementation's own reading of IEEE 1722 Annex J's UDP/IP encapsulation, consistent with rcp/avtp.hpp's own note that its framing is transport-agnostic by design. udp::Server binds a UDP socket, decodes each inbound datagram, dispatches the carried ACF request to a caller-supplied Handler shaped to match rcp::mock::Server::dispatch's signature (v2.12.0), and answers the sender under the same NTSCF/TSCF header kind the request arrived under, assigning stable per-sender client ids. udp::Client connects to one server address and correlates each response by the (byte_bus_id, transaction_num) echo rule rcp/acf.hpp's make_response documents, rather than a locally invented request id. Neither class builds on rcp.hpp's Zone/Command/Controller/Registry model, per that file's own header comment that nothing new should. Grepping the tree for consumers of the old udp::ZoneServer/ udp::Controller/udp::Registry API found none beyond this file's own (now-deleted) test and rcp/tsn.hpp's doc comment — rcp/tsn.hpp itself only ever depended on the generic rcp::Controller interface plus a raw socket fd, never on udp:: types directly — so no legacy-shim split file was needed here, unlike rcp/mock.hpp's at v2.12.0; rcp/legacy_wire.hpp is deleted outright in the same change. New coverage lives in tests/test_udp.cpp (REQ-UDP-001..012, entirely rewritten under the same file/prefix identity as the discarded pre-replacement coverage, the same convention tests/test_sim.cpp's REQ-SIM-001..007 rewrite followed at v2.12.0), traced in .fusa-reqs.json. Full bit-for-bit conformance against other TC18/ Annex-J implementations is not claimed, same as the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and rcp/discovery.hpp. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 48 ++-- CMakeLists.txt | 2 +- README.md | 3 +- ROADMAP.md | 39 +++ include/rcp/avtp.hpp | 14 +- include/rcp/legacy_wire.hpp | 232 --------------- include/rcp/tsn.hpp | 8 +- include/rcp/udp.hpp | 549 +++++++++++++++++++----------------- include/rcp/version.hpp | 2 +- tests/CMakeLists.txt | 6 +- tests/test_legacy_wire.cpp | 158 ----------- tests/test_udp.cpp | 326 +++++++++++++++++++++ 12 files changed, 698 insertions(+), 689 deletions(-) delete mode 100644 include/rcp/legacy_wire.hpp delete mode 100644 tests/test_legacy_wire.cpp create mode 100644 tests/test_udp.cpp diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 4b21ee8..727034c 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -754,96 +754,96 @@ }, { "id": "REQ-UDP-001", - "title": "Header magic bytes 'RC'", - "text": "All wire frames shall begin with the two-byte sequence 0x52 ('R'), 0x43 ('C').", + "title": "Frame round-trips an NTSCF-framed ACF_ABB request", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame carrying an NTSCF header (rcp::avtp::NtscfHeader) wrapping an ACF_ABB message (rcp::acf::AcfMessageInfo) unchanged: stream_id, sequence_num, the request's byte_bus_id/transaction_num/op, and its payload shall all be recoverable from the encoded bytes.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-002", - "title": "Protocol version field", - "text": "All wire frames shall carry the protocol version (currently 0x01) in byte 2.", + "title": "Frame round-trips a TSCF-framed request with avtp_timestamp", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame carrying a TSCF header (rcp::avtp::TscfHeader), including its timestamp_valid flag and 32-bit avtp_timestamp, unchanged.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-003", - "title": "Message type field", - "text": "Byte 3 of every wire frame shall encode the message type: Command, Response, Status, Subscribe, or Unsubscribe.", + "title": "Frame round-trips an ACF_GBB message_timestamp", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame whose AcfMessageInfo::acf_msg_type is kAcfMsgTypeGbb, including its mtv flag, 64-bit message_timestamp, and payload, unchanged.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-004", - "title": "Zone field", - "text": "Bytes 4 of every wire frame shall encode the target Zone as a uint8.", + "title": "decode_frame rejects a buffer shorter than the AVTPDU header", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when given fewer bytes than the AVTPDU header (NTSCF or TSCF, selected by the subtype byte) requires.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-005", - "title": "CommandType field for commands", - "text": "Bytes 5\u20136 of Command frames shall encode the CommandType as a big-endian uint16.", + "title": "decode_frame rejects a buffer shorter than the ACF common header", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when the AVTPDU header decodes successfully but fewer bytes than the 10-byte ACF common header (rcp::acf::kAcfCommonHeaderLen) remain.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-006", - "title": "Priority/Status field", - "text": "Byte 7 shall encode Priority for Command frames, ResponseStatus for Response frames, and Healthy flag for Status frames.", + "title": "decode_frame rejects an unrecognized ACF message type", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when the byte immediately following a valid AVTPDU header is neither rcp::acf::kAcfMsgTypeAbb nor rcp::acf::kAcfMsgTypeGbb.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-007", - "title": "Command/response ID field", - "text": "Bytes 8\u201311 shall encode the Command or Response ID as a big-endian uint32.", + "title": "Server dispatches a decoded request through its handler and answers the sender", + "text": "rcp::udp::Server shall decode each inbound UDP datagram as a Frame, invoke its registered Handler with the decoded AcfMessageInfo and payload, and send the handler's response back to the originating sender address, framed under the same NTSCF/TSCF header kind the request arrived under.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-008", - "title": "Payload length field", - "text": "Bytes 12\u201315 shall encode the payload byte count as a big-endian uint32.", + "title": "Server answers Acknowledge by default when no handler is registered", + "text": "rcp::udp::Server shall respond to a decoded request with rcp::acf::ResponseKind::Acknowledge when no Handler has been set via set_handler.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-009", - "title": "Payload follows header", - "text": "The variable-length payload shall immediately follow the 16-byte header.", + "title": "Server assigns stable per-sender client ids", + "text": "rcp::udp::Server shall pass the same opaque client id to its Handler for every request originating from the same sender address, and a distinct id for a request from a different sender address.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-010", - "title": "Big-endian multi-byte fields", - "text": "All multi-byte fields in wire frames shall be encoded in big-endian (network) byte order.", + "title": "Client::request returns ErrTimeout when no response arrives", + "text": "rcp::udp::Client::request shall return rcp::ErrTimeout when the supplied rcp::Context's deadline elapses before a matching response frame is received.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-011", - "title": "Frame validation rejects bad magic", - "text": "The decoder shall return an error if frame bytes 0\u20131 are not 0x52, 0x43.", + "title": "Client::request correlates concurrent requests by byte_bus_id/transaction_num", + "text": "rcp::udp::Client::request shall match each inbound response frame to the pending request whose AcfMessageInfo::byte_bus_id and AcfMessageInfo::transaction_num it echoes, so that two outstanding requests with distinct (byte_bus_id, transaction_num) pairs each receive their own response.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-012", - "title": "Frame validation rejects bad version", - "text": "The decoder shall return an error if the version byte does not equal the supported protocol version.", + "title": "Server and Client close() are idempotent and requests after close fail", + "text": "rcp::udp::Server::close and rcp::udp::Client::close shall be safe to call more than once without blocking indefinitely or crashing, and rcp::udp::Client::request shall return rcp::ErrClosed once the Client has been closed.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a3e5d1..6be7b75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.12.0 + VERSION 2.13.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/README.md b/README.md index 18e9782..3ac015e 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,12 @@ reference. | Header | Description | |---|---| -| `` | Pure-C++ UDP transport for the RCP protocol (still framed via `` until v2.13.0) | +| `` | Native IEEE 1722-over-UDP/IP transport (Annex J): `Server`/`Client` carry AVTPDU (NTSCF/TSCF) + ACF_ABB/ACF_GBB frames over UDP sockets (ROADMAP.md v2.13.0) | | `` | Mutual TLS transport for zone controller communication | | `` | Zero-copy intra-host command delivery via shared in-process memory | | `` | IEEE 802.1Qbv-aware UDP transport adapter for hard real-time Ethernet | | `` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing (ROADMAP.md v2.0.0) | | `` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format (ROADMAP.md v2.0.0) | -| `` | Pre-replacement 16-byte frame codec, kept only for ``'s internal use until it is rebuilt at v2.13.0 | | `` | Timing-realistic RC Server simulator for SiL/HIL testing — wraps `` with latency/jitter and Fault/Recover controls, watchdog wired via `` (ROADMAP.md v2.12.0) | ## Build diff --git a/ROADMAP.md b/ROADMAP.md index 6f625da..e031a56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1017,6 +1017,45 @@ disclaimers in `rcp/regmap.hpp`, `rcp/lifecycle.hpp`, `rcp/gpio.hpp`, and it *is* the old wire format this whole roadmap replaces - Depends on: Wire format core (v2.0.0) +**Done (v2.13.0):** `rcp/udp.hpp` is replaced in full, per the Satellite +Package Disposition table's entry for this file — the bespoke `R`/`C`-magic +16-byte Zone/Command/Response/Status frame this file used to carry +(`rcp/legacy_wire.hpp`) is discarded outright, not adapted, matching the +roadmap's own call for this milestone. `udp::Frame`/`encode_frame`/ +`decode_frame` compose `rcp/avtp.hpp`'s NTSCF/TSCF header codec with +`rcp/acf.hpp`'s ACF_ABB/ACF_GBB message codec into one AVTPDU, carried as +the UDP datagram payload unmodified — this implementation's own reading of +IEEE 1722 Annex J's UDP/IP encapsulation, consistent with `rcp/avtp.hpp`'s +own header comment that its framing is transport-agnostic by design; no +additional encapsulation header is layered on top. `udp::Server` binds a UDP +socket, decodes each inbound datagram as a `Frame`, dispatches the carried +ACF request to a caller-supplied `Handler` shaped to match +`rcp::mock::Server::dispatch`'s signature (v2.12.0) so that simulator can be +wired up as this transport's handler directly, and answers the sender under +the same NTSCF/TSCF header kind the request arrived under; distinct sender +addresses are assigned stable, distinct opaque client ids, first-seen order. +`udp::Client` connects to one server address and correlates each response by +the `(byte_bus_id, transaction_num)` echo rule `rcp/acf.hpp`'s +`make_response` documents, rather than a locally invented request id — the +new addressing model has no analog of the old `Command::id`. Neither class +builds on `rcp.hpp`'s `Zone`/`Command`/`Controller`/`Registry` model, per +that file's own header comment that nothing new should; `udp::Registry`'s +old Zone-keyed controller-collection role has no replacement here since +addressing moved to stream/byte_bus_id. Grepping the tree for consumers of +the old `udp::ZoneServer`/`udp::Controller`/`udp::Registry` API before this +change found none beyond this file's own (now-deleted) test and +`rcp/tsn.hpp`'s doc comment — `rcp/tsn.hpp` itself only ever depended on the +generic `rcp::Controller` interface plus a raw socket fd, never on `udp::` +types directly — so no legacy-shim split file was needed here, unlike +`rcp/mock.hpp`'s at v2.12.0; `rcp/legacy_wire.hpp` is deleted outright in the +same change. New coverage lives in `tests/test_udp.cpp` (`REQ-UDP-001..012`, +entirely rewritten under the same file/prefix identity as the discarded +pre-replacement coverage, the same convention `tests/test_sim.cpp`'s +`REQ-SIM-001..007` rewrite followed at v2.12.0), traced in `.fusa-reqs.json`. +Full bit-for-bit conformance against other TC18/Annex-J implementations is +not claimed, same as the equivalent disclaimers in `rcp/avtp.hpp`, +`rcp/acf.hpp`, and `rcp/discovery.hpp`. + ### 58. Auxiliary Transport & Cross-Cutting Rebind (v2.14.0) - `mdns.hpp`: narrow its scope to the UDP/IP transport variant specifically diff --git a/include/rcp/avtp.hpp b/include/rcp/avtp.hpp index 9cfe810..ba3d2cf 100644 --- a/include/rcp/avtp.hpp +++ b/include/rcp/avtp.hpp @@ -11,12 +11,14 @@ // // ROADMAP.md milestone 44, "Wire Format Core (v2.0.0)": this header (plus // rcp/acf.hpp) replaces the old bespoke 16-byte frame this file used to -// define (now preserved as-is, unrenamed in behavior, under -// rcp/legacy_wire.hpp for rcp/udp.hpp's benefit until the transport itself -// is rebuilt at v2.13.0). Originally landed as a single rcp/wire.hpp; split -// into rcp/avtp.hpp (this file, AVTPDU header framing) and rcp/acf.hpp -// (ACF_ABB/ACF_GBB message format) per RELAY spec §13.7.2's standard -// module-name registry, which names these two concerns separately. +// define (preserved as-is, unrenamed in behavior, under rcp/legacy_wire.hpp +// for rcp/udp.hpp's benefit from v2.0.0 through v2.12.0, until rcp/udp.hpp +// was itself rebuilt directly on this header and rcp/acf.hpp — and +// rcp/legacy_wire.hpp deleted outright — at milestone 57, v2.13.0). +// Originally landed as a single rcp/wire.hpp; split into rcp/avtp.hpp (this +// file, AVTPDU header framing) and rcp/acf.hpp (ACF_ABB/ACF_GBB message +// format) per RELAY spec §13.7.2's standard module-name registry, which +// names these two concerns separately. // // Scope note: this module is a pure wire codec. It has no dependency on // rcp.hpp's Zone/Command/Controller/Registry model, and no dependency on any diff --git a/include/rcp/legacy_wire.hpp b/include/rcp/legacy_wire.hpp deleted file mode 100644 index 3257424..0000000 --- a/include/rcp/legacy_wire.hpp +++ /dev/null @@ -1,232 +0,0 @@ -// fusa:req REQ-UDP-001 -// fusa:req REQ-UDP-002 -// fusa:req REQ-UDP-003 -// fusa:req REQ-UDP-004 -// fusa:req REQ-UDP-005 -// fusa:req REQ-UDP-006 -// fusa:req REQ-UDP-007 -// fusa:req REQ-UDP-008 -// fusa:req REQ-UDP-009 -// fusa:req REQ-UDP-010 -// fusa:req REQ-UDP-011 -// fusa:req REQ-UDP-012 - -// Binary frame codec for the pre-replacement Zone/Command/Response/Status -// model, used today only as the internal frame format of rcp/udp.hpp. -// -// This is the old bespoke 16-byte header that this module (then named -// rcp/wire.hpp) carried before the TC18 protocol-replacement roadmap -// (ROADMAP.md, "44. Wire Format Core (v2.0.0)"). The new format has been -// built as the IEEE 1722 AVTPDU/ACF codec, in rcp/avtp.hpp and rcp/acf.hpp -// (split per RELAY spec §13.7.2's standard module-name registry); this file -// exists solely so rcp/udp.hpp keeps building -// and working against the still-current Zone/Command/Response/Status types -// until the Phase 13/14 model replacement (v2.1.0 onward) removes them and -// rcp/udp.hpp is rebuilt against the new wire format (v2.13.0, per the -// disposition table). Do not build new functionality on top of this file — -// it is a deliberately temporary bridge, not a second wire format going -// forward. -// -// Frame layout (all multi-byte fields big-endian): -// [0] Magic 'R' -// [1] Magic 'C' -// [2] Protocol version (0x01) -// [3] Message type (TypeCommand / TypeResponse / TypeStatus / …) -// [4] Zone (uint8) -// [5:7] CommandType or 0x0000 (uint16) -// [7] Priority, ResponseStatus, or Healthy flag (uint8) -// [8:12] Command/response ID or Status seq (uint32) -// [12:16] Payload length (uint32) -// [16:] Payload (variable) -#pragma once - -#include "rcp.hpp" - -#include -#include - -namespace rcp { -namespace legacy_wire { - -constexpr uint8_t MagicByte0 = 0x52; // 'R' -constexpr uint8_t MagicByte1 = 0x43; // 'C' -constexpr uint8_t ProtoVer = 0x01; - -constexpr uint8_t TypeCommand = 0x01; -constexpr uint8_t TypeResponse = 0x02; -constexpr uint8_t TypeStatus = 0x03; -constexpr uint8_t TypeSubscribe = 0x04; -constexpr uint8_t TypeUnsubscribe = 0x05; - -constexpr size_t HeaderLen = 16; -constexpr size_t MaxPayload = 65507 - HeaderLen; // fits in one UDP datagram - -// ── Codec errors ────────────────────────────────────────────────────────────── - -enum class WireErrc : int { - short_frame = 1, - bad_magic = 2, - bad_version = 3, -}; - -inline const std::error_category& wire_category() noexcept { - struct Cat : std::error_category { - const char* name() const noexcept override { return "rcp.legacy_wire"; } - std::string message(int ev) const override { - switch (static_cast(ev)) { - case WireErrc::short_frame: return "rcp/legacy_wire: frame too short"; - case WireErrc::bad_magic: return "rcp/legacy_wire: bad magic bytes"; - case WireErrc::bad_version: return "rcp/legacy_wire: unsupported protocol version"; - default: return "rcp/legacy_wire: unknown error"; - } - } - }; - static Cat instance; - return instance; -} - -inline std::error_code make_error_code(WireErrc e) noexcept { - return {static_cast(e), wire_category()}; -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -namespace detail { - -inline void put_u16(uint8_t* p, uint16_t v) noexcept { - p[0] = static_cast(v >> 8); - p[1] = static_cast(v & 0xFF); -} -inline void put_u32(uint8_t* p, uint32_t v) noexcept { - p[0] = static_cast(v >> 24); - p[1] = static_cast((v >> 16) & 0xFF); - p[2] = static_cast((v >> 8) & 0xFF); - p[3] = static_cast(v & 0xFF); -} -inline uint16_t get_u16(const uint8_t* p) noexcept { - return static_cast((uint16_t(p[0]) << 8) | p[1]); -} -inline uint32_t get_u32(const uint8_t* p) noexcept { - return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | - (uint32_t(p[2]) << 8) | uint32_t(p[3]); -} - -} // namespace detail - -// ── Header validation ───────────────────────────────────────────────────────── - -inline std::error_code validate_header(const uint8_t* b, size_t len) noexcept { - if (len < HeaderLen) - return make_error_code(WireErrc::short_frame); - if (b[0] != MagicByte0 || b[1] != MagicByte1) - return make_error_code(WireErrc::bad_magic); - if (b[2] != ProtoVer) - return make_error_code(WireErrc::bad_version); - return {}; -} - -// ── Command encode / decode ─────────────────────────────────────────────────── - -inline std::vector encode_command(const Command& cmd) { - const size_t n = HeaderLen + cmd.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeCommand; - buf[4] = static_cast(cmd.zone); - detail::put_u16(&buf[5], static_cast(cmd.type)); - buf[7] = static_cast(cmd.priority); - detail::put_u32(&buf[8], cmd.id); - detail::put_u32(&buf[12], static_cast(cmd.payload.size())); - std::copy(cmd.payload.begin(), cmd.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_command(const uint8_t* b, size_t len, Command& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.type = static_cast(detail::get_u16(&b[5])); - out.priority = static_cast(b[7]); - out.id = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Response encode / decode ────────────────────────────────────────────────── - -inline std::vector encode_response(const Response& resp) { - const size_t n = HeaderLen + resp.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeResponse; - buf[4] = static_cast(resp.zone); - detail::put_u16(&buf[5], 0); - buf[7] = static_cast(resp.status); - detail::put_u32(&buf[8], resp.command_id); - detail::put_u32(&buf[12], static_cast(resp.payload.size())); - std::copy(resp.payload.begin(), resp.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_response(const uint8_t* b, size_t len, Response& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.status = static_cast(b[7]); - out.command_id = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Status encode / decode ──────────────────────────────────────────────────── - -inline std::vector encode_status(const Status& st) { - const size_t n = HeaderLen + st.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeStatus; - buf[4] = static_cast(st.zone); - detail::put_u16(&buf[5], 0); - buf[7] = st.healthy ? 1u : 0u; - detail::put_u32(&buf[8], st.seq); - detail::put_u32(&buf[12], static_cast(st.payload.size())); - std::copy(st.payload.begin(), st.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_status(const uint8_t* b, size_t len, Status& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.healthy = (b[7] == 1); - out.seq = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Control frame (subscribe / unsubscribe) ─────────────────────────────────── - -inline std::vector encode_control(uint8_t msg_type, Zone zone) { - std::vector buf(HeaderLen, 0); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = msg_type; - buf[4] = static_cast(zone); - return buf; -} - -} // namespace legacy_wire -} // namespace rcp diff --git a/include/rcp/tsn.hpp b/include/rcp/tsn.hpp index dd24b68..70bd347 100644 --- a/include/rcp/tsn.hpp +++ b/include/rcp/tsn.hpp @@ -14,7 +14,13 @@ // Full 802.1Qbv gate scheduling requires a TSN-capable NIC and kernel ≥ 4.15. // On standard hardware this provides best-effort priority mapping only. // -// Wraps udp::Controller; add #include before this header. +// Wraps any rcp::Controller (e.g. the pre-replacement rcp/legacy_mock.hpp's +// Controller); it depends only on the socket fd passed in, not on any +// concrete transport header. See ROADMAP.md's Satellite Package Disposition +// table's entry for this file — a v2.14.0 rebind to the post-replacement +// request model, once rcp/udp.hpp itself no longer implements this +// pre-replacement Controller interface (v2.13.0) — for why this class still +// targets the old model today. #pragma once #include "rcp.hpp" diff --git a/include/rcp/udp.hpp b/include/rcp/udp.hpp index 0c965ee..7a25584 100644 --- a/include/rcp/udp.hpp +++ b/include/rcp/udp.hpp @@ -11,30 +11,65 @@ // fusa:req REQ-UDP-011 // fusa:req REQ-UDP-012 -// Pure-C++ UDP transport for the RCP protocol. +// Native IEEE 1722-over-UDP/IP transport for the OPEN Alliance TC18 Remote +// Control Protocol Specification v0.5.1_RC — carries real AVTPDU (NTSCF/ +// TSCF, rcp/avtp.hpp) frames wrapping ACF_ABB/ACF_GBB messages (rcp/acf.hpp) +// as raw UDP datagram payloads. // // On POSIX (Linux, macOS): full implementation using BSD sockets. // On Windows: stub that returns std::errc::function_not_supported. // -// Frame format is defined in rcp/legacy_wire.hpp — the pre-replacement -// 16-byte Zone/Command/Response/Status frame, not the TC18 AVTPDU/ACF codec -// now in rcp/avtp.hpp and rcp/acf.hpp. This transport is rebuilt to carry real AVTPDU -// frames at v2.13.0 (ROADMAP.md); until then it deliberately keeps using -// the old frame format so it keeps building and working against the -// still-current Zone/Command model. +// ROADMAP.md milestone 57, "Native Transport Rebuild — UDP/IP (Annex J), +// v2.13.0": this header REPLACES this file's pre-replacement content in +// full, per the Satellite Package Disposition table's entry for `udp.hpp` +// — the bespoke `R`/`C`-magic 16-byte Zone/Command/Response/Status frame +// this file used to carry (rcp/legacy_wire.hpp) is discarded outright, not +// adapted, since it *is* the old wire format this whole roadmap replaces +// (the roadmap's own words for this milestone). Unlike rcp/mock.hpp's split +// at v2.12.0, no legacy shim file was created here: grepping the tree found +// no consumer of the old udp::ZoneServer/udp::Controller/udp::Registry API +// beyond this file's own (now-deleted) test and rcp/tsn.hpp's doc comment +// — and rcp/tsn.hpp itself only ever depended on the generic rcp::Controller +// interface plus a raw socket fd, never on udp:: types directly, so it needed +// no change here. rcp/legacy_wire.hpp is deleted in the same change as this +// file for the same reason. +// +// This module does not build on rcp.hpp's Zone/Command/Controller/Registry +// model — per rcp.hpp's own header comment, nothing new should. Instead it +// addresses the way rcp/avtp.hpp and rcp/acf.hpp already do: a StreamId per +// endpoint plus a byte_bus_id/transaction_num pair per request, with request/ +// response correlation performed via the echo rule rcp/acf.hpp's +// make_response() documents rather than a locally invented 32-bit request +// id. Server's request handler is deliberately shaped to match +// rcp::mock::Server::dispatch's signature (v2.12.0) so an in-process +// simulator can be wired up as this transport's handler directly, without +// this header needing to depend on rcp/mock.hpp itself. +// +// IEEE 1722's own Annex J describes carrying AVTPDUs over UDP/IP instead of +// raw Ethernet, for links where native AVTP framing (destination MAC + +// EtherType 0x22F0) is not available. This implementation's own reading of +// that behavior — consistent with rcp/avtp.hpp's own header comment that its +// NTSCF/TSCF framing is transport-agnostic by design — is that the AVTPDU +// bytes rcp/avtp.hpp and rcp/acf.hpp already produce are carried unmodified +// as the UDP payload; no additional encapsulation header is layered on top +// here, and IP/UDP addressing substitutes for the Ethernet destination +// address a native AVTP link would otherwise use. Full bit-for-bit +// conformance against other TC18/Annex-J implementations is not claimed, +// same as the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and +// rcp/discovery.hpp. #pragma once -#include "rcp.hpp" -#include "legacy_wire.hpp" +#include "acf.hpp" +#include "avtp.hpp" +#include "rcp.hpp" // for rcp::Context only — see this header's own scope note above -#include #include #include +#include #include #include #include #include -#include #include #include #include @@ -51,18 +86,108 @@ namespace rcp { namespace udp { +// A UDP datagram is capped at 65507 payload bytes (65535 minus the IPv4 and +// UDP headers) regardless of AVTPDU/ACF content — the same ceiling +// rcp/legacy_wire.hpp's MaxPayload used to size against. +constexpr size_t kMaxDatagram = 65507; + #if defined(RCP_UDP_POSIX) -// ── ZoneServer ──────────────────────────────────────────────────────────────── +// ── Frame ───────────────────────────────────────────────────────────────────── +// Frame is one encapsulated AVTPDU: an NTSCF or TSCF header (rcp/avtp.hpp) +// wrapping one ACF_ABB or ACF_GBB message (rcp/acf.hpp, selected by +// info.acf_msg_type). encode_frame/decode_frame compose those two headers' +// existing codecs rather than re-deriving any bit layout, and are pure +// functions — no socket I/O — so they can be exercised directly in tests +// without a real UDP round trip. +struct Frame { + bool use_tscf = false; // false = NTSCF, true = TSCF + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; // TSCF "tv" bit; ignored under NTSCF + uint32_t avtp_timestamp = 0; // TSCF-only; ignored under NTSCF + + acf::AcfMessageInfo info{}; + uint64_t message_timestamp = 0; // honored only when info.acf_msg_type == kAcfMsgTypeGbb + std::vector payload; +}; -// ZoneServer listens on a UDP port, handles Command frames, and publishes -// Status to all registered subscribers. -class ZoneServer { -public: - using Handler = std::function; +inline std::vector encode_frame(const Frame& f) { + std::vector acf_msg = (f.info.acf_msg_type == acf::kAcfMsgTypeGbb) + ? acf::encode_acf_gbb(f.info, f.message_timestamp, f.payload) + : acf::encode_acf_abb(f.info, f.payload); + + std::vector out; + if (f.use_tscf) { + avtp::TscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + hdr.timestamp_valid = f.timestamp_valid; + hdr.avtp_timestamp = f.avtp_timestamp; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + out = avtp::encode_ntscf_header(hdr); + } + out.insert(out.end(), acf_msg.begin(), acf_msg.end()); + return out; +} + +inline std::error_code decode_frame(const uint8_t* b, size_t len, Frame& out) { + if (len < 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + + size_t acf_off; + out.use_tscf = (b[0] == avtp::kSubtypeTscf); + if (out.use_tscf) { + avtp::TscfHeader hdr; + if (auto ec = avtp::decode_tscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = hdr.timestamp_valid; + out.avtp_timestamp = hdr.avtp_timestamp; + acf_off = avtp::kTscfHeaderLen; + } else { + avtp::NtscfHeader hdr; + if (auto ec = avtp::decode_ntscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = false; + out.avtp_timestamp = 0; + acf_off = avtp::kNtscfHeaderLen; + } + + if (len < acf_off + 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + if (b[acf_off] == acf::kAcfMsgTypeGbb) { + return acf::decode_acf_gbb(b + acf_off, len - acf_off, out.info, + out.message_timestamp, out.payload); + } + out.message_timestamp = 0; + return acf::decode_acf_abb(b + acf_off, len - acf_off, out.info, out.payload); +} - ZoneServer(Zone zone, const char* addr, uint16_t port) - : zone_(zone), fd_(-1) { +// ── Server ──────────────────────────────────────────────────────────────────── +// Server binds a UDP socket, decodes each inbound datagram as a Frame, and +// dispatches the carried ACF request to a caller-supplied Handler — shaped +// to match rcp::mock::Server::dispatch's signature — then encodes and sends +// the handler's response back to the sender under the same header kind +// (NTSCF/TSCF) the request arrived under. Malformed datagrams (short buffer, +// bad subtype, unrecognized ACF message type) are dropped silently, the same +// "drop rather than partially process" choice rcp/discovery.hpp documents +// for its own decode path. +class Server { +public: + using Handler = std::function& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload)>; + + Server(avtp::StreamId stream_id, const char* addr, uint16_t port) + : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; sockaddr_in sa{}; @@ -77,11 +202,10 @@ class ZoneServer { fd_ = -1; return; } - healthy_.store(true); serve_thread_ = std::thread([this]{ serve(); }); } - ~ZoneServer() { close(); } + ~Server() { close(); } // addr_string returns "host:port" for this server's bound address. std::string addr_string() const { @@ -107,19 +231,6 @@ class ZoneServer { handler_ = std::move(h); } - void set_healthy(bool v) { healthy_.store(v, std::memory_order_release); } - - void publish(const std::vector& payload) { - uint32_t seq = ++seq_; - Status st{zone_, seq, healthy_.load(), payload}; - auto frame = legacy_wire::encode_status(st); - std::lock_guard lk(mu_); - for (auto& kv : subscribers_) { - ::sendto(fd_, frame.data(), frame.size(), 0, - reinterpret_cast(&kv.second), sizeof(kv.second)); - } - } - void close() { if (!closed_.exchange(true)) { if (fd_ >= 0) { @@ -134,67 +245,92 @@ class ZoneServer { bool ok() const noexcept { return fd_ >= 0; } private: - Zone zone_; - int fd_; + avtp::StreamId stream_id_; + int fd_; std::atomic closed_{false}; - std::atomic healthy_{false}; - std::atomic seq_{0}; + std::atomic seq_{0}; std::mutex mu_; Handler handler_; - std::map subscribers_; std::thread serve_thread_; + // client_ids_ assigns each distinct sender address a stable, opaque + // size_t identity, first-seen order — the same role rcp/regmap.hpp's + // Ep0 root-client index plays for its own callers, just derived from + // the UDP sender address instead of an in-process connection index. + std::map client_ids_; + size_t next_client_id_ = 0; + static std::string addr_key(const sockaddr_in& sa) { char buf[INET_ADDRSTRLEN]; ::inet_ntop(AF_INET, &sa.sin_addr, buf, sizeof(buf)); return std::string(buf) + ":" + std::to_string(ntohs(sa.sin_port)); } + size_t client_id_for(const sockaddr_in& from) { + auto key = addr_key(from); + auto it = client_ids_.find(key); + if (it != client_ids_.end()) return it->second; + size_t id = next_client_id_++; + client_ids_.emplace(key, id); + return id; + } + void serve() { - std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); - sockaddr_in client{}; - socklen_t clen = sizeof(client); + std::vector buf(kMaxDatagram); + sockaddr_in from{}; + socklen_t flen = sizeof(from); while (!closed_.load()) { ssize_t n = ::recvfrom(fd_, buf.data(), buf.size(), 0, - reinterpret_cast(&client), &clen); + reinterpret_cast(&from), &flen); if (n <= 0) break; - if (static_cast(n) < legacy_wire::HeaderLen) continue; - switch (buf[3]) { - case legacy_wire::TypeCommand: { - Command cmd; - if (legacy_wire::decode_command(buf.data(), static_cast(n), cmd)) break; - Response resp; - { - std::lock_guard lk(mu_); - if (handler_) resp = handler_(cmd); - else resp = Response{cmd.id, zone_, ResponseStatus::OK, {}}; + + Frame req; + if (decode_frame(buf.data(), static_cast(n), req)) continue; + + acf::AcfMessageInfo out_info; + std::vector out_payload; + size_t client; + { + std::lock_guard lk(mu_); + client = client_id_for(from); + if (handler_) { + auto ec = handler_(client, req.info, req.payload, out_info, out_payload); + (void)ec; // Handler always populates out_info even on failure, + // same contract rcp::mock::Server::dispatch documents. + } else { + out_info = acf::make_response(req.info, acf::ResponseKind::Acknowledge); } - auto frame = legacy_wire::encode_response(resp); - ::sendto(fd_, frame.data(), frame.size(), 0, - reinterpret_cast(&client), clen); - break; - } - case legacy_wire::TypeSubscribe: - subscribers_[addr_key(client)] = client; - break; - case legacy_wire::TypeUnsubscribe: - subscribers_.erase(addr_key(client)); - break; - default: - break; } + + Frame resp; + resp.use_tscf = req.use_tscf; + resp.stream_id = stream_id_; + resp.sequence_num = static_cast(++seq_); + resp.timestamp_valid = req.timestamp_valid; + resp.avtp_timestamp = req.avtp_timestamp; + resp.info = out_info; + resp.payload = std::move(out_payload); + + auto out_frame = encode_frame(resp); + ::sendto(fd_, out_frame.data(), out_frame.size(), 0, + reinterpret_cast(&from), flen); } } }; -// ── Controller ──────────────────────────────────────────────────────────────── - -// Controller connects to a ZoneServer via UDP and implements rcp::Controller. -class Controller final : public rcp::Controller { +// ── Client ──────────────────────────────────────────────────────────────────── +// Client connects to one Server address and sends AVTPDU-framed ACF requests, +// correlating each response by the (byte_bus_id, transaction_num) pair +// rcp/acf.hpp's make_response echoes back unchanged — there is no locally +// invented request id the way rcp.hpp's old Command::id was, since the new +// addressing model has no analog of it. Callers build `req` themselves (e.g. +// via acf::make_standard_request or rcp/discovery.hpp's +// make_discovery_request) so this transport stays a pure carrier, not a +// second place request semantics are decided. +class Client { public: - // Connect to serverHost:serverPort for zone. - Controller(Zone zone, const char* server_host, uint16_t server_port) - : zone_(zone), fd_(-1) { + Client(avtp::StreamId stream_id, const char* server_host, uint16_t server_port) + : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; @@ -211,41 +347,52 @@ class Controller final : public rcp::Controller { read_thread_ = std::thread([this]{ read_loop(); }); } - ~Controller() override { auto ec = close(); (void)ec; } - - Zone zone() const noexcept override { return zone_; } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { + ~Client() { auto ec = close(); (void)ec; } + + // request sends one ACF request — ABB or GBB per req.info's own + // acf_msg_type, wrapped in the AVTPDU header rcp/avtp.hpp defines for + // whichever kind `use_tscf` selects — and blocks until the matching + // response arrives or `ctx` is done. message_timestamp is only sent + // (and only meaningful) when req.acf_msg_type is kAcfMsgTypeGbb. + std::error_code request(const rcp::Context& ctx, + const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, + bool use_tscf = false, + uint32_t avtp_timestamp = 0, + uint64_t message_timestamp = 0) { if (closed_.load()) return ErrClosed; if (ctx.done()) return ErrTimeout; - if (cmd.zone != zone_) return ErrZoneMismatch; - uint32_t id = ++next_id_; - Command safe = cmd; - if (!cmd.payload.empty()) safe.payload = cmd.payload; - safe.id = id; - - auto frame = legacy_wire::encode_command(safe); - auto result = std::make_shared>(); + Frame out; + out.use_tscf = use_tscf; + out.stream_id = stream_id_; + out.sequence_num = static_cast(++seq_); + out.timestamp_valid = use_tscf; + out.avtp_timestamp = avtp_timestamp; + out.info = req; + out.message_timestamp = message_timestamp; + out.payload = req_payload; + + const uint16_t key = pending_key(req.byte_bus_id, req.transaction_num); + auto result = std::make_shared>(); auto future = result->get_future(); { std::lock_guard lk(mu_); - pending_[id] = result; + pending_[key] = result; } - auto cleanup = [&]{ std::lock_guard lk(mu_); - pending_.erase(id); + pending_.erase(key); }; - if (::send(fd_, frame.data(), frame.size(), 0) < 0) { + auto frame_bytes = encode_frame(out); + if (::send(fd_, frame_bytes.data(), frame_bytes.size(), 0) < 0) { cleanup(); return ErrClosed; } - // Wait for response or timeout. std::future_status st; if (ctx.deadline()) { st = future.wait_until(*ctx.deadline()); @@ -256,50 +403,14 @@ class Controller final : public rcp::Controller { cleanup(); if (st == std::future_status::timeout) return ErrTimeout; if (!future.valid()) return ErrClosed; - out = future.get(); - return {}; - } - - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - if (closed_.load()) return ErrClosed; - - auto ch = std::make_shared(16); - { - std::lock_guard lk(mu_); - subs_.push_back(ch); - } - - // Send subscribe control frame. - auto frame = legacy_wire::encode_control(legacy_wire::TypeSubscribe, zone_); - ::send(fd_, frame.data(), frame.size(), 0); - - std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { - while (!ctx.done() && !closed_.load()) { - auto c = weak_ch.lock(); - if (!c || c->is_closed()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - // Send unsubscribe. - if (!closed_.load()) { - auto frame2 = legacy_wire::encode_control(legacy_wire::TypeUnsubscribe, zone_); - ::send(fd_, frame2.data(), frame2.size(), 0); - } - auto c = weak_ch.lock(); - if (!c) return; - { - std::lock_guard lk(mu_); - auto it = std::find(subs_.begin(), subs_.end(), c); - if (it != subs_.end()) subs_.erase(it); - } - c->close(); - }).detach(); - out = std::move(ch); + Frame resp = future.get(); + out_resp = resp.info; + out_resp_payload = std::move(resp.payload); return {}; } - std::error_code close() override { + std::error_code close() { if (!closed_.exchange(true)) { if (fd_ >= 0) { ::shutdown(fd_, SHUT_RDWR); @@ -307,169 +418,89 @@ class Controller final : public rcp::Controller { } } if (read_thread_.joinable()) read_thread_.join(); - std::lock_guard lk(mu_); - for (auto& s : subs_) s->close(); - subs_.clear(); return {}; } bool ok() const noexcept { return fd_ >= 0; } private: - Zone zone_; - int fd_; + static uint16_t pending_key(avtp::ByteBusId bus_id, uint8_t transaction_num) noexcept { + return static_cast((static_cast(bus_id) << 8) | transaction_num); + } + + avtp::StreamId stream_id_; + int fd_; std::atomic closed_{false}; - std::atomic next_id_{0}; + std::atomic seq_{0}; std::mutex mu_; - std::map>> pending_; - std::vector> subs_; + std::map>> pending_; std::thread read_thread_; void read_loop() { - std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); + std::vector buf(kMaxDatagram); while (!closed_.load()) { ssize_t n = ::recv(fd_, buf.data(), buf.size(), 0); if (n <= 0) break; - if (static_cast(n) < legacy_wire::HeaderLen) continue; - switch (buf[3]) { - case legacy_wire::TypeResponse: { - Response resp; - if (legacy_wire::decode_response(buf.data(), static_cast(n), resp)) break; - std::lock_guard lk(mu_); - auto it = pending_.find(resp.command_id); - if (it != pending_.end()) { - it->second->set_value(std::move(resp)); - } - break; - } - case legacy_wire::TypeStatus: { - Status st; - if (legacy_wire::decode_status(buf.data(), static_cast(n), st)) break; - std::lock_guard lk(mu_); - for (auto& s : subs_) s->push(st); - break; - } - default: - break; + + Frame resp; + if (decode_frame(buf.data(), static_cast(n), resp)) continue; + + const uint16_t key = pending_key(resp.info.byte_bus_id, resp.info.transaction_num); + std::lock_guard lk(mu_); + auto it = pending_.find(key); + if (it != pending_.end()) { + it->second->set_value(std::move(resp)); + pending_.erase(it); } } } }; -// ── Registry ────────────────────────────────────────────────────────────────── - -class Registry final : public rcp::Registry { -public: - std::error_code dial(Zone z, const char* host, uint16_t port) { - auto ctrl = std::make_shared(z, host, port); - if (!ctrl->ok()) return make_error_code(Errc::not_found); - return register_ctrl(ctrl); - } - - std::error_code register_ctrl(std::shared_ptr ctrl) override { - std::unique_lock lk(mu_); - if (closed_) return ErrClosed; - if (ctrls_.count(ctrl->zone())) return ErrAlreadyExists; - ctrls_[ctrl->zone()] = ctrl; - return {}; - } - - std::error_code deregister(Zone z) override { - std::unique_lock lk(mu_); - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - auto ctrl = it->second; - ctrls_.erase(it); - lk.unlock(); - return ctrl->close(); - } - - std::error_code lookup(Zone z, std::shared_ptr& out) override { - std::shared_lock lk(mu_); - if (closed_) return ErrClosed; - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - out = it->second; - return {}; - } - - std::vector> controllers() override { - std::shared_lock lk(mu_); - std::vector> out; - out.reserve(ctrls_.size()); - for (auto& kv : ctrls_) out.push_back(kv.second); - return out; - } - - std::error_code close() override { - std::unique_lock lk(mu_); - if (closed_) return {}; - closed_ = true; - auto local = std::move(ctrls_); - lk.unlock(); - for (auto& kv : local) { auto ec = kv.second->close(); (void)ec; } - return {}; - } +#else // !RCP_UDP_POSIX (Windows stub) -private: - mutable std::shared_mutex mu_; - std::map> ctrls_; - bool closed_ = false; +struct Frame { + bool use_tscf = false; + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; + uint32_t avtp_timestamp = 0; + acf::AcfMessageInfo info{}; + uint64_t message_timestamp = 0; + std::vector payload; }; -inline std::unique_ptr new_registry() { - return std::make_unique(); +inline std::vector encode_frame(const Frame&) { return {}; } +inline std::error_code decode_frame(const uint8_t*, size_t, Frame&) { + return std::make_error_code(std::errc::function_not_supported); } -#else // !RCP_UDP_POSIX (Windows stub) - -class ZoneServer { +class Server { public: - ZoneServer(Zone, const char*, uint16_t) {} + using Handler = std::function&, + acf::AcfMessageInfo&, std::vector&)>; + + Server(avtp::StreamId, const char*, uint16_t) {} std::string addr_string() const { return {}; } uint16_t port() const { return 0; } - void set_handler(std::function) {} - void set_healthy(bool) {} - void publish(const std::vector&) {} + void set_handler(Handler) {} void close() {} bool ok() const noexcept { return false; } }; -class Controller final : public rcp::Controller { +class Client { public: - Controller(Zone z, const char*, uint16_t) : zone_(z) {} - Zone zone() const noexcept override { return zone_; } - std::error_code send(const rcp::Context&, const Command&, Response&) override { + Client(avtp::StreamId, const char*, uint16_t) {} + std::error_code request(const rcp::Context&, const acf::AcfMessageInfo&, + const std::vector&, + acf::AcfMessageInfo&, std::vector&, + bool = false, uint32_t = 0, uint64_t = 0) { return std::make_error_code(std::errc::function_not_supported); } - std::error_code subscribe(const rcp::Context&, std::shared_ptr&) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code close() override { return {}; } + std::error_code close() { return {}; } bool ok() const noexcept { return false; } -private: - Zone zone_; }; -class Registry final : public rcp::Registry { -public: - std::error_code register_ctrl(std::shared_ptr) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code deregister(Zone) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code lookup(Zone, std::shared_ptr&) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::vector> controllers() override { return {}; } - std::error_code close() override { return {}; } -}; - -inline std::unique_ptr new_registry() { - return std::make_unique(); -} - #endif // RCP_UDP_POSIX } // namespace udp diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 89d8f77..15ee9ee 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.12.0"; +constexpr std::string_view kVersion = "2.13.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 17f1f2f..9003aac 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,10 +18,6 @@ add_executable(test_acf test_acf.cpp) target_link_libraries(test_acf PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_acf COMMAND test_acf) -add_executable(test_legacy_wire test_legacy_wire.cpp) -target_link_libraries(test_legacy_wire PRIVATE rcp::rcp Catch2::Catch2WithMain) -add_test(NAME rcp_legacy_wire COMMAND test_legacy_wire) - add_executable(test_lifecycle test_lifecycle.cpp) target_link_libraries(test_lifecycle PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_lifecycle COMMAND test_lifecycle) @@ -197,7 +193,7 @@ add_test(NAME rcp_cli COMMAND test_cli) foreach(mod canbr ddsbr doipbr grpcbridge linbr mqttbr restbridge someipbr udsbr - tsn mdns powerstate tls) + tsn mdns powerstate tls udp) add_executable(test_${mod} test_${mod}.cpp) target_link_libraries(test_${mod} PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_${mod} COMMAND test_${mod}) diff --git a/tests/test_legacy_wire.cpp b/tests/test_legacy_wire.cpp deleted file mode 100644 index 67530af..0000000 --- a/tests/test_legacy_wire.cpp +++ /dev/null @@ -1,158 +0,0 @@ -// fusa:test REQ-UDP-001 -// fusa:test REQ-UDP-002 -// fusa:test REQ-UDP-003 -// fusa:test REQ-UDP-004 -// fusa:test REQ-UDP-005 -// fusa:test REQ-UDP-006 -// fusa:test REQ-UDP-007 -// fusa:test REQ-UDP-008 -// fusa:test REQ-UDP-009 -// fusa:test REQ-UDP-010 -// fusa:test REQ-UDP-011 -// fusa:test REQ-UDP-012 - -// Tests for rcp/legacy_wire.hpp — the pre-replacement 16-byte frame codec -// still used internally by rcp/udp.hpp. See that header's comment for why -// this still exists alongside the new rcp/avtp.hpp + rcp/acf.hpp AVTPDU/ACF -// codec. - -#include -#include - -using namespace rcp; -using namespace rcp::legacy_wire; - -// ── Header constants ────────────────────────────────────────────────────────── - -TEST_CASE("Legacy wire header length and magic are correct", "[legacy_wire][REQ-UDP-001]") { - REQUIRE(HeaderLen == 16); - REQUIRE(MagicByte0 == 'R'); - REQUIRE(MagicByte1 == 'C'); -} - -TEST_CASE("Legacy wire version is 1", "[legacy_wire][REQ-UDP-002]") { - REQUIRE(ProtoVer == 1); -} - -TEST_CASE("Legacy MaxPayload is non-zero", "[legacy_wire][REQ-UDP-003]") { - REQUIRE(MaxPayload > 0); -} - -// ── Command round-trip ──────────────────────────────────────────────────────── - -TEST_CASE("encode_command / decode_command round-trip with no payload", "[legacy_wire][REQ-UDP-004]") { - Command cmd; - cmd.id = 0xDEADBEEF; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Set; - cmd.priority = Priority::High; - - auto frame = encode_command(cmd); - REQUIRE(frame.size() >= HeaderLen); - - Command out; - auto ec = decode_command(frame.data(), frame.size(), out); - REQUIRE_FALSE(ec); - REQUIRE(out.id == cmd.id); - REQUIRE(out.zone == cmd.zone); - REQUIRE(out.type == cmd.type); - REQUIRE(out.priority == cmd.priority); - REQUIRE(out.payload.empty()); -} - -TEST_CASE("encode_command / decode_command round-trip with payload", "[legacy_wire][REQ-UDP-005]") { - Command cmd; - cmd.id = 42; - cmd.zone = Zone::RearRight; - cmd.type = CommandType::Get; - cmd.payload = {0x01, 0x02, 0x03, 0x04}; - - auto frame = encode_command(cmd); - Command out; - REQUIRE_FALSE(decode_command(frame.data(), frame.size(), out)); - REQUIRE(out.payload == cmd.payload); -} - -TEST_CASE("decode_command rejects truncated frames", "[legacy_wire][REQ-UDP-006]") { - Command cmd; - cmd.id = 1; - cmd.zone = Zone::Central; - auto frame = encode_command(cmd); - - Command out; - REQUIRE(decode_command(frame.data(), HeaderLen - 1, out)); // truncated -} - -// ── Response round-trip ─────────────────────────────────────────────────────── - -TEST_CASE("encode_response / decode_response round-trip", "[legacy_wire][REQ-UDP-007]") { - Response resp; - resp.command_id = 0x1234; - resp.zone = Zone::FrontRight; - resp.status = ResponseStatus::OK; - resp.payload = {0xFF, 0xFE}; - - auto frame = encode_response(resp); - Response out; - REQUIRE_FALSE(decode_response(frame.data(), frame.size(), out)); - REQUIRE(out.command_id == resp.command_id); - REQUIRE(out.zone == resp.zone); - REQUIRE(out.status == resp.status); - REQUIRE(out.payload == resp.payload); -} - -// ── Status round-trip ───────────────────────────────────────────────────────── - -TEST_CASE("encode_status / decode_status round-trip", "[legacy_wire][REQ-UDP-008]") { - Status st; - st.zone = Zone::RearLeft; - st.seq = 99; - st.healthy = true; - st.payload = {0xAB}; - - auto frame = encode_status(st); - Status out; - REQUIRE_FALSE(decode_status(frame.data(), frame.size(), out)); - REQUIRE(out.zone == st.zone); - REQUIRE(out.seq == st.seq); - REQUIRE(out.healthy == st.healthy); - REQUIRE(out.payload == st.payload); -} - -// ── Frame magic / version ───────────────────────────────────────────────────── - -TEST_CASE("Frames begin with 'RC' magic and correct version", "[legacy_wire][REQ-UDP-009]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - REQUIRE(frame[0] == 'R'); - REQUIRE(frame[1] == 'C'); - REQUIRE(frame[2] == ProtoVer); -} - -TEST_CASE("decode_command rejects wrong magic", "[legacy_wire][REQ-UDP-010]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - frame[0] = 'X'; // corrupt magic - Command out; - REQUIRE(decode_command(frame.data(), frame.size(), out)); -} - -TEST_CASE("decode_command rejects wrong version", "[legacy_wire][REQ-UDP-011]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - frame[2] = ProtoVer + 1; // bump version - Command out; - REQUIRE(decode_command(frame.data(), frame.size(), out)); -} - -// ── Control frame ───────────────────────────────────────────────────────────── - -TEST_CASE("encode_control produces a valid header-only frame", "[legacy_wire][REQ-UDP-012]") { - auto frame = encode_control(TypeSubscribe, Zone::Central); - REQUIRE(frame.size() == HeaderLen); - REQUIRE(frame[0] == 'R'); - REQUIRE(frame[3] == TypeSubscribe); -} diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp new file mode 100644 index 0000000..033bd4a --- /dev/null +++ b/tests/test_udp.cpp @@ -0,0 +1,326 @@ +// fusa:test REQ-UDP-001 +// fusa:test REQ-UDP-002 +// fusa:test REQ-UDP-003 +// fusa:test REQ-UDP-004 +// fusa:test REQ-UDP-005 +// fusa:test REQ-UDP-006 +// fusa:test REQ-UDP-007 +// fusa:test REQ-UDP-008 +// fusa:test REQ-UDP-009 +// fusa:test REQ-UDP-010 +// fusa:test REQ-UDP-011 +// fusa:test REQ-UDP-012 + +// Tests for rcp/udp.hpp — the native IEEE 1722-over-UDP/IP transport +// (ROADMAP.md milestone 57, "Native Transport Rebuild — UDP/IP (Annex J)", +// v2.13.0). See that header's own comment for why no legacy-shim split file +// was needed for this rebuild, unlike rcp/mock.hpp's at v2.12.0. + +#include +#include + +#include + +using namespace rcp; +using namespace rcp::udp; + +namespace { + +#if defined(RCP_UDP_POSIX) +// A bound UDP socket that accepts datagrams but never replies. Used only by +// the timeout test below, so a Client waiting on it genuinely times out +// instead of racing an OS-level "port unreachable" ICMP notification against +// an already-closed port (platform-dependent and not what REQ-UDP-010 means +// to exercise). +class BlackHole { +public: + BlackHole() { + fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = 0; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + ::bind(fd_, reinterpret_cast(&sa), sizeof(sa)); + } + ~BlackHole() { if (fd_ >= 0) ::close(fd_); } + + uint16_t port() const { + sockaddr_in sa{}; + socklen_t len = sizeof(sa); + ::getsockname(fd_, reinterpret_cast(&sa), &len); + return ntohs(sa.sin_port); + } + +private: + int fd_ = -1; +}; +#endif + +avtp::StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { + avtp::StreamId id; + for (auto& b : id.mac) b = mac_seed++; + id.suffix = suffix; + return id; +} + +acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, uint8_t transaction_num, + bool write = false, uint16_t read_size = 0) { + return acf::make_standard_request(bus_id, transaction_num, write, read_size); +} + +} // namespace + +// ── Frame encode/decode (pure codec, no sockets) ───────────────────────────── + +TEST_CASE("Frame round-trips an NTSCF-framed ACF_ABB request", "[udp][REQ-UDP-001]") { + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x1234); + f.sequence_num = 7; + f.info = standard_request(/*bus_id=*/3, /*transaction_num=*/9, /*write=*/true); + f.payload = {0x01, 0x02, 0x03}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeNtscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE_FALSE(out.use_tscf); + REQUIRE(out.stream_id == f.stream_id); + REQUIRE(out.sequence_num == f.sequence_num); + REQUIRE(out.info.byte_bus_id == f.info.byte_bus_id); + REQUIRE(out.info.transaction_num == f.info.transaction_num); + REQUIRE(out.info.op == f.info.op); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("Frame round-trips a TSCF-framed request with avtp_timestamp", "[udp][REQ-UDP-002]") { + Frame f; + f.use_tscf = true; + f.stream_id = make_stream_id(0xAA, 0x0001); + f.sequence_num = 42; + f.timestamp_valid = true; + f.avtp_timestamp = 0xCAFEBABE; + f.info = standard_request(/*bus_id=*/1, /*transaction_num=*/5); + f.payload = {0xFF}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeTscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE(out.use_tscf); + REQUIRE(out.timestamp_valid); + REQUIRE(out.avtp_timestamp == 0xCAFEBABE); + REQUIRE(out.info.byte_bus_id == 1); +} + +TEST_CASE("Frame round-trips an ACF_GBB message_timestamp", "[udp][REQ-UDP-003]") { + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x10, 0x2222); + f.sequence_num = 1; + f.info = standard_request(/*bus_id=*/2, /*transaction_num=*/1); + f.info.acf_msg_type = acf::kAcfMsgTypeGbb; + f.info.mtv = true; + f.message_timestamp = 0x1122334455667788ULL; + f.payload = {0xAB, 0xCD}; + + auto bytes = encode_frame(f); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE(out.info.acf_msg_type == acf::kAcfMsgTypeGbb); + REQUIRE(out.info.mtv); + REQUIRE(out.message_timestamp == 0x1122334455667788ULL); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("decode_frame rejects a buffer shorter than the AVTPDU header", "[udp][REQ-UDP-004]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + + Frame out; + REQUIRE(decode_frame(bytes.data(), avtp::kNtscfHeaderLen - 1, out)); +} + +TEST_CASE("decode_frame rejects a buffer shorter than the ACF common header", + "[udp][REQ-UDP-005]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + + Frame out; + REQUIRE(decode_frame(bytes.data(), avtp::kNtscfHeaderLen + 1, out)); +} + +TEST_CASE("decode_frame rejects an unrecognized ACF message type", "[udp][REQ-UDP-006]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + bytes[avtp::kNtscfHeaderLen] = 0x00; // neither kAcfMsgTypeAbb nor kAcfMsgTypeGbb + + Frame out; + REQUIRE(decode_frame(bytes.data(), bytes.size(), out)); +} + +// ── Server (real UDP sockets, loopback) ────────────────────────────────────── + +TEST_CASE("Server dispatches a decoded request through its handler and answers the sender", + "[udp][REQ-UDP-007]") { + Server server(make_stream_id(0x02, 1), "127.0.0.1", 0); + REQUIRE(server.ok()); + + server.set_handler([](size_t, const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + out_resp_payload = req_payload; + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 1), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto req = standard_request(/*bus_id=*/5, /*transaction_num=*/11); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + auto ec = client.request(ctx, req, {0x11, 0x22}, resp, resp_payload); + + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); + REQUIRE(resp.byte_bus_id == 5); + REQUIRE(resp.transaction_num == 11); + REQUIRE(resp_payload == std::vector{0x11, 0x22}); + + server.close(); + client.close(); +} + +TEST_CASE("Server answers Acknowledge by default when no handler is registered", + "[udp][REQ-UDP-008]") { + Server server(make_stream_id(0x02, 2), "127.0.0.1", 0); + REQUIRE(server.ok()); + + Client client(make_stream_id(0x03, 2), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto req = standard_request(/*bus_id=*/1, /*transaction_num=*/1); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + REQUIRE_FALSE(client.request(ctx, req, {}, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::Acknowledge); + + server.close(); + client.close(); +} + +TEST_CASE("Server assigns stable per-sender client ids", "[udp][REQ-UDP-009]") { + Server server(make_stream_id(0x02, 3), "127.0.0.1", 0); + REQUIRE(server.ok()); + + std::vector seen; + server.set_handler([&](size_t client, const acf::AcfMessageInfo& req, + const std::vector&, + acf::AcfMessageInfo& out_resp, std::vector&) { + seen.push_back(client); + out_resp = acf::make_response(req, acf::ResponseKind::Acknowledge); + return std::error_code{}; + }); + + Client client_a(make_stream_id(0x03, 3), "127.0.0.1", server.port()); + Client client_b(make_stream_id(0x04, 3), "127.0.0.1", server.port()); + REQUIRE(client_a.ok()); + REQUIRE(client_b.ok()); + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + + REQUIRE_FALSE(client_a.request(ctx, standard_request(1, 1), {}, resp, resp_payload)); + REQUIRE_FALSE(client_a.request(ctx, standard_request(1, 2), {}, resp, resp_payload)); + REQUIRE_FALSE(client_b.request(ctx, standard_request(1, 1), {}, resp, resp_payload)); + + REQUIRE(seen.size() == 3); + REQUIRE(seen[0] == seen[1]); // same sender address, same client id + REQUIRE(seen[0] != seen[2]); // different sender address, different id + + server.close(); + client_a.close(); + client_b.close(); +} + +// ── Client (real UDP sockets, loopback) ────────────────────────────────────── + +TEST_CASE("Client::request returns ErrTimeout when no response arrives", + "[udp][REQ-UDP-010]") { + BlackHole sink; // listens, but never replies + Client client(make_stream_id(0x03, 4), "127.0.0.1", sink.port()); + REQUIRE(client.ok()); + + auto req = standard_request(1, 1); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::milliseconds(50)); + auto ec = client.request(ctx, req, {}, resp, resp_payload); + REQUIRE(ec == ErrTimeout); + + client.close(); +} + +TEST_CASE("Client::request correlates concurrent requests by byte_bus_id/transaction_num", + "[udp][REQ-UDP-011]") { + Server server(make_stream_id(0x02, 5), "127.0.0.1", 0); + REQUIRE(server.ok()); + + server.set_handler([](size_t, const acf::AcfMessageInfo& req, + const std::vector&, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + out_resp_payload = {req.byte_bus_id, req.transaction_num}; + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 5), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + + acf::AcfMessageInfo resp1, resp2; + std::vector payload1, payload2; + REQUIRE_FALSE(client.request(ctx, standard_request(1, 1), {}, resp1, payload1)); + REQUIRE_FALSE(client.request(ctx, standard_request(2, 1), {}, resp2, payload2)); + + REQUIRE(payload1 == std::vector{1, 1}); + REQUIRE(payload2 == std::vector{2, 1}); + + server.close(); + client.close(); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +TEST_CASE("Server and Client close() are idempotent and requests after close fail", + "[udp][REQ-UDP-012]") { + Server server(make_stream_id(0x02, 6), "127.0.0.1", 0); + Client client(make_stream_id(0x03, 6), "127.0.0.1", server.port()); + REQUIRE(server.ok()); + REQUIRE(client.ok()); + + server.close(); + server.close(); // second call must not hang or crash + + REQUIRE_FALSE(client.close()); + REQUIRE_FALSE(client.close()); // second call must not hang or crash + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::background(); + REQUIRE(client.request(ctx, standard_request(1, 1), {}, resp, resp_payload) == ErrClosed); +} From 04bdf1474b64db09cbcb80cadfbb645247b30cdb Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:38:36 -0700 Subject: [PATCH 2/3] fix: guard test_udp.cpp's real-socket tests behind RCP_UDP_POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server/Client-based tests (REQ-UDP-007..012), including the BlackHole helper, only make sense on the POSIX build — on Windows both classes are the function_not_supported stub rcp/udp.hpp's own header comment documents. BlackHole was referenced outside its RCP_UDP_POSIX guard, breaking the Windows/MSVC build. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- tests/test_udp.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp index 033bd4a..3757ab4 100644 --- a/tests/test_udp.cpp +++ b/tests/test_udp.cpp @@ -168,6 +168,12 @@ TEST_CASE("decode_frame rejects an unrecognized ACF message type", "[udp][REQ-UD REQUIRE(decode_frame(bytes.data(), bytes.size(), out)); } +// Server/Client exercise real UDP sockets, which only exist on the POSIX +// build (RCP_UDP_POSIX, set by rcp/udp.hpp itself) — on Windows both classes +// are the function_not_supported stub rcp/udp.hpp's own header comment +// documents, so REQ-UDP-007..012 have nothing real to exercise there. +#if defined(RCP_UDP_POSIX) + // ── Server (real UDP sockets, loopback) ────────────────────────────────────── TEST_CASE("Server dispatches a decoded request through its handler and answers the sender", @@ -324,3 +330,5 @@ TEST_CASE("Server and Client close() are idempotent and requests after close fai auto ctx = Context::background(); REQUIRE(client.request(ctx, standard_request(1, 1), {}, resp, resp_payload) == ErrClosed); } + +#endif // RCP_UDP_POSIX From e33f8e4a0f063cbf15f7efaf8dd26017a690eaad Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:43:53 -0700 Subject: [PATCH 3/3] fix: guard test_udp.cpp's Frame codec tests behind RCP_UDP_POSIX too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encode_frame/decode_frame are also the Windows stub (return {} / always fail) per rcp/udp.hpp's own header comment, not just Server/Client — the first Frame round-trip test indexed into the stub's empty return vector and segfaulted under MSVC. Move the RCP_UDP_POSIX guard to wrap the entire test body, and add a small Windows-only placeholder TEST_CASE that exercises the function_not_supported stub surface directly, so the binary registers at least one real assertion there instead of Catch2 reporting "No tests ran" (a ctest failure) when the guard elides every other TEST_CASE. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- tests/test_udp.cpp | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp index 3757ab4..e639ba4 100644 --- a/tests/test_udp.cpp +++ b/tests/test_udp.cpp @@ -70,6 +70,12 @@ acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, uint8_t transaction } // namespace +// Every TEST_CASE below exercises the real POSIX implementation (Frame +// codec, Server, Client) — on Windows, rcp/udp.hpp's own header comment +// documents that all of these are the function_not_supported stub, so +// there is nothing real left for REQ-UDP-001..012 to exercise there. +#if defined(RCP_UDP_POSIX) + // ── Frame encode/decode (pure codec, no sockets) ───────────────────────────── TEST_CASE("Frame round-trips an NTSCF-framed ACF_ABB request", "[udp][REQ-UDP-001]") { @@ -168,12 +174,6 @@ TEST_CASE("decode_frame rejects an unrecognized ACF message type", "[udp][REQ-UD REQUIRE(decode_frame(bytes.data(), bytes.size(), out)); } -// Server/Client exercise real UDP sockets, which only exist on the POSIX -// build (RCP_UDP_POSIX, set by rcp/udp.hpp itself) — on Windows both classes -// are the function_not_supported stub rcp/udp.hpp's own header comment -// documents, so REQ-UDP-007..012 have nothing real to exercise there. -#if defined(RCP_UDP_POSIX) - // ── Server (real UDP sockets, loopback) ────────────────────────────────────── TEST_CASE("Server dispatches a decoded request through its handler and answers the sender", @@ -331,4 +331,24 @@ TEST_CASE("Server and Client close() are idempotent and requests after close fai REQUIRE(client.request(ctx, standard_request(1, 1), {}, resp, resp_payload) == ErrClosed); } +#else // !RCP_UDP_POSIX + +// On Windows rcp/udp.hpp has no real transport (see its own header comment) +// — this exercises the function_not_supported stub surface instead, so this +// binary still registers a real assertion rather than reporting "No tests +// ran" (which Catch2, and therefore ctest, treats as a failure). +TEST_CASE("Windows stub reports function_not_supported", "[udp]") { + Server server(make_stream_id(0x02, 1), "127.0.0.1", 0); + REQUIRE_FALSE(server.ok()); + + Client client(make_stream_id(0x03, 1), "127.0.0.1", 0); + REQUIRE_FALSE(client.ok()); + + auto ctx = Context::background(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = client.request(ctx, standard_request(1, 1), {}, resp, resp_payload); + REQUIRE(ec == std::make_error_code(std::errc::function_not_supported)); +} + #endif // RCP_UDP_POSIX