From a73e609fcfa62e779ac96afa4cca0939c1da8e5b Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:26:41 -0700 Subject: [PATCH] =?UTF-8?q?rewrite(phase4):=20mock.hpp=20batch=20D2=20?= =?UTF-8?q?=E2=80=94=20AVTPDU=20frame-level=20dispatch,=20closes=20out=20P?= =?UTF-8?q?hase=204?= 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. Second of two sub-batches (D1/D2) making up Phase 4's fourth and FINAL batch. This completes mock.hpp -- the last module of Phase 4 -- and closes out the server/dispatch layer in full. Built on batches A (admission), B (response suppression + regmap/discovery), C (single-member E2E dispatch), and D1 (E2E fragmented dispatch). ## Ported (c-RCP src/mock.c/src/scheduler.c/src/request.c, REQ-MOCK-019/ 020/029, REQ-E2E-033, REQ-CANCEL-012) - split_frame_members() -- ported from rcp_sched_split_frame_members() (scheduler.c:63-106), a new namespace-scope helper (request.hpp's own header explicitly notes this splitting logic lives nowhere else in the rewrite -- c-RCP keeps it mock.c-local too). - FrameMemberResult + Server::dispatch_frame()/dispatch_frame_e2e() -- ported from rcp_mock_server_dispatch_frame()/_dispatch_frame_e2e() (mock.c:3068-3382). Each returns per-member results (mirrors c-RCP's own result-array shape -- c-RCP does not aggregate members' responses into one outgoing wire frame either, confirmed by source read). - Chain-group/position bookkeeping (REQ-CANCEL-012, ported from rcp_chained_advance/is_chained_member/last_pending_index, mock.c:2950-3231, request.c:673-694) -- via a new shared dispatch_frame_impl() template, using request::should_execute_chained() for the cs-polarity predicate and a new last_pending_index() helper. - apply_cancellation()'s ClearSingle TODO (open since batch A) filled in: decodes request::decode_clear_single() from the raw frame, captures the target's chain_group/chain_position before removing it, cascades via cancel_chain_from(). ## Bug found and fixed (beyond the literal ask) admit_and_classify() always re-encoded ABB from req/payload, silently stripping any genuine GBB conditional/cancellation opcode's semantics -- meaning ClearSingle/Chained frames were never actually reachable before this batch, regardless of how they'd been decoded. Refactored to take the raw frame directly (mechanical edit across its 10 call sites, zero behavior change for them) and added decode_and_dispatch()'s own conditional-opcode-aware routing branch, so dispatch_frame()/dispatch_frame_e2e() -- and every existing dispatch_e2e()/dispatch_e2e_fragment() caller -- can now genuinely admit Chained/Compound/Triggered/Timed/Cancellation GBB members. Also extracted dispatch_e2e_core()/dispatch_e2e_fragment_core() with an apply_seq_gate flag so the frame-level sequence gate runs exactly once per frame (matching the pre-existing doc comment's own stated intent), not once per member. ## Delta from c-RCP (documented in-file) dispatch_frame_e2e() routes every member through dispatch_e2e_fragment_core() rather than plain dispatch_e2e_core() -- c-RCP has no frame-level fragment-aware dispatch at all; this is a safe superset since the fragment-core function falls back identically for non-fragmented members. ## Test-writing findings (documented, not library bugs) split_frame_members() requires each member's declared acf_msg_length*4 to exactly match its real encoded byte count, but acf::encode_acf_abb()/e2e::wrap_framed() never auto-pad payloads to quadlet boundaries (documented as caller-owned in acf.hpp) -- never mattered for single-member dispatch before; all new multi-member test frames use quadlet-aligned payloads with an explanatory comment. Confirmed DispatchErrc::unknown_bus is unreachable in this codebase (acf.hpp's decode is deliberately lenient vs c-RCP's strict decode) -- documented as defensive-only with a test pinning the actual lenient behavior. ## Final Phase-4 sweep Grepped the whole file for TODO(phase4-batch-*) markers: only apply_cancellation()'s ClearSingle stub existed, now resolved. No dangling phase-4 markers remain anywhere in mock.hpp. ## Verification New split_frame_members()/dispatch_frame()/dispatch_frame_e2e() + ClearSingle cascade, tested end-to-end via multi-member AVTPDU frames. include/rcp/mock.hpp: 2723 -> 3413 lines. tests/test_mock.cpp: 2349 -> 2775 lines, test_mock: 101 TEST_CASEs (863 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. ## Phase 4 complete mock.hpp grew from its pre-Phase-4 baseline of 917 lines to 3413 lines across four batches: A added server::Endpoint admission wiring, B added Table 24 response suppression plus regmap/discovery storage, C wired RxSequenceGuard/StreamFaultTracker/RxWatchdog into single-member E2E dispatch (closing a gap flagged since PR #134), D1 wired fragment.hpp/respqueue.hpp for E2E fragmented dispatch (closing a gap open since Phase 1), and this D2 batch added AVTPDU frame-level multi-member splitting/routing/chain-cascade cancellation. Combined with regmap (batches A/B), the new server.hpp, discovery, and adapt -- all merged earlier in Phase 4 -- the server/dispatch layer is now complete with no phase-4-scoped work left deferred. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- include/rcp/mock.hpp | 786 ++++++++++++++++++++++++++++++++++++++++--- tests/test_mock.cpp | 426 +++++++++++++++++++++++ 2 files changed, 1164 insertions(+), 48 deletions(-) diff --git a/include/rcp/mock.hpp b/include/rcp/mock.hpp index 4395e7f..4372a50 100644 --- a/include/rcp/mock.hpp +++ b/include/rcp/mock.hpp @@ -317,6 +317,38 @@ enum class DispatchErrc : int { // synchronously. Same "out_resp left zeroed" contract as every other non-wire // outcome above — the real multi-frame response is sitting in that queue, not in // out_resp/out_resp_payload; a caller drains it via RespQueue::pop(). + + // Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): dispatch_frame()'s/ + // dispatch_frame_e2e()'s own three additional outcomes — every one of + // them meaningful ONLY at frame-splitting granularity (a single-member + // dispatch()/dispatch_e2e()/dispatch_e2e_fragment() call never returns + // any of these), ported from c-RCP's own RCP_MOCK_DISPATCH_ERR_UNKNOWN_ + // BUS/_CHAIN_ERROR/_CHAIN_ABORTED (include/rcp/mock.h:928/946/950). + unknown_bus = 10, // a member did not decode as a well-formed ACF_ABB/ACF_GBB message at all (an + // unrecognized acf_msg_type, or a declared length/pad inconsistent with the member's + // own bytes) — mirrors RCP_MOCK_DISPATCH_ERR_UNKNOWN_BUS. Same "out_resp left + // zeroed" contract as queued/pending/etc. above: nothing was routed or run. + // DEFENSIVE-ONLY in this codebase today: split_frame_members() (this file's own + // namespace-scope helper) already validates a member's own acf_msg_length against + // the buffer before dispatch_frame_impl() ever calls acf::decode_acf_abb()/_gbb() + // on it, and that pair's own decode is deliberately LENIENT (acf.hpp's own + // TODO(phase1-followup) note) — unlike c-RCP's stricter rcp_acf_decode_abb()/ + // _decode_gbb(), it cannot actually fail for anything split_frame_members() has + // already accepted. Kept for classifier completeness (mirrors admit_and_classify()'s + // own Pending/Cancellation branches, "a real, correct outcome even when not exercised + // by this file's own current call sites") and in case a future, stricter decode makes + // it reachable — see tests/test_mock.cpp's own dedicated test pinning this exact, + // disclaimed delta from c-RCP. + chain_error = 11, // REQ-CANCEL-012 (TC18 §11.2.3): a Chained member is the very first member of + // its own frame, so it has no predecessor to chain to at all — it, and this batch's + // own chain_aborted latch means every member after it too, is ignored. Builds a + // genuine WireErrorCode::ChainError ErrorResponse addressed to this member's own + // byte_bus_id/transaction_num, mirroring RCP_MOCK_DISPATCH_CHAIN_ERROR. + chain_aborted = 12, // REQ-CANCEL-012: a Chained member's predecessor (within this same frame) + // finished in error and this member's own cs bit selected abort-on-error + // (request::should_execute_chained(), request.hpp), OR an earlier member in this + // frame already aborted the chain. Builds a genuine WireErrorCode::ChainAborted + // ErrorResponse, mirroring RCP_MOCK_DISPATCH_CHAIN_ABORTED. }; inline const std::error_category& dispatch_category() noexcept { @@ -333,6 +365,9 @@ inline const std::error_category& dispatch_category() noexcept { case DispatchErrc::fragment_pending: return "rcp/mock: intermediate fragment accepted, more expected"; case DispatchErrc::fragment_rejected: return "rcp/mock: fragment reassembly rejected the request"; case DispatchErrc::response_fragmented: return "rcp/mock: response queued as multiple fragments on a RespQueue"; + case DispatchErrc::unknown_bus: return "rcp/mock: frame member did not decode as a well-formed ACF message"; + case DispatchErrc::chain_error: return "rcp/mock: chained member has no predecessor within its own frame"; + case DispatchErrc::chain_aborted: return "rcp/mock: chained member aborted per its predecessor's own outcome"; default: return "rcp/mock: unknown dispatch outcome"; } } @@ -464,6 +499,109 @@ inline acf::AcfMessageInfo make_acknowledge_rejected_response(const acf::AcfMess // dispatch path either (confirmed by direct source read), so this // port does not invent a dispatch()-side discovery-request handler // that c-RCP has no counterpart for. + +// ── AVTPDU frame-level multi-member dispatch (Phase 4/Phase 17 batch D2, +// cpp-RCP issue #129) ─────────────────────────────────────────────────────── +// TC18 §12.9.1.1 lets one AVTPDU pack several ACF_ABB/ACF_GBB messages +// back-to-back ("members"), each addressed to its own byte_bus_id. Every +// dispatch_*() elsewhere in this file (and the public dispatch()/ +// dispatch_e2e()/dispatch_e2e_fragment()) only ever handles ONE +// already-isolated member — nothing in this rewrite walks a whole frame's +// own member boundaries; rcp/request.hpp's own file header calls this out +// explicitly ("c-RCP's own multi-request-per-frame splitting... is +// deliberately NOT re-implemented here"). c-RCP itself keeps that walk +// mock.c/scheduler.c-local too (rcp_sched_split_frame_members(), +// src/scheduler.c:63-106) — this is this rewrite's own first and only port +// of it, scoped to this batch's own Server::dispatch_frame()/ +// _dispatch_frame_e2e() below. + +// kMaxFrameMembers mirrors c-RCP's own RCP_MOCK_MAX_FRAME_MEMBERS +// (include/rcp/mock.h:1262) — the fixed number of ACF members +// Server::dispatch_frame()/_dispatch_frame_e2e() below can enumerate from +// one frame. A real frame's member count is bounded far below this by AVTP +// payload limits (rcp/avtp.hpp) in practice. +constexpr size_t kMaxFrameMembers = 32; + +// split_frame_members ported from c-RCP's rcp_sched_split_frame_members() +// (src/scheduler.c:63-106) — walks frame[0..frame.size()) as a +// concatenation of ACF_ABB/ACF_GBB members, recovering just enough of each +// member's own Message Info header (acf_msg_type via octet0 bits 7:1, +// acf_msg_length via the shared 9-bit quadlet-count field spanning octet0 +// bit 0 | octet1 — rcp/acf.hpp's own header comment) to find where the next +// member starts, without decoding any member's own byte_bus_id/payload yet +// — that is dispatch_frame()'s own job, once each member's own extent is +// known. +// +// Writes up to out_offsets.size() starting octet offsets, in frame order, +// and returns how many members frame actually contains — which may exceed +// out_offsets.size() (mirroring rcp_sched_split_frame_members()'s own +// out_offsets-truncated-but-return-value-is-the-real-count contract; +// dispatch_frame() below is what actually stops at out_offsets.size()). +// Returns 0 if frame does not parse as a well-formed concatenation of ACF +// messages at all: too short to hold even acf_msg_type+acf_msg_length (2 +// octets), an unrecognized acf_msg_type, a header longer than what remains, +// a declared acf_msg_length shorter than that message type's own fixed +// header, or a declared length longer than what remains in the buffer. +inline size_t split_frame_members(const std::vector& frame, + std::array& out_offsets) noexcept { + const uint8_t* b = frame.data(); + const size_t len = frame.size(); + size_t offset = 0; + size_t found = 0; + + while (offset < len) { + // acf_msg_type occupies bits 7:1 of octet 0 — not the full octet — + // so at least 2 octets (type+length) must be present before either + // can be read. + if (len - offset < 2) return 0; + + const uint8_t msg_type = static_cast(b[offset] >> 1); + size_t header_len; + if (msg_type == acf::kAcfMsgTypeAbb) { + header_len = acf::kAcfCommonHeaderLen; + } else if (msg_type == acf::kAcfMsgTypeGbb) { + header_len = acf::kAcfGbbMessageInfoLen; + } else { + return 0; // malformed: not a recognized acf_msg_type + } + if (len - offset < header_len) return 0; // malformed: header truncated + + const uint16_t quadlets = static_cast(((b[offset] & 0x01u) << 8) | b[offset + 1]); + const size_t msg_len = static_cast(quadlets) * 4u; + if (msg_len < header_len) return 0; // malformed: declared length shorter than header + if (len - offset < msg_len) return 0; // malformed: payload truncated + + if (found < out_offsets.size()) out_offsets[found] = offset; + ++found; + offset += msg_len; + } + return found; +} + +// FrameMemberResult carries one AVTPDU member's own dispatch outcome, as +// populated by Server::dispatch_frame()/_dispatch_frame_e2e() below — +// mirrors c-RCP's own rcp_mock_frame_member_result_t +// (include/rcp/mock.h:1264-1276), composed onto this file's own +// std::error_code+AcfMessageInfo+payload dispatch contract (every +// dispatch_*() in this file already returns that shape) instead of a +// duplicate numbered-outcome enum. Response aggregation across a whole +// frame stops here, matching c-RCP's own dispatch_frame()/ +// _dispatch_frame_e2e(), which return one rcp_mock_frame_member_result_t +// PER MEMBER too, never a single frame concatenating every member's own +// response back into one outgoing AVTPDU (confirmed by direct source read +// of both functions and their own tests/test_mock.c coverage) — this mock +// has no outer AVTP/NTSCF/TSCF framing ENCODE of its own either (the same +// "no wire codec, protocol library only" disclaimer this file's own header +// comment already carries for the decode direction), so assembling +// out_results back into one wire frame is left to the caller, exactly as +// it already is for every response this file ever builds. +struct FrameMemberResult { + std::error_code result; + avtp::ByteBusId byte_bus_id = 0; // 0 when result could not even be determined + acf::AcfMessageInfo response; // default-constructed (rsp == false) unless a genuine wire response was built + std::vector response_payload; +}; + class Server final { public: Server() @@ -970,6 +1108,30 @@ class Server final { std::error_code dispatch_e2e(size_t client, avtp::StreamId stream_id, uint8_t sequence_num, const std::vector& frame, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { + return dispatch_e2e_core(client, stream_id, sequence_num, frame, out_resp, out_resp_payload, + /*apply_seq_gate=*/true); + } + + // dispatch_e2e_core — Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): + // dispatch_e2e()'s own body, extracted so this batch's own + // dispatch_frame_e2e() below can reuse every one of its checks EXCEPT + // the sequence-number gate, which a multi-member frame must evaluate + // exactly ONCE per frame, not once per member — see dispatch_e2e()'s + // OWN pre-D2 doc comment, still accurate above, for why batch C put + // that gate here at single-member granularity in the first place + // ("batch D's own frame-level, multi-member dispatch_frame_e2e() will + // evaluate it once per FRAME instead... reusing this SAME + // seq_trackers_/RxSequenceGuard state rather than a second, parallel + // instance"). apply_seq_gate false skips exactly that one check + // (dispatch_frame_e2e() has already run frame_seq_gate_admits()'s own + // equivalent, seq_gate_admits(), once for the whole frame before + // calling this per member); every other check below runs unconditionally + // either way. dispatch_e2e()'s own public contract is unchanged: it + // always passes true, matching this function's pre-extraction behavior + // exactly. + std::error_code dispatch_e2e_core(size_t client, avtp::StreamId stream_id, uint8_t sequence_num, + const std::vector& frame, acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, bool apply_seq_gate) noexcept { out_resp = acf::AcfMessageInfo{}; out_resp_payload.clear(); @@ -1028,7 +1190,7 @@ class Server final { // response at all (TC18 leaves the request unfiled, not merely // un-acknowledged), mirroring RCP_MOCK_DISPATCH_SEQ_ERROR's own // identical "response left zeroed" contract (src/mock.c:3106-3112). - if (!seq_gate_admits(stream_id, sequence_num)) { + if (apply_seq_gate && !seq_gate_admits(stream_id, sequence_num)) { return make_error_code(DispatchErrc::seq_error); } @@ -1155,6 +1317,24 @@ class Server final { const std::vector& fragment, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { + return dispatch_e2e_fragment_core(client, stream_id, /*sequence_num=*/0, fragment, out_resp, + out_resp_payload, /*apply_seq_gate=*/true); + } + + // dispatch_e2e_fragment_core — Phase 4/Phase 17 batch D2 (cpp-RCP issue + // #129): dispatch_e2e_fragment()'s own body, extracted the same way + // dispatch_e2e_core() is above, so this batch's own + // dispatch_frame_e2e() can route a fragment-shaped member through it + // with the FRAME's own real sequence_num and apply_seq_gate=false + // (the once-per-frame gate already ran) instead of dispatch_e2e_ + // fragment()'s own public hardcoded sequence_num=0/apply_seq_gate=true + // fallback-to-dispatch_e2e() behavior, which is UNCHANGED for every + // pre-existing direct caller of dispatch_e2e_fragment() itself. + std::error_code dispatch_e2e_fragment_core(size_t client, avtp::StreamId stream_id, + uint8_t sequence_num, const std::vector& fragment, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, + bool apply_seq_gate) noexcept { out_resp = acf::AcfMessageInfo{}; out_resp_payload.clear(); @@ -1188,7 +1368,8 @@ class Server final { const uint8_t stream_index = regmap::request_stream_cfg::resolve_index( regs_.request_streams.data(), regs_.request_streams.size(), stream_id.to_u64()); if (stream_index == 0) { - return dispatch_e2e(client, stream_id, /*sequence_num=*/0, fragment, out_resp, out_resp_payload); + return dispatch_e2e_core(client, stream_id, sequence_num, fragment, out_resp, out_resp_payload, + apply_seq_gate); } fragment::Reassembler& reasm = frag_reassemblers_[stream_index - 1]; std::vector& first_header = frag_first_headers_[stream_index - 1]; @@ -1225,7 +1406,8 @@ class Server final { if (!reasm.is_collecting()) { // Never actually fragmented — byte-identical to calling // dispatch_e2e() directly. - return dispatch_e2e(client, stream_id, /*sequence_num=*/0, fragment, out_resp, out_resp_payload); + return dispatch_e2e_core(client, stream_id, sequence_num, fragment, out_resp, out_resp_payload, + apply_seq_gate); } // Real multi-fragment message completing: this final fragment's own @@ -1315,6 +1497,121 @@ class Server final { return result_ec; } + // dispatch_frame — Phase 4/Phase 17 batch D2 (cpp-RCP issue #129, + // Phase 4's fourth and FINAL batch): the AVTPDU frame-level + // multi-member counterpart to dispatch() above, ported from c-RCP's + // rcp_mock_server_dispatch_frame() (src/mock.c:3068-3237, REQ-MOCK-019/ + // 020/029). Splits `frame` into its constituent ACF members + // (split_frame_members(), this file's own namespace-scope helper + // above) and routes each one — in frame order — through + // decode_and_dispatch() (dispatch()'s own raw-bytes entry point, now + // conditional/cancellation-opcode-aware too, see that method's own + // updated doc comment for this same batch's companion fix), applying + // REQ-CANCEL-012's chain-group/position bookkeeping (TC18 §11.2.3) + // along the way (dispatch_frame_impl(), private, below — shared with + // dispatch_frame_e2e()). Every admission/classification/suppression/E2E + // decision is made by decode_and_dispatch()/dispatch()/ + // admit_and_classify() themselves, exactly as batches A/B/C/D1 already + // built them — this method's own job is purely the splitting/routing/ + // chain-bookkeeping layer sitting on top, matching this batch's own + // scope. + // + // Writes one FrameMemberResult per dispatched member into out_results + // (cleared first), in frame order, and returns how many were + // dispatched (== out_results.size() afterward) — 0 if frame does not + // parse as a well-formed concatenation of ACF messages at all + // (split_frame_members() itself returned 0). If frame contains more + // than kMaxFrameMembers members, only the first kMaxFrameMembers are + // dispatched, mirroring split_frame_members()'s own out_offsets + // truncation (pass a frame with at most kMaxFrameMembers members to be + // certain none is dropped). + // + // client/stream_id are shared across every member — properties of the + // enclosing NTSCF/TSCF frame, not of any one ACF message packed inside + // it, same as c-RCP's own avtp_subtype/time_sync_supported/stream_id + // parameters (this mock's own dispatch()/dispatch_e2e() family already + // has no avtp_subtype/time_sync_supported concept of its own — see this + // file's own header comment — so only client/stream_id carry over + // here). + size_t dispatch_frame(size_t client, avtp::StreamId stream_id, const std::vector& frame, + std::vector& out_results) noexcept { + return dispatch_frame_impl( + frame, out_results, + [this, client, stream_id](const std::vector& member, acf::AcfMessageInfo& resp, + std::vector& resp_payload) { + return decode_and_dispatch(client, member, stream_id, resp, resp_payload); + }); + } + + // dispatch_frame_e2e — dispatch_frame()'s own E2E-aware counterpart, + // ported from c-RCP's rcp_mock_server_dispatch_frame_e2e() (src/mock.c: + // 3240-3382, REQ-E2E-033). The one real difference from dispatch_frame() + // above: each member is independently unwrapped-and-CRC-verified + // against its own addressed endpoint's own ep_req_crc_enable (TC18 + // §13.6's "a separate CRC32... for each E2E-protected ACF message", + // REQ-E2E-033 — never one CRC across the whole frame), AND, per this + // batch's own item 2 ("route each member... based on E2E/fragmentation + // flags on that member"), reassembled if it turns out to be a fragment + // — both via dispatch_e2e_fragment_core() (dispatch_e2e_fragment()'s + // own extracted body, this batch's own addition alongside dispatch_e2e_ + // core(), see that method's own doc comment). This is a deliberate, + // documented DELTA from c-RCP: c-RCP's own rcp_mock_server_dispatch_ + // frame_e2e() calls only rcp_mock_server_dispatch_e2e() per member + // (src/mock.c:3349-3352) and has no frame-level fragment-aware + // counterpart at all (confirmed by direct source read — no + // rcp_mock_server_dispatch_frame_e2e_fragment symbol exists anywhere in + // c-RCP). dispatch_e2e_fragment_core() is a strict superset of + // dispatch_e2e_core() for a non-fragmented member (it falls back to + // calling dispatch_e2e_core() itself in both of its own "never actually + // fragmented" branches — see that method's own doc comment), so this + // costs nothing for the ordinary, non-fragmented case while adding + // correct handling for the fragmented one, closing a real gap c-RCP + // itself still has. + // + // `sequence_num` (the enclosing AVTPDU's own Sequence_Nr) is evaluated + // exactly ONCE here, via seq_gate_admits() (this file's own + // single-member-shaped REQ-E2E-028/029 gate — reused rather than + // duplicated, a legitimate frame-level use since it is stream-keyed + // state, not member-keyed), BEFORE any member is processed — mirrors + // c-RCP's own frame_seq_gate_admits() (src/mock.c:3038-3063): a + // legitimate 2nd+ member of a multi-member frame must never be + // spuriously rejected as a replay against the 1st member's own + // just-advanced tracker state. A rejected sequence number reports + // DispatchErrc::seq_error for every member, with no per-member wire + // response (mirrors RCP_MOCK_DISPATCH_SEQ_ERROR's own identical + // "response left zeroed" contract, src/mock.c:3106-3112), and no + // member is otherwise inspected at all. Every per-member call below + // passes apply_seq_gate=false — this frame-level check already covers + // it; see dispatch_e2e_core()'s own doc comment for why that parameter + // exists. + size_t dispatch_frame_e2e(size_t client, avtp::StreamId stream_id, uint8_t sequence_num, + const std::vector& frame, + std::vector& out_results) noexcept { + out_results.clear(); + + std::array offsets{}; + const size_t real_count = split_frame_members(frame, offsets); + if (real_count == 0) return 0; + const size_t stored_count = std::min(real_count, kMaxFrameMembers); + + if (!seq_gate_admits(stream_id, sequence_num)) { + for (size_t i = 0; i < stored_count; ++i) { + out_results.push_back(FrameMemberResult{make_error_code(DispatchErrc::seq_error), 0, + acf::AcfMessageInfo{}, {}}); + } + return out_results.size(); + } + + return dispatch_frame_impl( + frame, out_results, + [this, client, stream_id, sequence_num](const std::vector& member, + acf::AcfMessageInfo& resp, + std::vector& resp_payload) { + return dispatch_e2e_fragment_core(client, stream_id, sequence_num, member, resp, resp_payload, + /*apply_seq_gate=*/false); + }); + } + // fragment_reassembler gives direct, mutable access to this Server's own // fragment::Reassembler for stream_id — the same "expose the real // subsystem object, don't wrap every one of its methods" convention @@ -1463,13 +1760,62 @@ class Server final { out_resp_payload = acf::encode_error_payload(code); } - // decode_and_dispatch is dispatch_e2e()'s own shared delegation tail — - // both its "plain command mode" branch (an endpoint with - // ep_req_crc_enable not set) and its "CRC validated" branch decode a - // raw ACF_ABB/ACF_GBB frame into a real AcfMessageInfo+payload pair and - // hand it to dispatch() unchanged, exactly as c-RCP's own - // rcp_mock_server_dispatch_e2e() delegates to dispatch_plain() at both - // of its own two identical call sites (src/mock.c:1949-1950/2034-2035). + // peek_conditional_request_type — Phase 4/Phase 17 batch D2 (cpp-RCP + // issue #129): decode_and_dispatch()'s own admission-routing peek, + // mirroring server::Endpoint's own private peek_request_type() + // (server.hpp) exactly (duplicated here rather than exposed, since + // that one 8-line helper is server::Endpoint's own private + // implementation detail, not part of its public admission surface) — + // if frame is long enough to hold the full ACF_GBB Message Info block + // (16 octets) and decodes as an untimed (mtv-clear) ACF_GBB whose + // repurposed opcode byte is one of rcp::request's defined + // RequestTypeOpcode values, returns it. Otherwise (ABB, a GBB with mtv + // set — a genuine timestamped Standard request — too short, or an + // unrecognized opcode byte) returns std::nullopt: every one of those is + // a Standard request, routed through the ordinary ABB-oriented + // dispatch() path below exactly as before this batch. + static std::optional peek_conditional_request_type( + const std::vector& frame) noexcept { + if (frame.size() < acf::kAcfGbbMessageInfoLen) return std::nullopt; + acf::AcfMessageInfo hdr; + acf::decode_acf_message_info(frame.data(), hdr); + if (hdr.acf_msg_type != acf::kAcfMsgTypeGbb || hdr.mtv) return std::nullopt; + const uint8_t byte0 = frame[acf::kAcfCommonHeaderLen]; + if (!request::is_valid_request_type(byte0)) return std::nullopt; + return static_cast(byte0); + } + + // decode_and_dispatch is dispatch_e2e()/dispatch_e2e_fragment()'s own + // shared delegation tail — both dispatch_e2e()'s "plain command mode" + // branch (an endpoint with ep_req_crc_enable not set) and its "CRC + // validated" branch decode a raw ACF_ABB/ACF_GBB frame and dispatch it, + // exactly as c-RCP's own rcp_mock_server_dispatch_e2e() delegates to + // dispatch_plain() at both of its own two identical call sites + // (src/mock.c:1949-1950/2034-2035). This batch's own dispatch_frame()/ + // dispatch_frame_e2e() below reuse it too, for every one of a frame's + // own isolated members — see their own doc comments. + // + // Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): a genuine repurposed- + // timestamp ACF_GBB conditional/cancellation opcode + // (peek_conditional_request_type() above) is now routed DIRECTLY to + // its addressed endpoint's own admission queue (admit_and_classify(), + // below) on frame's own raw bytes, never through dispatch()'s own + // ABB-only path — closing the gap admit_and_classify()'s own doc + // comment names ("every request built here is always encoded + // ACF_ABB... meaning only ExecuteNow/Queued/Suspended/Rejected are + // ever reachable through this function") for every caller of THIS + // function, single-member or frame-level alike. Before this batch, + // such a member would have been decoded via acf::decode_acf_gbb() (its + // own message_timestamp silently discarded) and dispatched as an + // ordinary Standard request using req.op/evt_op — i.e. its + // repurposed-opcode meaning was lost entirely; this is what + // apply_cancellation()'s own former TODO(phase4-batch-d2) named as + // needing "a real multi-member frame to [reach this path from]". No + // existing single-member dispatch_e2e()/_dispatch_e2e_fragment() test + // exercises a genuine conditional/cancellation GBB frame (confirmed by + // source read of tests/test_mock.cpp), so this is a strict behavior + // *addition*, not a change, for every pre-existing caller. + // // A decode failure (too short, or an unrecognized acf_msg_type) leaves // out_resp/out_resp_payload at their default "nothing to send" shape, // matching every other malformed-input disposition in this file. @@ -1480,6 +1826,38 @@ class Server final { out_resp_payload.clear(); if (frame.empty()) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + if (peek_conditional_request_type(frame)) { + // admit_with_ack() (inside admit_and_classify() below) re-peeks + // and fully decodes this same opcode itself — the check above + // exists only to choose which routing branch to take. + acf::AcfMessageInfo peek; + acf::decode_acf_message_info(frame.data(), peek); + server::Endpoint* ep = admission(peek.byte_bus_id); + if (!ep) { + return set_error_response(peek, make_error_code(regmap::RegMapErrc::invalid_parameter), + out_resp, out_resp_payload); + } + if (!operational_requests_allowed()) { + std::error_code ec = set_error_response( + peek, make_error_code(regmap::RegMapErrc::request_rejected), out_resp, out_resp_payload); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return ec; + } + std::error_code admit_ec; + // A genuine conditional/cancellation opcode never reaches + // AdmitOutcome::ExecuteNow (admit_with_ack()'s own body: + // every recognized category other than Cancellation always + // stores and returns Pending; Cancellation never executes a + // handler at all) — the `true` return is defensively + // unreachable here, matching admit_and_classify()'s own + // "return true" ExecuteNow case leaving out_resp untouched by + // design (its normal callers own a handler body to run + // instead; this call site has none to offer). + (void)admit_and_classify(*ep, frame, peek, out_resp, out_resp_payload, admit_ec); + suppress_response_per_stream_cfg(regs_, stream_id, out_resp, out_resp_payload); + return admit_ec; + } + acf::AcfMessageInfo req; std::vector payload; std::error_code ec; @@ -1725,29 +2103,36 @@ class Server final { // apply_cancellation applies an already-classified AdmitOutcome:: // Cancellation request against admission's own request store — ported - // from c-RCP's apply_cancellation() (src/mock.c:1180-1245), reduced to - // the two whole-store cancellation kinds (clear-all/clear-non- - // safestate) this batch actually needs a response shape for. - // clear-single's own REQ-CANCEL-012 chain-cascade needs its target - // transaction_num decoded out of the raw frame - // (request::decode_clear_single(), request.hpp) plus a genuine - // REQUEST_NOT_FOUND error response when no match exists — deliberately - // left as a stub here: AdmitOutcome::Cancellation is unreachable - // through this file's own dispatch_*() below in the first place (see - // admit_and_classify()'s own comment), so there is no dispatch() - // caller yet that can exercise it end-to-end; wiring a GBB-carrying - // frame in that actually reaches this path is AVTPDU frame-level - // dispatch_frame() territory — batch D2's own scope specifically (D1, - // this batch, is fragment reassembly only; D1's own dispatch_e2e_ - // fragment() above still routes every request it admits through the - // SAME admit_and_classify(), so this remains just as unreachable - // through it as it was before D1 — see dispatch_e2e_fragment()'s own - // doc comment). - // TODO(phase4-batch-d2): decode clear-single's own target transaction_num - // and apply REQ-CANCEL-012's chain cascade once dispatch_frame() can - // reach this path with a real multi-member frame to derive - // chain_group/chain_position from. + // from c-RCP's apply_cancellation() (src/mock.c:1180-1245). + // + // Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): fills in the + // ClearSingle case left as a TODO(phase4-batch-d2) stub since batch A — + // now that admit_and_classify() (above) hands this function the raw + // frame bytes directly (see that function's own updated doc comment), + // ClearSingle's own target clear_transaction_num can actually be + // decoded (request::decode_clear_single(), request.hpp) and REQ- + // CANCEL-012's chain cascade applied for real. `req` carries the + // CANCELLATION request's own byte_bus_id/transaction_num, used to + // address the one wire response this function ever builds (TC18 + // §12.9.6's general "error response addressed to the request that + // caused it" rule — NOT the not-found target's own byte_bus_id/ + // transaction_num, which this mock's own single-endpoint-per- + // byte_bus_id model guarantees is identical anyway, but is kept + // conceptually distinct here to match c-RCP's own doc comment, + // src/mock.c:1158-1167). + // + // Clear-all and clear-non-safestate cancel every request each removes + // with its own REQUEST_CANCELED error response (TC18 §11.2.3: one + // response per cancelled request, not one for the cancellation itself) + // — a multi-response fanout this function's single out_resp cannot + // represent, same gap c-RCP's own apply_cancellation() names and defers + // (github.com/SoundMatt/c-RCP/issues/163); a ClearSingle's own + // successful cascade below carries the identical fanout gap for the + // same reason. Not attempted here — out_resp/out_resp_payload are left + // at their default "nothing to send" shape for every branch except + // ClearSingle's own REQUEST_NOT_FOUND case. static void apply_cancellation(server::Endpoint& admission, request::RequestTypeOpcode request_type, + const std::vector& frame, const acf::AcfMessageInfo& req, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) noexcept { out_resp = acf::AcfMessageInfo{}; @@ -1759,8 +2144,62 @@ class Server final { case request::RequestTypeOpcode::ClearNonSafestate: (void)admission.cancel_non_safestate(); break; + case request::RequestTypeOpcode::ClearSingle: { + request::ClearSingleRequest csr; + if (request::decode_clear_single(frame.data(), frame.size(), csr)) { + break; // malformed despite admit_with_ack()'s own opcode-byte peek succeeding: no-op + } + + // REQ-CANCEL-012: read chain_group/chain_position out BEFORE + // cancelling — cancel_single() below already frees and clears + // the target's own store slot, so its own chain bookkeeping + // has to be captured first. Left at their zero-valued + // defaults (chain_group 0, the "not part of a chain" + // sentinel) if no matching entry is found — the cascade call + // below is then a guaranteed no-op (server::Endpoint:: + // cancel_chain_from()'s own doc comment), exactly matching a + // target that reports NotFound. Mirrors c-RCP's own + // apply_cancellation() (src/mock.c:1200-1216) using pending(), + // the same public accessor this file's own frame-splitting + // chain bookkeeping below already relies on for the identical + // reason. + uint32_t target_chain_group = 0; + uint8_t target_chain_position = 0; + for (size_t i = 0; i < server::kMaxPending; ++i) { + if (const server::PendingRequest* slot = admission.pending(i)) { + if (slot->transaction_num == csr.clear_transaction_num) { + target_chain_group = slot->chain_group; + target_chain_position = slot->chain_position; + break; + } + } + } + + // Every request still sitting in the store is by definition + // queued rather than under execution — this mock runs a + // selected request to completion synchronously inside + // tick()/complete() (never leaving one observably + // mid-execution here), so CancelLifecycle::Queued is always + // the right lifecycle to attempt against. + const server::CancelResult result = + admission.cancel_single(csr.clear_transaction_num, server::CancelLifecycle::Queued); + if (result == server::CancelResult::NotFound) { + // TC18 §11.2.3.3: "...will create an error response with + // the error code = REQUEST_NOT_FOUND, when the + // clear_transaction_num was not found." + out_resp = acf::make_response(req, acf::ResponseKind::ErrorResponse); + out_resp_payload = acf::encode_error_payload(acf::WireErrorCode::RequestNotFound); + } else if (result == server::CancelResult::Canceled) { + (void)admission.cancel_chain_from(target_chain_group, target_chain_position); + } + // NotCancellable: c-RCP's own apply_cancellation() builds no + // response for this outcome either (only NotFound does) — + // left at "nothing to send", matching every other branch's + // own default disposition above. + break; + } default: - break; // ClearSingle: see this function's own TODO above. + break; } } @@ -1805,11 +2244,29 @@ class Server final { // a genuine wire ErrorResponse/Acknowledge-rejected uses // regmap::RegMapErrc::request_rejected instead, mirroring // operational_requests_allowed()'s own existing "rejected" signal). - static bool admit_and_classify(server::Endpoint& admission, const acf::AcfMessageInfo& req, - const std::vector& payload, acf::AcfMessageInfo& out_resp, + // + // Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): `frame` is now taken + // directly from the caller rather than re-derived internally from + // `req`/`payload` via acf::encode_acf_abb() — every one of this file's + // own ten dispatch__inner() call sites still passes exactly that + // same ABB-re-encoded frame (behavior-unchanged for them: `req` was + // always decoded from an ACF_ABB request kind to begin with, per this + // file's own header comment on dispatch()'s Standard-only scope), but + // decode_and_dispatch()'s own new conditional/cancellation-opcode + // branch below now ALSO calls this function directly with a genuine, + // still-repurposed-timestamp ACF_GBB member's raw bytes preserved — + // closing the gap this function's own prior doc comment named ("every + // request built here is always encoded ACF_ABB... meaning only + // ExecuteNow/Queued/Suspended/Rejected are ever reachable through this + // function AS CALLED BELOW") for that one caller, without duplicating + // this switch's own outcome-handling logic a second time. `req` is + // still used for response-shape building only (byte_bus_id/ + // transaction_num/evt_ack — decoded identically off ABB and GBB, since + // a GBB Message Info header is an ABB header's same fields plus the + // trailing message_timestamp slot). + static bool admit_and_classify(server::Endpoint& admission, const std::vector& frame, + const acf::AcfMessageInfo& req, acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload, std::error_code& out_ec) { - const std::vector frame = acf::encode_acf_abb(req, payload); - std::optional request_type; std::optional admit_error; std::vector ack; @@ -1843,7 +2300,7 @@ class Server final { return false; case server::AdmitOutcome::Cancellation: - apply_cancellation(admission, *request_type, out_resp, out_resp_payload); + apply_cancellation(admission, *request_type, frame, req, out_resp, out_resp_payload); out_ec = make_error_code(DispatchErrc::cancelled); return false; @@ -1883,6 +2340,229 @@ class Server final { } } + // last_pending_index — Phase 4/Phase 17 batch D2 (cpp-RCP issue #129): + // the store index of the most recently admitted request on ep — the + // one admit_and_classify()/admit_with_ack() just placed there, + // identified by its own highest `sequence` (server::PendingRequest's + // own arrival-order tie-break field, already exposed publicly via + // server::Endpoint::pending()). Ported from c-RCP's own + // last_pending_index() (src/mock.c:3011-3028), using this rewrite's own + // public pending()/kMaxPending accessors rather than reaching into + // server::Endpoint's own private storage the way c-RCP's helper reaches + // into rcp_server_endpoint_t's own public `pending[]` array directly — + // admit_and_classify() (above) deliberately never surfaces the index + // admit_with_ack() itself already computes (its own out_index parameter + // is always passed nullptr), so dispatch_frame_impl() below has to + // re-derive it exactly the way c-RCP's own dispatch_frame() does. + // Returns server::kMaxPending if ep's own store is empty. + static size_t last_pending_index(const server::Endpoint& ep) noexcept { + size_t best = server::kMaxPending; + uint64_t max_seq = 0; + for (size_t i = 0; i < server::kMaxPending; ++i) { + const server::PendingRequest* slot = ep.pending(i); + if (!slot) continue; + if (best == server::kMaxPending || slot->sequence >= max_seq) { + max_seq = slot->sequence; + best = i; + } + } + return best; + } + + // dispatch_frame_impl is dispatch_frame()'s and dispatch_frame_e2e()'s + // own shared splitting/routing/chain-bookkeeping loop (Phase 4/Phase 17 + // batch D2, cpp-RCP issue #129) — the two differ ONLY in which + // single-member entry point actually dispatches each already-isolated + // member (decode_and_dispatch() for dispatch_frame(); dispatch_e2e_ + // fragment_core() for dispatch_frame_e2e(), see that method's own doc + // comment), so `dispatch_member` (a callable of shape + // std::error_code(const std::vector& member, + // acf::AcfMessageInfo& resp, std::vector& resp_payload)) is + // the one thing each caller supplies; every other step — splitting, + // per-member byte_bus_id/chain-opcode peeking, REQ-CANCEL-012 chain + // bookkeeping — is identical between them and lives here once, matching + // this batch's own "do not duplicate admission/classification/E2E + // logic" charter (the dispatch_member call IS that reused logic; this + // function never re-implements any of it). + // + // Ported from the shared shape of c-RCP's own rcp_mock_server_ + // dispatch_frame()/_dispatch_frame_e2e() (src/mock.c:3068-3382) MINUS + // the once-per-frame sequence gate (each caller's own job — see + // dispatch_frame_e2e()'s own doc comment for why it cannot live here: + // dispatch_frame() itself has no sequence_num parameter and no gate to + // apply at all). + template + size_t dispatch_frame_impl(const std::vector& frame, std::vector& out_results, + DispatchMemberFn&& dispatch_member) noexcept { + out_results.clear(); + + std::array offsets{}; + const size_t real_count = split_frame_members(frame, offsets); + if (real_count == 0) return 0; + const size_t stored_count = std::min(real_count, kMaxFrameMembers); + + // Chain sequencing state, carried across this frame's own members + // in order — mirrors c-RCP's own identical locals (src/mock.c: + // 3082-3098). chain_group == 0 is the "not part of a chain" + // sentinel; every member (chained or not) starts a fresh potential + // chain_group == i+1 (the +1 avoids colliding with the sentinel at + // i==0) unless it is itself chained, in which case it keeps its + // predecessor's own chain_group and advances chain_position by + // one. + bool chain_aborted = false; + bool prev_errored = false; + uint32_t chain_group = 0; + uint8_t chain_position = 0; + + for (size_t i = 0; i < stored_count; ++i) { + const size_t member_off = offsets[i]; + size_t member_end; + if (i + 1 < stored_count) { + member_end = offsets[i + 1]; + } else if (i + 1 == real_count) { + member_end = frame.size(); // i is genuinely the last member in the whole frame + } else { + break; // real_count exceeds kMaxFrameMembers and i is the last stored offset — see + // split_frame_members()'s own doc comment for why there is no reliable end here + } + const std::vector member(frame.begin() + static_cast(member_off), + frame.begin() + static_cast(member_end)); + + FrameMemberResult out; + + // peek_member_byte_bus_id equivalent (c-RCP's own + // src/mock.c:2950-2980): a full ABB/GBB decode, not just a + // header peek, since a syntactically well-formed member (per + // split_frame_members()'s own quadlet-count check) can still + // fail to decode for other reasons (e.g. a declared pad + // exceeding its own body region) — this must surface as + // unknown_bus, not a crash or a bogus dispatch. + acf::AcfMessageInfo peek; + bool decoded = false; + const uint8_t msg_type = member.empty() ? 0 : acf::peek_acf_msg_type(member.data()); + if (msg_type == acf::kAcfMsgTypeAbb) { + std::vector ignored; + decoded = !acf::decode_acf_abb(member.data(), member.size(), peek, ignored); + } else if (msg_type == acf::kAcfMsgTypeGbb) { + uint64_t ts = 0; + std::vector ignored; + decoded = !acf::decode_acf_gbb(member.data(), member.size(), peek, ts, ignored); + } + if (!decoded) { + out.result = make_error_code(DispatchErrc::unknown_bus); + out.byte_bus_id = 0; + prev_errored = true; + out_results.push_back(std::move(out)); + continue; + } + out.byte_bus_id = peek.byte_bus_id; + + // is_chained_member equivalent (c-RCP's own src/mock.c: + // 2982-3009): a chained member's execution condition is its + // *predecessor within this same frame* — a chain never spans + // AVTPDUs, and a member's position in the chain is its + // position in the frame, not a sub-field of its own — so this + // decision is made here, where frame order is known, rather + // than inside server::Endpoint's own store. + request::ChainedMember cm; + const bool chained_flag = !request::decode_chained_member(member.data(), member.size(), cm); + + if (!chained_flag) { + chain_group = static_cast(i) + 1u; + chain_position = 0; + } else { + ++chain_position; + } + + if (chained_flag) { + // rcp_chained_advance() equivalent (src/request.c:673-694), + // composed onto request::should_execute_chained()'s own + // pure per-successor predicate (request.hpp) rather than + // re-deriving its cs-polarity here — cpp-RCP issue #58's + // own fix stays the single source of truth for that logic. + if (i == 0) { + // No predecessor at all within this frame: this + // member, and (via the chain_aborted latch) every + // member after it, is ignored. + chain_aborted = true; + out.result = make_error_code(DispatchErrc::chain_error); + out.response = acf::make_response(peek, acf::ResponseKind::ErrorResponse); + out.response_payload = acf::encode_error_payload(acf::WireErrorCode::ChainError); + prev_errored = true; + out_results.push_back(std::move(out)); + continue; + } + if (chain_aborted || !request::should_execute_chained(cm.cs, prev_errored)) { + chain_aborted = true; + out.result = make_error_code(DispatchErrc::chain_aborted); + out.response = acf::make_response(peek, acf::ResponseKind::ErrorResponse); + out.response_payload = acf::encode_error_payload(acf::WireErrorCode::ChainAborted); + prev_errored = true; + out_results.push_back(std::move(out)); + continue; + } + } + + // pending_count() before/after (rather than testing out.result + // == DispatchErrc::pending below) is what actually detects a + // fresh AdmitOutcome::Pending admission: admit_and_classify() + // reports success ({}), not DispatchErrc::pending, when the + // member's own evt[3] requested an Acknowledge (REQ-SRV-016) — + // a genuine Pending admission either way. A Standard ABB + // member never changes pending_count() at all, so this check + // is a safe no-op for every non-conditional member too. + server::Endpoint* ep_for_bookkeeping = admission(peek.byte_bus_id); + const size_t pending_before = ep_for_bookkeeping ? ep_for_bookkeeping->pending_count() : 0; + + out.result = dispatch_member(member, out.response, out.response_payload); + + // What "the predecessor errored" means for the next member: a + // member that never reached its endpoint's own admission at + // all (unknown bus, rejected) counts as an error for chaining + // purposes; one that executed, queued, or was stored does not. + // Mirrors c-RCP's own identical rule (src/mock.c:3197-3203), + // reduced to this file's own reachable outcome set (this mock + // has no DROPPED-shaped outcome distinct from REJECTED — every + // lifecycle-gated rejection here already reports + // regmap::RegMapErrc::request_rejected, see + // operational_requests_allowed()'s own call sites). + prev_errored = (out.result == make_error_code(DispatchErrc::unknown_bus) || + out.result == make_error_code(regmap::RegMapErrc::request_rejected)); + + // REQ-CANCEL-012: a member admitted as AdmitOutcome::Pending + // has its predecessor already behind it (this same loop + // already dispatched every earlier member), so its + // chain_exec_delay timer starts now, AND this same PendingRequest + // slot records its own chain_group/chain_position — + // unconditionally, exactly like chain_predecessor_done() + // itself, which is a no-op for a non-Chained entry (see that + // method's own doc comment, server.hpp): a standalone + // (non-chained) member that lands here is its own chain's own + // anchor, tagged chain_position 0, ready to cascade to any + // real successors admitted after it. Mirrors c-RCP's own + // identical block (src/mock.c:3205-3231) MINUS its own + // find_slot_on_stream() stream-scoping: this rewrite's own + // Server hosts exactly one registered endpoint per + // byte_bus_id (no per-stream endpoint duplication concept + // exists here — see this class's own header comment), so + // admission(byte_bus_id) alone is already unambiguous. + if (ep_for_bookkeeping && ep_for_bookkeeping->pending_count() > pending_before) { + const size_t last = last_pending_index(*ep_for_bookkeeping); + if (last < server::kMaxPending) { + (void)ep_for_bookkeeping->chain_predecessor_done(last, /*now=*/0); + if (server::PendingRequest* slot = ep_for_bookkeeping->pending(last)) { + slot->chain_group = chain_group; + slot->chain_position = chain_position; + } + } + } + + out_results.push_back(std::move(out)); + } + + return out_results.size(); + } + // suppress_response_per_stream_cfg — TC18 §12.7.7 Table 24 // (REQ-RMAP-048/049), ported from c-RCP's own identically-named // function (src/mock.c:1716-1742). Table 24's own two per-request- @@ -2025,7 +2705,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(gpio_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(gpio_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } if (!req.op) { @@ -2073,7 +2754,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(spi_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(spi_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } uint8_t channel = 0; @@ -2116,7 +2798,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(i2c_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(i2c_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } auto ec = i2c_.handle_request(req.evt_op, payload, i2c_response_, i2c_acked_); @@ -2176,7 +2859,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(adc_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(adc_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } @@ -2263,7 +2947,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(pwm_in_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(pwm_in_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } // `payload` remains otherwise unused below (§13.7.6.3 carries no @@ -2310,7 +2995,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(lin_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(lin_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } auto ec = lin_.handle_request(req.evt_op, payload, lin_response_, lin_responded_); @@ -2365,7 +3051,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(can_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(can_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } can::CanDataFrame frame; @@ -2434,7 +3121,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(uart_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(uart_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } std::vector data; @@ -2496,7 +3184,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(iseled_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(iseled_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } auto ec = iseled_.handle_request(req.evt_op, payload); @@ -2562,7 +3251,8 @@ class Server final { out_resp, out_resp_payload); } std::error_code admit_ec; - if (!admit_and_classify(mdio_admission_, req, payload, out_resp, out_resp_payload, admit_ec)) { + const std::vector admit_frame = acf::encode_acf_abb(req, payload); + if (!admit_and_classify(mdio_admission_, admit_frame, req, out_resp, out_resp_payload, admit_ec)) { return admit_ec; } mdio::MdioRequest request; diff --git a/tests/test_mock.cpp b/tests/test_mock.cpp index 91a119a..98374f2 100644 --- a/tests/test_mock.cpp +++ b/tests/test_mock.cpp @@ -2347,3 +2347,429 @@ TEST_CASE("fragment_reassembler()/resp_queue_for_stream() fail toward nullptr fo REQUIRE(server.fragment_reassembler(stream_id) == nullptr); REQUIRE(server.resp_queue_for_stream(stream_id) == nullptr); } + +// ── AVTPDU frame-level multi-member dispatch (Phase 4/Phase 17 batch D2, +// cpp-RCP issue #129 — Phase 4's fourth and FINAL batch) ──────────────────── +// Ported from c-RCP's tests/test_mock.c coverage for rcp_mock_server_ +// dispatch_frame()/_dispatch_frame_e2e() (REQ-MOCK-019/020/029, +// REQ-E2E-033, REQ-CANCEL-012), reduced to this mock's own dispatch_frame()/ +// dispatch_frame_e2e() entry point shapes — see their own doc comments. + +namespace { + +// wrap_write is wrap_gpio_write()'s own generic sibling: builds a real +// E2E-CRC-protected NTSCF frame for an arbitrary standard write request — +// used below where more than one endpoint type needs an E2E-wrapped member +// within the same frame (wrap_gpio_write() itself stays GPIO-OR-write-only, +// unchanged, for its own pre-existing call sites above). +std::vector wrap_write(avtp::StreamId stream_id, avtp::ByteBusId byte_bus_id, uint8_t evt_op, + uint8_t transaction_num, const std::vector& payload) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + info.op = true; + info.evt_op = evt_op; + return e2e::wrap_framed(/*is_ntscf_framed=*/true, kE2eHeaderOctet1, /*tu=*/false, stream_id, + /*avtp_timestamp=*/std::nullopt, info, /*message_timestamp=*/std::nullopt, payload); +} + +} // namespace + +TEST_CASE("dispatch_frame dispatches each ACF member of a multi-member frame to its own endpoint", + "[mock][frame][REQ-MOCK-019]") { + mock::Server server; + configure_gpio_all_outputs(server); + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.set_i2c_response({0xDE, 0xAD}); + + auto req1 = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Or)); + auto frame1 = acf::encode_acf_abb(req1, gpio::encode_gpio_payload(0x0000'000F)); + + // A 4-byte (already quadlet-aligned) payload — split_frame_members() + // relies on each non-final member's own declared acf_msg_length*4 + // matching its real encoded byte count exactly to find where the next + // member starts; acf::encode_acf_abb() itself does not auto-pad an + // unaligned payload to that boundary (acf.hpp's own header comment: + // "padding has always been a caller-owned concern in this codec"), so + // every member built for one of THIS section's own combined frames + // uses a payload whose own header+payload length is already a multiple + // of 4 octets, sidestepping that caller-owned padding step entirely. + auto req2 = standard_request(mock::kI2cByteBusId, /*write=*/true, /*evt_op=*/0); + auto frame2 = acf::encode_acf_abb(req2, std::vector{0xA0, 0x00, 0x00, 0x00}); + + std::vector combined = frame1; + combined.insert(combined.end(), frame2.begin(), frame2.end()); + + std::vector results; + const size_t dispatched = server.dispatch_frame(0, avtp::StreamId{}, combined, results); + + REQUIRE(dispatched == 2); + REQUIRE(results.size() == 2); + REQUIRE_FALSE(results[0].result); + REQUIRE(results[0].byte_bus_id == mock::kGpioByteBusId); + REQUIRE(acf::response_kind_of(results[0].response) == acf::ResponseKind::WriteResponse); + REQUIRE(server.gpio().read() == 0x0000'000F); + + REQUIRE_FALSE(results[1].result); + REQUIRE(results[1].byte_bus_id == mock::kI2cByteBusId); + REQUIRE(acf::response_kind_of(results[1].response) == acf::ResponseKind::ReadResponse); + REQUIRE(results[1].response_payload == std::vector{0xDE, 0xAD}); +} + +TEST_CASE("dispatch_frame on a single-member frame matches calling dispatch() directly", + "[mock][frame][REQ-MOCK-019]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.set_i2c_response({0x11, 0x22}); + + auto req = standard_request(mock::kI2cByteBusId, /*write=*/true, /*evt_op=*/0); + const std::vector payload{0x55, 0x00, 0x00, 0x00}; // quadlet-aligned — see this section's own note above + auto frame = acf::encode_acf_abb(req, payload); + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, frame, results) == 1); + REQUIRE_FALSE(results[0].result); + REQUIRE(results[0].byte_bus_id == mock::kI2cByteBusId); + REQUIRE(results[0].response_payload == std::vector{0x11, 0x22}); + + mock::Server direct; + REQUIRE_FALSE(direct.advance_to_rcp_configured()); + direct.set_i2c_response({0x11, 0x22}); + acf::AcfMessageInfo direct_resp; + std::vector direct_payload; + REQUIRE_FALSE(direct.dispatch(0, req, payload, direct_resp, direct_payload)); + REQUIRE(direct_payload == results[0].response_payload); +} + +TEST_CASE("dispatch_frame returns 0 for a frame that does not parse as a well-formed " + "concatenation of ACF messages", + "[mock][frame][REQ-MOCK-020]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + const std::vector garbage{0xFF, 0xFF, 0xFF}; + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, garbage, results) == 0); + REQUIRE(results.empty()); +} + +TEST_CASE("dispatch_frame's unknown_bus outcome is defensive-only in this codebase — a member " + "split_frame_members() already accepted always decodes, given acf::decode_acf_abb()'s " + "own deliberately lenient contract", + "[mock][frame][REQ-MOCK-020]") { + // c-RCP's own equivalent test (test_dispatch_frame_reports_unknown_bus_ + // for_undecodable_member, tests/test_mock.c) uses a member whose own + // declared pad (1) exceeds its own 0-octet body region to force + // rcp_acf_decode_abb() — a STRICT decoder — to reject it. This + // codebase's own acf::decode_acf_abb()/_decode_gbb() are deliberately + // LENIENT instead (acf.hpp's own TODO(phase1-followup) note: they hand + // back "everything from the header to the end of the buffer given" + // without validating `pad` against the body region at all) — combined + // with split_frame_members() already having validated this member's + // own acf_msg_length against the buffer before ever handing it to + // decode_acf_abb()/_gbb(), decode_and_dispatch()'s own decode call + // cannot actually fail for anything split_frame_members() has already + // accepted. DispatchErrc::unknown_bus (and this loop's own `decoded` + // check) is therefore unreachable through this exact pairing today — + // kept for classifier completeness (the same "not exercised by this + // file's own call sites, but still a real, correct branch" precedent + // admit_and_classify()'s own Pending/Cancellation branches already + // establish) in case a future, stricter acf::decode_acf_abb()/_gbb() + // makes it reachable. This test pins the CURRENT, lenient, documented + // behavior instead: the identical stimulus c-RCP rejects with is + // accepted here (a genuine, disclaimed conformance delta, not a bug). + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + std::vector raw(acf::kAcfCommonHeaderLen, 0); + raw[0] = static_cast(acf::kAcfMsgTypeAbb << 1); // type=ABB, len MSB=0 + raw[1] = static_cast(acf::kAcfCommonHeaderLen / 4); // 2 quadlets: 0 body octets + raw[2] = 0x40; // pad[7:6] = 01 -> pad=1, but body_len computes to 0 + raw[3] = static_cast(mock::kGpioByteBusId); // byte_bus_id low octet + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, raw, results) == 1); + REQUIRE(results[0].result != mock::make_error_code(mock::DispatchErrc::unknown_bus)); + REQUIRE(results[0].byte_bus_id == mock::kGpioByteBusId); +} + +TEST_CASE("dispatch_frame assigns REQ-CANCEL-012 chain_group/chain_position to Chained members " + "in frame order, and marks each one's predecessor already done", + "[mock][frame][REQ-CANCEL-012]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + // member0: an ordinary Standard write — this frame's own chain anchor + // (chain_group 1, chain_position 0), executes immediately. + auto anchor_req = standard_request(mock::kGpioByteBusId, /*write=*/true, + static_cast(WriteSemantics::Or)); + auto anchor_frame = acf::encode_acf_abb(anchor_req, gpio::encode_gpio_payload(0)); + // member1/member2: two successive Chained (0x01) followers, both + // continue-on-error (cs=false), both addressed to GPIO. + auto chained1 = request::encode_chained_member(mock::kGpioByteBusId, /*chain_exec_delay=*/0, + /*cs=*/false, /*transaction_num=*/101); + auto chained2 = request::encode_chained_member(mock::kGpioByteBusId, /*chain_exec_delay=*/0, + /*cs=*/false, /*transaction_num=*/102); + + std::vector combined = anchor_frame; + combined.insert(combined.end(), chained1.begin(), chained1.end()); + combined.insert(combined.end(), chained2.begin(), chained2.end()); + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, combined, results) == 3); + + REQUIRE_FALSE(results[0].result); // anchor executed + REQUIRE(results[1].result == mock::make_error_code(mock::DispatchErrc::pending)); + REQUIRE(results[2].result == mock::make_error_code(mock::DispatchErrc::pending)); + REQUIRE(server.pending_count(mock::kGpioByteBusId) == 2); + + server::Endpoint* ep = server.admission(mock::kGpioByteBusId); + REQUIRE(ep != nullptr); + const server::PendingRequest* slot1 = nullptr; + const server::PendingRequest* slot2 = nullptr; + for (size_t i = 0; i < server::kMaxPending; ++i) { + if (const server::PendingRequest* s = ep->pending(i)) { + if (s->transaction_num == 101) slot1 = s; + if (s->transaction_num == 102) slot2 = s; + } + } + REQUIRE(slot1 != nullptr); + REQUIRE(slot2 != nullptr); + REQUIRE(slot1->chain_group != 0); + REQUIRE(slot1->chain_group == slot2->chain_group); + REQUIRE(slot1->chain_position == 1); + REQUIRE(slot2->chain_position == 2); + // Frame-splitting dispatch runs strictly in order, so by the time each + // Chained member's own admission finishes, its predecessor (the + // previous loop iteration) has already fully run — predecessor_done is + // therefore already true, only chain_exec_delay itself still gates it. + REQUIRE(slot1->predecessor_done); + REQUIRE(slot2->predecessor_done); +} + +TEST_CASE("A ClearSingle member routed through dispatch_frame() cancels its target AND cascades " + "REQ-CANCEL-012's chain cascade to every successor at or after its own position", + "[mock][frame][REQ-CANCEL-012]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + auto anchor_frame = acf::encode_acf_abb( + standard_request(mock::kGpioByteBusId, /*write=*/true, static_cast(WriteSemantics::Or)), + gpio::encode_gpio_payload(0)); + auto chained1 = request::encode_chained_member(mock::kGpioByteBusId, 0, /*cs=*/false, /*transaction_num=*/101); + auto chained2 = request::encode_chained_member(mock::kGpioByteBusId, 0, /*cs=*/false, /*transaction_num=*/102); + + std::vector combined = anchor_frame; + combined.insert(combined.end(), chained1.begin(), chained1.end()); + combined.insert(combined.end(), chained2.begin(), chained2.end()); + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, combined, results) == 3); + REQUIRE(server.pending_count(mock::kGpioByteBusId) == 2); + + // Cancel transaction_num 101 (chain_position 1) — REQ-CANCEL-012's own + // cascade rule must also remove transaction_num 102 (chain_position 2, + // >= 1), even though the ClearSingle itself only ever names 101. + auto clear_frame = request::encode_clear_single(mock::kGpioByteBusId, /*clear_transaction_num=*/101, + /*transaction_num=*/200); + std::vector clear_results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, clear_frame, clear_results) == 1); + REQUIRE(clear_results[0].result == mock::make_error_code(mock::DispatchErrc::cancelled)); + REQUIRE_FALSE(clear_results[0].response.rsp); // Canceled: no wire response (only NotFound builds one) + REQUIRE(server.pending_count(mock::kGpioByteBusId) == 0); +} + +TEST_CASE("A ClearSingle member naming an untracked transaction_num reports a genuine " + "REQUEST_NOT_FOUND error response", + "[mock][frame][REQ-CANCEL-012]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + auto clear_frame = request::encode_clear_single(mock::kGpioByteBusId, /*clear_transaction_num=*/9, + /*transaction_num=*/1); + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, clear_frame, results) == 1); + REQUIRE(results[0].result == mock::make_error_code(mock::DispatchErrc::cancelled)); + REQUIRE(acf::response_kind_of(results[0].response) == acf::ResponseKind::ErrorResponse); + REQUIRE(results[0].response_payload.size() == 1); + REQUIRE(results[0].response_payload[0] == static_cast(acf::WireErrorCode::RequestNotFound)); +} + +TEST_CASE("dispatch_frame reports chain_error for a Chained member with no predecessor within " + "its own frame", + "[mock][frame][REQ-CANCEL-012]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + auto chained = request::encode_chained_member(mock::kGpioByteBusId, 0, /*cs=*/false, /*transaction_num=*/1); + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, chained, results) == 1); + REQUIRE(results[0].result == mock::make_error_code(mock::DispatchErrc::chain_error)); + REQUIRE(acf::response_kind_of(results[0].response) == acf::ResponseKind::ErrorResponse); + REQUIRE(results[0].response_payload[0] == static_cast(acf::WireErrorCode::ChainError)); + REQUIRE(server.pending_count(mock::kGpioByteBusId) == 0); +} + +TEST_CASE("dispatch_frame aborts a Chained member whose predecessor errored and whose own cs " + "selected abort-on-error, and latches the abort for every member after it", + "[mock][frame][REQ-CANCEL-012]") { + // Server deliberately left HW_UNCONFIGURED: member0 (a Standard GPIO + // write) is rejected outright by operational_requests_allowed(), + // giving this frame a genuinely errored first member for member1's own + // cs=true (abort-on-error) to react to. + mock::Server server; + + auto anchor_frame = acf::encode_acf_abb( + standard_request(mock::kGpioByteBusId, /*write=*/true, static_cast(WriteSemantics::Or)), + gpio::encode_gpio_payload(0)); + auto chained1 = request::encode_chained_member(mock::kGpioByteBusId, 0, /*cs=*/true, /*transaction_num=*/11); + // member2's own cs is false — proves the chain_aborted LATCH, not just + // this member's own cs, is what drives the abort once it has fired. + auto chained2 = request::encode_chained_member(mock::kGpioByteBusId, 0, /*cs=*/false, /*transaction_num=*/12); + + std::vector combined = anchor_frame; + combined.insert(combined.end(), chained1.begin(), chained1.end()); + combined.insert(combined.end(), chained2.begin(), chained2.end()); + + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, combined, results) == 3); + + REQUIRE(results[0].result == make_error_code(regmap::RegMapErrc::request_rejected)); + REQUIRE(results[1].result == mock::make_error_code(mock::DispatchErrc::chain_aborted)); + REQUIRE(acf::response_kind_of(results[1].response) == acf::ResponseKind::ErrorResponse); + REQUIRE(results[1].response_payload[0] == static_cast(acf::WireErrorCode::ChainAborted)); + REQUIRE(results[2].result == mock::make_error_code(mock::DispatchErrc::chain_aborted)); + REQUIRE(server.pending_count(mock::kGpioByteBusId) == 0); // neither chained member was ever admitted +} + +TEST_CASE("dispatch_frame_e2e independently CRC-verifies each member against its own addressed " + "endpoint (REQ-E2E-033) — one member's CRC failure does not affect its sibling", + "[mock][frame][e2e][REQ-E2E-033]") { + mock::Server server; + configure_gpio_all_outputs(server); + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.registers().generic_configs[mock::kGpioEndpointId - 1].ep_req_crc_enable = true; + server.registers().generic_configs[mock::kSpiEndpointId - 1].ep_req_crc_enable = true; + server.set_spi_poci(/*channel=*/0, {0xAB}); + + const auto stream_id = e2e_stream(0x8001); + auto good = wrap_write(stream_id, mock::kGpioByteBusId, static_cast(WriteSemantics::Or), + /*transaction_num=*/1, gpio::encode_gpio_payload(0x0000'00FF)); + auto bad = wrap_write(stream_id, mock::kSpiByteBusId, /*evt_op=*/0, /*transaction_num=*/2, + std::vector{0x01, 0x00, 0x00, 0x00}); // quadlet-aligned, see this section's own note + bad[bad.size() - 1] ^= 0xFF; // corrupt SPI member's own trailing CRC byte + + std::vector combined = good; + combined.insert(combined.end(), bad.begin(), bad.end()); + + std::vector results; + REQUIRE(server.dispatch_frame_e2e(0, stream_id, /*sequence_num=*/0, combined, results) == 2); + + REQUIRE_FALSE(results[0].result); + REQUIRE(results[0].byte_bus_id == mock::kGpioByteBusId); + REQUIRE(server.gpio().read() == 0x0000'00FF); + + REQUIRE(results[1].result == e2e::make_error_code(e2e::E2eErrc::crc_error)); + REQUIRE(results[1].byte_bus_id == mock::kSpiByteBusId); + REQUIRE(acf::response_kind_of(results[1].response) == acf::ResponseKind::ErrorResponse); + REQUIRE(results[1].response_payload[0] == static_cast(acf::WireErrorCode::PociFailure)); +} + +TEST_CASE("dispatch_frame_e2e evaluates its sequence gate exactly ONCE for the whole frame, " + "rejecting every member together on a replay", + "[mock][frame][e2e][REQ-E2E-028][REQ-E2E-029]") { + mock::Server server; + configure_gpio_all_outputs(server); + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.registers().generic_configs[mock::kGpioEndpointId - 1].ep_req_crc_enable = true; + server.registers().generic_configs[mock::kSpiEndpointId - 1].ep_req_crc_enable = true; + server.set_spi_poci(/*channel=*/0, {0xCD}); + + const auto stream_id = e2e_stream(0x8002); + regmap::RequestStreamConfig cfg; + cfg.stream_id = stream_id; + cfg.rx_enforce_seq = true; + REQUIRE(server.set_request_stream_cfg({cfg})); + + auto m1 = wrap_write(stream_id, mock::kGpioByteBusId, static_cast(WriteSemantics::Or), + /*transaction_num=*/1, gpio::encode_gpio_payload(0x0000'000F)); + auto m2 = wrap_write(stream_id, mock::kSpiByteBusId, /*evt_op=*/0, /*transaction_num=*/2, + std::vector{0x02, 0x00, 0x00, 0x00}); // quadlet-aligned + std::vector combined = m1; + combined.insert(combined.end(), m2.begin(), m2.end()); + + std::vector first; + REQUIRE(server.dispatch_frame_e2e(0, stream_id, /*sequence_num=*/7, combined, first) == 2); + REQUIRE_FALSE(first[0].result); + REQUIRE_FALSE(first[1].result); + REQUIRE(server.gpio().read() == 0x0000'000F); + + // A second frame reusing the SAME sequence_num (fwd_distance 0) must + // reject BOTH members together, before either is even CRC-checked — + // proves the gate ran once per FRAME, not once per member (a per-member + // gate would spuriously reject only the 2nd+ member against the 1st + // member's own just-advanced tracker state). + auto m3 = wrap_write(stream_id, mock::kGpioByteBusId, static_cast(WriteSemantics::Or), + /*transaction_num=*/3, gpio::encode_gpio_payload(0xFFFF'FFFF)); + auto m4 = wrap_write(stream_id, mock::kSpiByteBusId, /*evt_op=*/0, /*transaction_num=*/4, + std::vector{0x03, 0x00, 0x00, 0x00}); // quadlet-aligned + std::vector combined2 = m3; + combined2.insert(combined2.end(), m4.begin(), m4.end()); + + std::vector second; + REQUIRE(server.dispatch_frame_e2e(0, stream_id, /*sequence_num=*/7, combined2, second) == 2); + REQUIRE(second[0].result == mock::make_error_code(mock::DispatchErrc::seq_error)); + REQUIRE(second[1].result == mock::make_error_code(mock::DispatchErrc::seq_error)); + REQUIRE_FALSE(second[0].response.rsp); + REQUIRE_FALSE(second[1].response.rsp); + REQUIRE(server.gpio().read() == 0x0000'000F); // unchanged — never dispatched +} + +TEST_CASE("dispatch_frame_e2e routes a member split across an intermediate and a final fragment " + "(REQ-E2E-038/039) — a strict superset of c-RCP's own dispatch_frame_e2e(), which has no " + "frame-level fragment-aware counterpart at all", + "[mock][frame][e2e][fragment][REQ-E2E-038][REQ-E2E-039]") { + // I2C rather than GPIO: build_fragments() itself does not pad each + // wire segment to a quadlet boundary (never needed before this batch — + // every pre-existing call site fed its own output to dispatch_e2e_ + // fragment() as an ISOLATED buffer, never concatenated with a sibling + // member the way this test's own combined frame needs). GPIO's own + // payload is fixed at exactly 4 bytes (gpio.hpp's own §13.7.4.3 rule), + // too small to split into two nonzero, independently quadlet-aligned + // segments at all. I2C's own payload has no such fixed-width + // constraint (rcp/i2c.hpp — a raw byte stream, any length), so an + // 8-byte payload split into two 4-byte segments (max_fragment_payload + // == 4) keeps BOTH segments naturally quadlet-aligned (header(8) + 4 == + // 12 octets for the intermediate fragment; header(8) + 4 + CRC(4) == 16 + // octets for the final one) with no extra padding logic needed at all. + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + server.registers().generic_configs[mock::kI2cEndpointId - 1].ep_req_crc_enable = true; + + const auto stream_id = e2e_stream(0x8003); + regmap::RequestStreamConfig cfg; + cfg.stream_id = stream_id; + REQUIRE(server.set_request_stream_cfg({cfg})); + + const std::vector payload{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + auto frames = build_fragments(stream_id, mock::kI2cByteBusId, /*transaction_num=*/5, /*write=*/true, + /*evt_op=*/0, /*final_read_size=*/0, payload, /*max_fragment_payload=*/4); + REQUIRE(frames.size() == 2); + std::vector combined = frames[0]; + combined.insert(combined.end(), frames[1].begin(), frames[1].end()); + + // Both fragments live in the SAME frame, so this one dispatch_frame_e2e() + // call already runs member1 (the completing final fragment) to + // completion by the time it returns — unlike dispatch_e2e_fragment()'s + // own two-SEPARATE-calls tests above, there is no "in between" state to + // observe mid-frame; only the two members' own final results are. + std::vector results; + REQUIRE(server.dispatch_frame_e2e(0, stream_id, /*sequence_num=*/0, combined, results) == 2); + + REQUIRE(results[0].result == mock::make_error_code(mock::DispatchErrc::fragment_pending)); + REQUIRE_FALSE(results[0].response.rsp); + + REQUIRE_FALSE(results[1].result); + REQUIRE(acf::response_kind_of(results[1].response) == acf::ResponseKind::ReadResponse); + REQUIRE(server.i2c().last_sent() == payload); // the reassembled 8-byte payload reached I2cEndpoint whole +}