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 +}