From 2588dfc0e339b830e0ad2fbba6e26c6fd0061597 Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:00:58 -0700 Subject: [PATCH] =?UTF-8?q?rewrite(phase5):=20udp.hpp=20=E2=80=94=20wire?= =?UTF-8?q?=20Server::Handler=20to=20Phase=204=20frame-level=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the v3.0.0 ground-up rewrite (cpp-RCP #129, ROADMAP.md Phase 17), branch rewrite/v3-from-c-rcp. Phase 5 (transport layer), Wave 1. ## The gap udp::Server::Handler was shaped to match mock::Server::dispatch()'s single-already-isolated-message contract, so Server::serve() decoded each datagram into individual ACF messages and called Handler once per message. A caller wiring that Handler straight to mock::Server:: dispatch() -- the obvious, natural thing to do -- never reached mock.hpp's Phase 4 batch D2 frame-level entry points (dispatch_frame()/ dispatch_frame_e2e()), silently losing Table 24 response suppression, conditional/cancellation-opcode routing, and E2E CRC verification/ fragment reassembly for every request that goes over UDP. Note: c-RCP's current src/udp.c/include/rcp/udp.h have been re-architected into a pure raw-AVTPDU transport (send/recv only) with no dispatch wiring at all -- that concern is deliberately deferred to a future layer per udp.h's own comment. This gap is purely internal to cpp-RCP's own design (where udp::Server bakes in decode+dispatch+ encode), not a delta versus a current c-RCP dispatch implementation. ## Fix Widened Server::Handler to a frame-level contract: using Handler = std::function& acf_frame, std::vector& out_responses)>; acf_frame is the raw ACF-region bytes -- exactly the shape mock:: Server::dispatch_frame()/dispatch_frame_e2e() accept. New FrameResponse type mirrors the response half of mock::FrameMemberResult without udp.hpp needing to #include mock.hpp. serve() now hands raw bytes straight through and treats FrameResponse::info.rsp == false as "no wire message for this member" (Table 24 suppression/queued/pending/ etc.), skipping it from the reply MultiFrame; if every member was suppressed, no reply datagram is sent at all (previously a reply was always sent). Chose widening Handler's own signature over keeping the old single-message shape as a parallel "compatibility path" -- two wiring surfaces would keep inviting the exact mistake this gap describes; one correct surface is safer. Both the real POSIX Server and the Windows stub Server were updated identically. ## Call sites tests/test_udp.cpp: every existing handler lambda ported to the new signature. include/rcp/tls.hpp needed no change (SecureServer::Handler is just `using Handler = udp::Server::Handler;`, follows automatically -- confirmed via rcp_tls ctest pass). ## Other findings No other behavioral deltas found versus c-RCP's current udp.c/udp.h -- its Annex J wrap/unwrap and control port already match cpp-RCP's existing encode_annexj_datagram/decode_annexj_datagram/ kAnnexJControlPort, left untouched. Documented (via a new test) that regmap::RequestStreamConfig::rx_resp_stream_index defaults to 1, not 0 (intentional "answer discovery before configuration" power-on default) -- the suppression test sets it explicitly to 0 to actually exercise suppression. ## Verification 4 new end-to-end tests wiring a real mock::Server through UDP: multi-member dispatch_frame, Table 24 suppression (client now genuinely times out instead of getting a default response), a Triggered conditional-opcode request (routed to Pending admission, not mistaken for Standard), and an E2E-CRC-protected request via dispatch_frame_e2e. include/rcp/udp.hpp: 747 -> 846 lines. tests/test_udp.cpp: 599 -> 923 lines, test_udp: 24 TEST_CASEs (151 assertions). Full tree rebuilds clean (0 errors, 0 warnings), 58/58 ctest suites pass (100%) -- independently re-verified from a fresh build, not just the porting agent's own report. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- include/rcp/udp.hpp | 167 +++++++++++++++---- tests/test_udp.cpp | 388 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 489 insertions(+), 66 deletions(-) diff --git a/include/rcp/udp.hpp b/include/rcp/udp.hpp index 5f26040..84b695f 100644 --- a/include/rcp/udp.hpp +++ b/include/rcp/udp.hpp @@ -156,6 +156,35 @@ inline std::error_code decode_annexj_datagram(const uint8_t* b, size_t len, return {}; } +// FrameResponse — one dispatched member's own outcome, exactly enough of it +// for Server::serve() (below) to decide whether to put a reply message on +// the wire for it, and what that message says. Mirrors the response half of +// rcp::mock::FrameMemberResult (rcp/mock.hpp) without this header needing to +// #include rcp/mock.hpp itself — this file's own header comment already +// establishes that the Server/Client here stay usable without ever +// depending on the in-process simulator; a caller wiring +// rcp::mock::Server::dispatch_frame()/dispatch_frame_e2e() to Handler below +// supplies a small glue lambda translating each mock::FrameMemberResult it +// gets back into this shape (see tests/test_udp.cpp). Declared outside the +// RCP_UDP_POSIX split below (it depends only on acf.hpp, included +// unconditionally) so both the real POSIX Server and the Windows stub +// Server share the exact same Handler contract. +// +// info.rsp == false (acf::AcfMessageInfo's own default) is a valid, +// EXPECTED outcome for a member Server::serve() must NOT put a reply +// message on the wire for at all — Table 24 response suppression +// (REQ-RMAP-048/049, rcp::mock::suppress_response_per_stream_cfg), a +// queued/pending/suspended/still-reassembling-a-fragment admission outcome, +// or any other "no wire response" case rcp::mock::DispatchErrc documents +// (rcp/mock.hpp). serve() below drops any entry with info.rsp == false from +// the outgoing MultiFrame instead of encoding it — exactly what a caller +// going through rcp::mock::Server::dispatch_frame()/_e2e() directly would +// do after inspecting .response.rsp itself. +struct FrameResponse { + acf::AcfMessageInfo info; + std::vector payload; +}; + #if defined(RCP_UDP_POSIX) // ── Frame ───────────────────────────────────────────────────────────────────── @@ -340,25 +369,62 @@ inline std::error_code decode_multi_frame(const uint8_t* b, size_t len, MultiFra // this file's header comment for the secondary-source provenance of that // number — unless a caller passes a different one), decodes each inbound // datagram as an Annex J envelope (4-byte encapsulation sequence number + -// MultiFrame AVTPDU), and dispatches every ACF request the datagram carries -// — one, the common case, or more than one packed back to back (extraction -// §12.9.1.1, issue cpp-RCP-04-fresh) — to a caller-supplied Handler -// individually, shaped to match rcp::mock::Server::dispatch's signature, -// then encodes every response into a single reply MultiFrame (same order as -// the requests arrived in), re-wraps it in a fresh encapsulation sequence -// number, and sends it back to the sender under the same header kind -// (NTSCF/TSCF) the request arrived under. Malformed datagrams (too short -// for even the encapsulation sequence number, short buffer, bad subtype, -// unrecognized ACF message type on the very first message) are dropped -// silently, the same "drop rather than partially process" choice -// rcp/discovery.hpp documents for its own decode path. +// MultiFrame AVTPDU), and hands the *raw* ACF-region bytes (the AVTPDU's +// payload immediately after its own NTSCF/TSCF header — one, the common +// case, or more than one ACF_ABB/ACF_GBB member packed back to back, +// extraction §12.9.1.1) to a caller-supplied frame-level Handler exactly +// once per datagram, then encodes every non-suppressed FrameResponse it +// gets back into a single reply MultiFrame (frame order preserved), +// re-wraps it in a fresh encapsulation sequence number, and sends it back +// to the sender under the same header kind (NTSCF/TSCF) the request +// arrived under — UNLESS every member's own response was suppressed (Table +// 24) or the handler produced nothing at all, in which case no reply +// datagram is sent, matching what "no response is to be sent" actually +// means on the wire. Malformed datagrams (too short for even the +// encapsulation sequence number, short buffer, bad subtype, unrecognized +// ACF message type on the very first message) are dropped silently, the +// same "drop rather than partially process" choice rcp/discovery.hpp +// documents for its own decode path. +// +// Handler is deliberately FRAME-level, not dispatch()'s own single-message +// shape (issue cpp-RCP-udp-01, cpp-RCP issue #129 Phase 5 wave 1): an +// earlier revision of this Handler matched rcp::mock::Server::dispatch's +// single already-isolated-member contract directly (mock.hpp:1006), which +// meant a caller wiring this Handler straight to mock::Server::dispatch — +// the obvious, natural thing to do — silently lost Table 24 response +// suppression, conditional/cancellation-opcode routing (peek_conditional_ +// request_type, mock.hpp:1763), and E2E/fragmentation handling for every +// request that arrived over UDP: dispatch()/dispatch_e2e()/ +// dispatch_e2e_fragment() only ever see ONE already-isolated member, so +// none of mock.hpp's own Phase 4 batch D2 frame-level machinery +// (dispatch_frame()/dispatch_frame_e2e(), mock.hpp:1536/1587, which apply +// Table 24 suppression via decode_and_dispatch()'s own admit_and_ +// classify()/suppress_response_per_stream_cfg() calls internally) was ever +// reached. Handler's signature now matches dispatch_frame()'s/ +// dispatch_frame_e2e()'s own shared (client, stream_id, frame) shape (plus +// the AVTPDU's own Sequence_Nr, needed only by dispatch_frame_e2e()'s own +// once-per-frame REQ-E2E-028/029 gate) instead, so a caller can wire either +// one directly (via a thin glue lambda translating +// std::vector to std::vector — see +// tests/test_udp.cpp) and get the exact same behavior dispatching against +// mock::Server directly would. class Server { public: - using Handler = std::function& req_payload, - acf::AcfMessageInfo& out_resp, - std::vector& out_resp_payload)>; + // sequence_num is the enclosing AVTPDU's own Sequence_Nr (avtp:: + // NtscfHeader::sequence_num/avtp::TscfHeader::sequence_num, 8-bit + // rolling counter) — required by rcp::mock::Server::dispatch_frame_e2e()'s + // own frame-level sequence gate (mock.hpp:1572-1586) even though a + // caller wiring dispatch_frame() instead (no E2E) has no use for it. + // Returns the number of FrameResponse entries appended to + // out_responses (out_responses is NOT cleared first, mirroring + // rcp::mock::Server::dispatch_frame()'s own "size_t dispatched count, + // caller passes an empty vector" contract — every out_responses this + // file's own Handler is ever invoked with already starts empty, see + // serve() below). + using Handler = std::function& acf_frame, + std::vector& out_responses)>; Server(avtp::StreamId stream_id, const char* addr, uint16_t port = kAnnexJControlPort) : stream_id_(stream_id), fd_(-1) { @@ -484,8 +550,20 @@ class Server { in_encap_seq, avtpdu, avtpdu_len)) continue; + // decode_multi_frame validates the NTSCF/TSCF header (subtype, + // control_data_length) and, for the no-handler-registered + // fallback below, decodes its member list — but Handler above + // wants the RAW ACF-region bytes, not the decoded messages, so + // a frame-level dispatcher (rcp::mock::Server::dispatch_frame()/ + // dispatch_frame_e2e()) can apply its own splitting/suppression/ + // conditional-opcode/E2E logic against the unmodified wire + // bytes, exactly as it would for any other caller of those + // entry points. MultiFrame req; if (decode_multi_frame(avtpdu, avtpdu_len, req)) continue; + const size_t acf_off = req.use_tscf ? avtp::kTscfHeaderLen : avtp::kNtscfHeaderLen; + const std::vector acf_frame(avtpdu + acf_off, avtpdu + avtpdu_len); + const uint8_t sequence_num = static_cast(req.sequence_num); MultiFrame resp; resp.use_tscf = req.use_tscf; @@ -493,28 +571,47 @@ class Server { resp.sequence_num = static_cast(++seq_); resp.timestamp_valid = req.timestamp_valid; resp.avtp_timestamp = req.avtp_timestamp; - resp.messages.reserve(req.messages.size()); + + std::vector results; { std::lock_guard lk(mu_); size_t client = client_id_for(from); last_recv_encap_seq_[client] = in_encap_seq; - // §12.9.1.1: "check each of them individually if to be - // processed or not" — each request in the datagram is - // dispatched and answered on its own, not as a batch. - for (const auto& m : req.messages) { - acf::AcfEntry out_entry; - if (handler_) { - auto ec = handler_(client, m.info, m.payload, out_entry.info, out_entry.payload); - (void)ec; // Handler always populates out_entry.info even on - // failure, same contract rcp::mock::Server::dispatch - // documents. - } else { - out_entry.info = acf::make_response(m.info, acf::ResponseKind::Acknowledge); + if (handler_) { + handler_(client, req.stream_id, sequence_num, acf_frame, results); + } else { + // No handler registered: acknowledge every member + // individually — this class's own pre-existing default, + // now built from req.messages (decode_multi_frame's own + // already-decoded member list) rather than raw bytes, + // since there is no dispatch logic here to hand raw + // bytes to. + results.reserve(req.messages.size()); + for (const auto& m : req.messages) { + FrameResponse r; + r.info = acf::make_response(m.info, acf::ResponseKind::Acknowledge); + results.push_back(std::move(r)); } - resp.messages.push_back(std::move(out_entry)); } } + // §12.9.1.1: each request in the datagram was checked and + // dispatched individually — now assemble the reply MultiFrame + // from whichever members actually produced a genuine wire + // response (FrameResponse::info.rsp == true; see that struct's + // own doc comment for why info.rsp == false is a valid, + // non-error "do not reply to this one" outcome, e.g. Table 24 + // suppression). + resp.messages.reserve(results.size()); + for (auto& r : results) { + if (!r.info.rsp) continue; + acf::AcfEntry entry; + entry.info = std::move(r.info); + entry.payload = std::move(r.payload); + resp.messages.push_back(std::move(entry)); + } + if (resp.messages.empty()) continue; // every member suppressed / nothing to send back + auto out_frame = encode_multi_frame(resp); auto out_wire = encode_annexj_datagram(++encap_seq_, out_frame); ::sendto(fd_, out_wire.data(), out_wire.size(), 0, @@ -713,9 +810,11 @@ inline std::error_code decode_frame(const uint8_t*, size_t, Frame&) { class Server { public: - using Handler = std::function&, - acf::AcfMessageInfo&, std::vector&)>; + // Same shape as the real POSIX Server::Handler above — see that one's + // own doc comment for why it is frame-level, not single-message. + using Handler = std::function&, + std::vector&)>; Server(avtp::StreamId, const char*, uint16_t = kAnnexJControlPort) {} std::string addr_string() const { return {}; } diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp index 474fc16..66e8257 100644 --- a/tests/test_udp.cpp +++ b/tests/test_udp.cpp @@ -17,8 +17,22 @@ // (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. +// +// This file also covers cpp-RCP issue #129 Phase 5 wave 1's own +// dispatch-wiring fix: udp::Server::Handler is now FRAME-level (raw +// ACF-region bytes in, std::vector out — matching +// rcp::mock::Server::dispatch_frame()/dispatch_frame_e2e()'s own shared +// shape) rather than the old single-already-isolated-message shape that +// matched rcp::mock::Server::dispatch() alone. The "Server wired to +// mock::Server::..." tests below (search that string) are the NEW coverage +// this fix adds — proving a multi-member, Table-24-suppressed, conditional- +// opcode, or E2E-CRC-protected request arriving over real UDP now gets +// EXACTLY the behavior dispatching it directly against rcp::mock::Server +// would, not a silently downgraded default. Every other TEST_CASE below is +// pre-existing coverage, ported onto the new frame-level Handler shape. #include +#include #include #include @@ -26,6 +40,7 @@ using namespace rcp; using namespace rcp::udp; +using rcp::endpoint::WriteSemantics; namespace { @@ -71,6 +86,51 @@ acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, uint8_t transaction return acf::make_standard_request(bus_id, transaction_num, write, read_size); } +#if defined(RCP_UDP_POSIX) +// make_echo_handler builds a udp::Server::Handler that decodes every ACF +// member out of the raw frame bytes Server::serve() now hands it +// (acf::decode_acf_messages — this file's own helper tests use, not any +// mock.hpp dispatch machinery) and calls `build` once per member to +// produce its FrameResponse — the generic "decode, respond per member" +// shape several of the pre-existing tests below reuse instead of the old +// single-message Handler contract udp::Server::Handler used to have. +template +Server::Handler make_echo_handler(BuildFn build) { + return [build](size_t /*client*/, avtp::StreamId /*stream_id*/, uint8_t /*sequence_num*/, + const std::vector& frame, std::vector& out) -> size_t { + std::vector members; + if (acf::decode_acf_messages(frame.data(), frame.size(), members)) return 0; + out.reserve(members.size()); + for (auto& m : members) out.push_back(build(m.info, m.payload)); + return out.size(); + }; +} + +// forward_to_mock builds a udp::Server::Handler that hands `frame` straight +// to `dispatch` (typically a lambda closing over an rcp::mock::Server and +// calling its own dispatch_frame()/dispatch_frame_e2e()) and translates +// each rcp::mock::FrameMemberResult it gets back into a udp::FrameResponse +// — exactly the "small glue lambda" this file's own udp.hpp header comment +// (Server::Handler's own doc comment) describes as the intended way to +// wire an in-process rcp::mock::Server as this transport's handler. +template +Server::Handler forward_to_mock(DispatchFn dispatch) { + return [dispatch](size_t client, avtp::StreamId stream_id, uint8_t sequence_num, + const std::vector& frame, std::vector& out) -> size_t { + std::vector results; + size_t n = dispatch(client, stream_id, sequence_num, frame, results); + out.reserve(results.size()); + for (auto& r : results) { + FrameResponse fr; + fr.info = std::move(r.response); + fr.payload = std::move(r.response_payload); + out.push_back(std::move(fr)); + } + return n; + }; +} +#endif + } // namespace // Every TEST_CASE below exercises the real POSIX implementation (Frame @@ -291,13 +351,12 @@ TEST_CASE("Server dispatches a decoded request through its handler and answers t 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{}; - }); + server.set_handler(make_echo_handler([](const acf::AcfMessageInfo& req, const std::vector& payload) { + FrameResponse r; + r.payload = payload; + r.info = acf::make_response(req, acf::ResponseKind::ReadResponse); + return r; + })); Client client(make_stream_id(0x03, 1), "127.0.0.1", server.port()); REQUIRE(client.ok()); @@ -328,14 +387,13 @@ TEST_CASE("Server handles multiple requests packed into a single datagram indivi REQUIRE(server.ok()); std::vector seen_bus_ids; - server.set_handler([&](size_t, const acf::AcfMessageInfo& req, - const std::vector& req_payload, - acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + server.set_handler(make_echo_handler([&](const acf::AcfMessageInfo& req, const std::vector& payload) { seen_bus_ids.push_back(req.byte_bus_id); - out_resp_payload = req_payload; - out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); - return std::error_code{}; - }); + FrameResponse r; + r.payload = payload; + r.info = acf::make_response(req, acf::ResponseKind::ReadResponse); + return r; + })); MultiFrame req_frame; req_frame.use_tscf = false; @@ -419,12 +477,17 @@ TEST_CASE("Server assigns stable per-sender client ids", "[udp][REQ-UDP-009]") { 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&) { + server.set_handler([&](size_t client, avtp::StreamId /*stream_id*/, uint8_t /*sequence_num*/, + const std::vector& frame, std::vector& out) -> size_t { seen.push_back(client); - out_resp = acf::make_response(req, acf::ResponseKind::Acknowledge); - return std::error_code{}; + std::vector members; + if (acf::decode_acf_messages(frame.data(), frame.size(), members)) return 0; + for (auto& m : members) { + FrameResponse r; + r.info = acf::make_response(m.info, acf::ResponseKind::Acknowledge); + out.push_back(std::move(r)); + } + return out.size(); }); Client client_a(make_stream_id(0x03, 3), "127.0.0.1", server.port()); @@ -472,17 +535,16 @@ TEST_CASE("Client::request correlates concurrent requests by byte_bus_id/transac 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); + server.set_handler(make_echo_handler([](const acf::AcfMessageInfo& req, const std::vector&) { + FrameResponse r; + r.info = acf::make_response(req, acf::ResponseKind::ReadResponse); // avtp::ByteBusId widened to uint16_t (rcp/avtp.hpp v2.19.0 wire // conformance pass, issue cpp-RCP-04, since byte_bus_id is an // 11-bit wire field) — narrow explicitly for this single-byte test // payload rather than relying on an implicit narrowing conversion. - out_resp_payload = {static_cast(req.byte_bus_id), req.transaction_num}; - return std::error_code{}; - }); + r.payload = {static_cast(req.byte_bus_id), req.transaction_num}; + return r; + })); Client client(make_stream_id(0x03, 5), "127.0.0.1", server.port()); REQUIRE(client.ok()); @@ -509,12 +571,11 @@ TEST_CASE("Client's Annex J encapsulation sequence number increments monotonical Server server(make_stream_id(0x02, 9), "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 = acf::make_response(req, acf::ResponseKind::Acknowledge); - return std::error_code{}; - }); + server.set_handler(make_echo_handler([](const acf::AcfMessageInfo& req, const std::vector&) { + FrameResponse r; + r.info = acf::make_response(req, acf::ResponseKind::Acknowledge); + return r; + })); Client client(make_stream_id(0x03, 9), "127.0.0.1", server.port()); REQUIRE(client.ok()); @@ -576,6 +637,269 @@ 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); } +// ── Server wired to a real rcp::mock::Server (cpp-RCP issue #129, Phase 5 ── +// wave 1: udp.hpp/mock.hpp dispatch-wiring fix) ───────────────────────── +// Server::Handler used to be shaped to match rcp::mock::Server::dispatch's +// own single-already-isolated-message contract directly — a caller wiring +// it straight to mock::Server::dispatch, the obvious, natural thing to do, +// silently lost Table 24 response suppression, conditional/cancellation- +// opcode routing, and E2E/fragmentation handling for every request that +// arrived over UDP, because dispatch() never sees a whole frame, only one +// already-isolated member. Handler is now frame-level (see udp.hpp's own +// Server::Handler doc comment) and these four tests prove the fix: each +// one drives a REAL rcp::mock::Server, wired via forward_to_mock() above +// (the exact "small glue lambda" pattern Handler's own doc comment +// describes), over a REAL UDP round trip, and checks for the SAME behavior +// dispatching directly against that mock::Server would give — not a +// downgraded default. + +TEST_CASE("Server wired to mock::Server::dispatch_frame dispatches a multi-member datagram " + "exactly as calling dispatch_frame directly would", + "[udp][mock][REQ-UDP-007][REQ-MOCK-019]") { + mock::Server sim; + gpio::PinMask ignored = 0; + (void)sim.gpio().handle_write(WriteSemantics::Reconfigure, 0xFFFF'FFFFu, ignored); + REQUIRE_FALSE(sim.advance_to_rcp_configured()); + + auto stream_id = make_stream_id(0x02, 0x1010); + Server server(stream_id, "127.0.0.1", 0); + REQUIRE(server.ok()); + server.set_handler(forward_to_mock([&](size_t client, avtp::StreamId sid, uint8_t /*sequence_num*/, + const std::vector& frame, + std::vector& results) { + return sim.dispatch_frame(client, sid, frame, results); + })); + + MultiFrame req_frame; + req_frame.use_tscf = false; + req_frame.stream_id = stream_id; + req_frame.sequence_num = 1; + + acf::AcfEntry write_req; + write_req.info = standard_request(mock::kGpioByteBusId, /*transaction_num=*/1, /*write=*/true); + write_req.info.evt_op = static_cast(WriteSemantics::Or); + write_req.payload = gpio::encode_gpio_payload(0x0000'000F); + + acf::AcfEntry read_req; + read_req.info = standard_request(mock::kGpioByteBusId, /*transaction_num=*/2, /*write=*/false); + + req_frame.messages = {write_req, read_req}; + + auto req_bytes = encode_annexj_datagram(/*encap_seq=*/1, encode_multi_frame(req_frame)); + + int raw_fd = ::socket(AF_INET, SOCK_DGRAM, 0); + REQUIRE(raw_fd >= 0); + sockaddr_in server_addr{}; + server_addr.sin_family = AF_INET; + server_addr.sin_port = htons(server.port()); + server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + auto sent = ::sendto(raw_fd, req_bytes.data(), req_bytes.size(), 0, + reinterpret_cast(&server_addr), sizeof(server_addr)); + REQUIRE(sent == static_cast(req_bytes.size())); + + std::vector recv_buf(kMaxDatagram); + ssize_t n = ::recv(raw_fd, recv_buf.data(), recv_buf.size(), 0); + REQUIRE(n > 0); + ::close(raw_fd); + + uint32_t resp_seq = 0; + const uint8_t* resp_avtpdu = nullptr; + size_t resp_len = 0; + REQUIRE_FALSE(decode_annexj_datagram(recv_buf.data(), static_cast(n), resp_seq, resp_avtpdu, resp_len)); + + MultiFrame resp; + REQUIRE_FALSE(decode_multi_frame(resp_avtpdu, resp_len, resp)); + REQUIRE(resp.messages.size() == 2); + REQUIRE(acf::response_kind_of(resp.messages[0].info) == acf::ResponseKind::WriteResponse); + REQUIRE(acf::response_kind_of(resp.messages[1].info) == acf::ResponseKind::ReadResponse); + + gpio::PinMask read_back = 0; + REQUIRE_FALSE(gpio::decode_gpio_payload(resp.messages[1].payload.data(), resp.messages[1].payload.size(), + read_back)); + REQUIRE(read_back == 0x0000'000F); + REQUIRE(sim.gpio().read() == 0x0000'000F); // the second member's own side effect actually landed + + server.close(); +} + +TEST_CASE("Server wired to mock::Server::dispatch_frame honors Table 24 response suppression " + "end-to-end over UDP — a request stream configured with no ack/response routing " + "produces NO reply datagram at all, not a downgraded default response", + "[udp][mock][REQ-UDP-007][REQ-RMAP-048][REQ-RMAP-049]") { + mock::Server sim; + REQUIRE_FALSE(sim.advance_to_rcp_configured()); + + auto client_stream_id = make_stream_id(0x05, 0x4242); + regmap::RequestStreamConfig cfg; + cfg.stream_id = client_stream_id; + cfg.rx_ack_stream_index = 0; // REQ-RMAP-048: struct default already 0 ("no acknowledge is to be sent") + cfg.rx_resp_stream_index = 0; // REQ-RMAP-049: struct DEFAULT is 1 (regmap.hpp's own "a freshly + // reset server can answer discovery before any config is written" + // power-on rationale) — must be set to 0 explicitly here to actually + // exercise Table 24's "no response is to be sent" encoding, applied by + // mock::Server's own suppress_response_per_stream_cfg() (mock.hpp) + // inside dispatch_frame(). + REQUIRE(sim.set_request_stream_cfg({cfg})); + + Server server(make_stream_id(0x02, 1), "127.0.0.1", 0); + REQUIRE(server.ok()); + server.set_handler(forward_to_mock([&](size_t client, avtp::StreamId sid, uint8_t /*sequence_num*/, + const std::vector& frame, + std::vector& results) { + return sim.dispatch_frame(client, sid, frame, results); + })); + + Client client(client_stream_id, "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::milliseconds(200)); + auto ec = client.request(ctx, standard_request(mock::kGpioByteBusId, 1, /*write=*/false), {}, resp, resp_payload); + + // A genuine, successful GPIO ReadResponse was built and then suppressed + // at the source (mock::Server's own Table 24 logic) — the client + // genuinely gets nothing back, not a default Acknowledge or any other + // stand-in response. + REQUIRE(ec == ErrTimeout); + + server.close(); + client.close(); +} + +TEST_CASE("Server wired to mock::Server::dispatch_frame routes a Triggered conditional-opcode " + "request to Pending admission instead of silently treating it as a Standard request", + "[udp][mock][REQ-UDP-007][REQ-SRV-016]") { + mock::Server sim; + REQUIRE_FALSE(sim.advance_to_rcp_configured()); + + auto stream_id = make_stream_id(0x02, 0x7777); + Server server(stream_id, "127.0.0.1", 0); + REQUIRE(server.ok()); + server.set_handler(forward_to_mock([&](size_t client, avtp::StreamId sid, uint8_t /*sequence_num*/, + const std::vector& frame, + std::vector& results) { + return sim.dispatch_frame(client, sid, frame, results); + })); + + Client client(stream_id, "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + request::TriggeredStep step; + step.trigger_source_ep = 1; + step.trigger_signal_nr = 0; + step.trigger_threshold = 0; + + acf::AcfMessageInfo trig_info; + trig_info.acf_msg_type = acf::kAcfMsgTypeGbb; + trig_info.byte_bus_id = mock::kGpioByteBusId; + trig_info.transaction_num = 3; + trig_info.evt_ack = true; // request an Acknowledge so this is observable over the wire + // mtv left at its own default (false) — a conditional-opcode GBB + // message's message_timestamp field is repurposed to carry the opcode/ + // params instead of a real timestamp (mock::Server's own + // peek_conditional_request_type()); mtv must stay clear for that peek + // to recognize this as one at all. + + const uint64_t ts = request::encode_request_type(request::RequestTypeOpcode::Triggered, + request::encode_triggered_step_params(step)); + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + auto ec = client.request(ctx, trig_info, {}, resp, resp_payload, /*use_tscf=*/false, + /*avtp_timestamp=*/0, /*message_timestamp=*/ts); + + REQUIRE_FALSE(ec); + // A Pending admission's own Acknowledge shape (REQ-SRV-016) — NOT a + // WriteResponse/ReadResponse, which would mean the raw opcode/param + // bytes were misread as an ordinary Standard request's own + // message_timestamp and dispatched straight to GpioEndpoint instead of + // being stored as a conditional request. + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::Acknowledge); + REQUIRE(sim.pending_count(mock::kGpioByteBusId) == 1); + + server.close(); + client.close(); +} + +TEST_CASE("Server wired to mock::Server::dispatch_frame_e2e validates a genuine E2E CRC and " + "dispatches the unwrapped request over UDP exactly as dispatch_frame_e2e() would directly", + "[udp][mock][REQ-UDP-007][REQ-E2E-021]") { + mock::Server sim; + gpio::PinMask ignored = 0; + (void)sim.gpio().handle_write(WriteSemantics::Reconfigure, 0xFFFF'FFFFu, ignored); + REQUIRE_FALSE(sim.advance_to_rcp_configured()); + sim.registers().generic_configs[mock::kGpioEndpointId - 1].ep_req_crc_enable = true; + + const auto stream_id = make_stream_id(0x02, 0x9999); + regmap::RequestStreamConfig cfg; + cfg.stream_id = stream_id; + cfg.rx_enforce_e2e = true; + REQUIRE(sim.set_request_stream_cfg({cfg})); + + Server server(make_stream_id(0x03, 1), "127.0.0.1", 0); + REQUIRE(server.ok()); + server.set_handler(forward_to_mock([&](size_t client, avtp::StreamId sid, uint8_t sequence_num, + const std::vector& frame, + std::vector& results) { + return sim.dispatch_frame_e2e(client, sid, sequence_num, frame, results); + })); + + acf::AcfMessageInfo gpio_info; + gpio_info.byte_bus_id = mock::kGpioByteBusId; + gpio_info.transaction_num = 7; + gpio_info.op = true; + gpio_info.evt_op = static_cast(WriteSemantics::Or); + auto payload = gpio::encode_gpio_payload(0x0000'00F0); + // e2e::wrap_framed() (rcp/e2e.hpp) builds the real, CRC32-protected ACF + // region bytes — Client::request() has no CRC-wrapping option of its + // own (this is transport-independent wire content mock::Server's own + // E2E layer produces/consumes, not something udp.hpp's Frame codec + // knows about), so this frame is assembled and sent over a raw socket + // instead, the same pattern the multi-member test above already uses. + auto wrapped = e2e::wrap_framed(/*is_ntscf_framed=*/true, /*header_octet1=*/0x00, /*tu=*/false, stream_id, + /*avtp_timestamp=*/std::nullopt, gpio_info, + /*message_timestamp=*/std::nullopt, payload); + + avtp::NtscfHeader hdr; + hdr.stream_id = stream_id; + hdr.sequence_num = 0; + hdr.control_data_length = static_cast(wrapped.size()); + auto avtpdu = avtp::encode_ntscf_header(hdr); + avtpdu.insert(avtpdu.end(), wrapped.begin(), wrapped.end()); + + auto req_bytes = encode_annexj_datagram(/*encap_seq=*/1, avtpdu); + + int raw_fd = ::socket(AF_INET, SOCK_DGRAM, 0); + REQUIRE(raw_fd >= 0); + sockaddr_in server_addr{}; + server_addr.sin_family = AF_INET; + server_addr.sin_port = htons(server.port()); + server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + auto sent = ::sendto(raw_fd, req_bytes.data(), req_bytes.size(), 0, + reinterpret_cast(&server_addr), sizeof(server_addr)); + REQUIRE(sent == static_cast(req_bytes.size())); + + std::vector recv_buf(kMaxDatagram); + ssize_t n = ::recv(raw_fd, recv_buf.data(), recv_buf.size(), 0); + REQUIRE(n > 0); + ::close(raw_fd); + + uint32_t resp_seq = 0; + const uint8_t* resp_avtpdu = nullptr; + size_t resp_len = 0; + REQUIRE_FALSE(decode_annexj_datagram(recv_buf.data(), static_cast(n), resp_seq, resp_avtpdu, resp_len)); + + MultiFrame resp; + REQUIRE_FALSE(decode_multi_frame(resp_avtpdu, resp_len, resp)); + REQUIRE(resp.messages.size() == 1); + REQUIRE(acf::response_kind_of(resp.messages[0].info) == acf::ResponseKind::WriteResponse); + REQUIRE(sim.gpio().read() == 0x0000'00F0); // the CRC-unwrapped request's own side effect landed + + server.close(); +} + #else // !RCP_UDP_POSIX // On Windows rcp/udp.hpp has no real transport (see its own header comment)