diff --git a/include/rcp/fragment.hpp b/include/rcp/fragment.hpp new file mode 100644 index 0000000..9fea87d --- /dev/null +++ b/include/rcp/fragment.hpp @@ -0,0 +1,423 @@ +// fusa:req REQ-FRAG-001 +// fusa:req REQ-FRAG-002 +// fusa:req REQ-FRAG-003 +// fusa:req REQ-FRAG-004 +// fusa:req REQ-FRAG-005 +// fusa:req REQ-FRAG-006 +// fusa:req REQ-FRAG-007 +// fusa:req REQ-FRAG-008 +// fusa:req REQ-FRAG-009 +// fusa:req REQ-FRAG-010 +// fusa:req REQ-FRAG-011 +// fusa:req REQ-FRAG-012 +// fusa:req REQ-FRAG-013 +// fusa:req REQ-FRAG-014 +// fusa:req REQ-FRAG-015 +// fusa:req REQ-FRAG-016 +// fusa:req REQ-FRAG-017 +// fusa:req REQ-FRAG-018 + +// Multi-AVTPDU message fragmentation and reassembly for the TC18 Remote +// Control Protocol wire layer (TC18 §13.7.11.3) — brand new to cpp-RCP. +// +// ROADMAP.md "Phase 17" (cpp-RCP issue #129): cpp-RCP made an explicit +// no-go call on fragmentation at milestone 52 (v2.8.0) — rcp/can.hpp, +// rcp/spi.hpp, rcp/uart.hpp, rcp/acf.hpp, and rcp/avtp.hpp all still carry +// "fragmentation deferred" comments, and rcp/regmap.hpp's kOptFragmentation +// capability bit is reserved and never set. This module is the generic +// primitive that closes the groundwork gap: it interprets ACF's own +// dual-purpose read_size_or_segment_num field and ms bit (rcp/acf.hpp's +// AcfMessageInfo already reserves both wire slots, per that header's own +// comment) without depending on rcp/acf.hpp, rcp/avtp.hpp, or any endpoint +// header at all — same "own small pure primitive, operate on caller-owned +// data" layering discipline rcp/deadline.hpp and rcp/watchdog.hpp already +// use. Wiring this primitive into rcp/can.hpp/rcp/mock.hpp real dispatch is +// explicitly OUT of scope for this pass (left for Phase 3/4); this header +// is the fragmentation/reassembly primitive only. +// +// Ported from c-RCP's include/rcp/fragment.h + src/fragment.c, this +// project's RC5-spec-conformant reference implementation for this module. +// No spec prose, bit layout, or numeric constant is reproduced here. +// +// ── Wire semantics this module encodes/decodes against ───────────────────── +// +// A single logical ACF payload — what a caller would otherwise hand +// straight to an ACF_ABB/ACF_GBB encoder as one contiguous buffer — is +// instead split into an ordered sequence of one or more fragments, each +// sharing the same acf_msg_type/byte_bus_id/op/transaction_num as the +// logical message they jointly carry. Every fragment but the last sets +// ms=true and carries, in read_size_or_segment_num, this fragment's own +// zero-based index within the sequence (Segment::segment_num — see +// plan() below). The final fragment sets ms=false; per ACF's own field +// semantics that reverts read_size_or_segment_num to its ordinary +// non-fragmentation meaning, so this module never writes a segment number +// for the final fragment — a caller fills that field in with whatever +// value it ordinarily carries for the message kind involved. A message +// that never needed fragmenting in the first place is simply a +// one-fragment sequence: ms=false on its only (and therefore also final) +// fragment, indistinguishable on the wire from ordinary single-frame +// traffic — this module's mechanism is a strict superset of "no +// fragmentation", not a parallel wire format. +// +// segment_num is 12 bits wide on the wire (ACF's +// read_size_or_segment_num[11:0]), giving 4096 distinct values (0..4095) — +// see kMaxIntermediateSegments below. +// +// ── Reassembly: a small caller-owned accumulator, not global state ───────── +// +// Reassembler is a small, caller-owned, explicitly-constructed piece of +// mutable state, one instance per request stream a caller is reassembling +// fragments for. It bounds the reassembled payload to a caller-supplied +// max_total_len, failing closed with ReasmResult::kErrTooLarge rather than +// growing without bound. +// +// Segment ordering is enforced strictly: the first ms=true fragment fed to +// a freshly-constructed-or-reset Reassembler must carry segment_num == 0, +// and every subsequent ms=true fragment must carry exactly one more than +// the previous fragment's segment_num, or ReasmResult::kErrOutOfOrder is +// returned and the fragment is not appended. +// +// ── Fixed-capacity from day one (cleaner than c-RCP's own C version) ─────── +// +// c-RCP's own rcp_fragment_reassembler_t accumulates into a realloc()-grown +// heap buffer (src/fragment.c's append() calls rcp_realloc()), bounded only +// by the caller-supplied max_total_len at each feed() call — a genuine +// allocation-failure path (RCP_FRAGMENT_REASM_ERR_ALLOC) exists purely +// because that growth can fail. cpp-RCP has no allocation seam yet (that +// lands in a later phase) and this project's own ASIL-D-oriented +// no-dynamic-allocation convention (already applied to +// RCP_RESPQUEUE_MAX_ENTRIES/RCP_LOAN_POOL_MAX_ENTRIES on the c-RCP side, and +// to this same phase's rcp/respqueue.hpp and rcp/loan.hpp ports) argues for +// going further here, not just matching c-RCP: Reassembler below is backed +// by a single std::array member — a +// plain, embedded, compile-time-sized buffer, not a pointer to +// realloc()-grown heap storage. There is therefore no allocation to fail on +// the reassembly path at all, and no ReasmResult analogous to +// RCP_FRAGMENT_REASM_ERR_ALLOC: any attempt to accumulate past the fixed +// capacity — exactly like any attempt to exceed the caller's own +// max_total_len — is reported as ReasmResult::kErrTooLarge, the state left +// untouched. This is a deliberate improvement over c-RCP's own design, not +// a regression from it (see this file's own kDefaultReassemblyCapacity +// comment for the bound's provenance). +// +// ── The oversized-reassembly lesson (c-RCP issues #614/#616) ─────────────── +// +// c-RCP's own history here is directly instructive. Once fragmentation +// landed (PRs #612/#613), issue #614 (fixed by #616) found that a request +// whose EVERY fragment, and whose combined E2E CRC, were genuinely valid +// could still reassemble successfully and then vanish with no observable +// error: the reassembled payload didn't fit back into a single +// ACF_ABB/ACF_GBB frame (RCP_ACF_ABB_MAX_PAYLOAD/RCP_ACF_GBB_MAX_PAYLOAD — +// acf_msg_length's own 9-bit wire-format ceiling, TC18 §13.6), the +// downstream re-encode call failed, and the caller (c-RCP's mock.c +// dispatch layer) originally just returned "rejected" with no response +// body constructed at all — a client left only able to time out. The fix +// was NOT in fragment.c/fragment.h at all (this module's own reassembler +// already reported RCP_FRAGMENT_REASM_COMPLETE correctly, with the full, +// correctly-reassembled — if oversized — payload); the fix was entirely in +// the caller's own dispatch code, which had to check the reassembled +// result against the frame-format ceiling BEFORE attempting to re-encode +// it, and build a real Table 27 RCP_ERROR_REQUEST_REJECTED response when +// that check fails, rather than silently dropping the request. +// +// This module is deliberately as ACF-agnostic in C++ as it is in C — it +// has no RCP_ACF_ABB_MAX_PAYLOAD/RCP_ACF_GBB_MAX_PAYLOAD constant of its +// own to check reassembled results against, matching rcp/acf.hpp's own +// kAcfAbbMaxPayload/kAcfGbbMaxPayload staying in that header, not this one. +// The lesson this module DOES bake in from day one: Reassembler::data()/ +// size() are fully inspectable after every ReasmResult::kComplete, so a +// future caller wiring this primitive into a real dispatch path (Phase +// 3/4, explicitly out of scope here) has everything it needs to run that +// same size check itself, immediately after kComplete and before ever +// attempting to re-encode — the same check c-RCP's mock.c now performs. +// Silently dropping an oversized-but-successfully-reassembled result is a +// caller bug, not something this primitive can prevent on its own (it has +// no framing knowledge to know what "too large" even means) — but this +// primitive must never make that bug easy to fall into by hiding or +// truncating the reassembled result itself, and it does not: get()/data()/ +// size() always report the true, complete, reassembled length. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace fragment { + +// ── Errors (encode/plan side) ─────────────────────────────────────────────── + +enum class FragmentErrc : int { + disabled = 1, // max_fragment_payload == 0 and the payload does not fit in a single fragment + too_many_segments = 2, // the split would need more intermediate segments than segment_num's 12-bit width can address + bad_segment_count = 3, // segment_count passed to plan() does not match plan_count()'s answer for the same inputs +}; + +inline const std::error_category& fragment_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.fragment"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case FragmentErrc::disabled: return "rcp/fragment: fragmentation disabled for this max_fragment_payload"; + case FragmentErrc::too_many_segments: return "rcp/fragment: payload needs more segments than segment_num can address"; + case FragmentErrc::bad_segment_count: return "rcp/fragment: segment_count does not match plan_count()"; + default: return "rcp/fragment: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(FragmentErrc e) noexcept { + return {static_cast(e), fragment_category()}; +} + +inline const std::error_code ErrDisabled = make_error_code(FragmentErrc::disabled); +inline const std::error_code ErrTooManySegments = make_error_code(FragmentErrc::too_many_segments); +inline const std::error_code ErrBadSegmentCount = make_error_code(FragmentErrc::bad_segment_count); + +// The largest number of ms=true (intermediate) fragments a single +// reassembled message can be split into: segment_num is 12 bits wide +// (ACF's read_size_or_segment_num[11:0]), giving 4096 distinct values +// (0..4095) — every one usable by an intermediate fragment, since (unlike +// the final fragment) an intermediate fragment's segment_num is always +// this module's own sequence index. Ported from c-RCP's +// RCP_FRAGMENT_MAX_INTERMEDIATE_SEGMENTS. +constexpr size_t kMaxIntermediateSegments = 4096; + +// ── Planning (encode side) ────────────────────────────────────────────────── + +// One planned fragment: which slice [offset, offset+len) of the original +// payload it carries, whether it is an intermediate (ms=true) or the final +// (ms=false) fragment, and — meaningful only when ms is true — this +// fragment's own segment_num. A final fragment's segment_num is left at 0: +// that wire slot means something else once ms=false, and it is the +// caller's job to fill it in for the message kind involved. +struct Segment { + size_t offset = 0; + size_t len = 0; + bool ms = false; + uint16_t segment_num = 0; +}; + +// The number of fragments plan() would produce for payload_len octets split +// into fragments of at most max_fragment_payload octets each (every +// fragment but the last carries exactly max_fragment_payload octets). A +// payload_len of 0 always plans to exactly one (empty, ms=false) fragment, +// regardless of max_fragment_payload. A payload_len that already fits +// within max_fragment_payload likewise always plans to exactly one +// (ms=false) fragment. Returns 0 — a value no valid plan ever produces — +// if max_fragment_payload == 0 and payload_len exceeds it +// (FragmentErrc::disabled, see plan()), or if the resulting split would +// need more than kMaxIntermediateSegments intermediate fragments to +// represent (FragmentErrc::too_many_segments). +inline size_t plan_count(size_t payload_len, size_t max_fragment_payload) noexcept { + if (payload_len == 0) return 1; + if (max_fragment_payload == 0) return 0; + if (payload_len <= max_fragment_payload) return 1; + + size_t count = (payload_len + max_fragment_payload - 1) / max_fragment_payload; + size_t intermediate = count - 1; // every segment but the last is intermediate + if (intermediate > kMaxIntermediateSegments) return 0; + + return count; +} + +// Fills out_segments[0..segment_count) with this module's own greedy, +// fixed-size splitting plan for payload_len octets split into fragments of +// at most max_fragment_payload octets each — see plan_count() above. +// segment_count must equal plan_count(payload_len, max_fragment_payload) +// exactly (ErrBadSegmentCount otherwise, out_segments left untouched). +// Returns ErrDisabled or ErrTooManySegments under the same conditions +// plan_count() returns 0 for (checked before the segment_count match, so +// either can be diagnosed from the same call). On success (empty +// std::error_code), a caller assembles fragment i's own ACF payload as the +// original payload's [out_segments[i].offset, out_segments[i].offset + +// out_segments[i].len) slice and encodes it with ms = out_segments[i].ms +// and, iff out_segments[i].ms, read_size_or_segment_num = +// out_segments[i].segment_num. +inline std::error_code plan(size_t payload_len, size_t max_fragment_payload, + Segment* out_segments, size_t segment_count) noexcept { + size_t expected = plan_count(payload_len, max_fragment_payload); + + if (expected == 0) { + return (max_fragment_payload == 0) ? ErrDisabled : ErrTooManySegments; + } + if (segment_count != expected) return ErrBadSegmentCount; + + if (expected == 1) { + out_segments[0] = Segment{0, payload_len, false, 0}; + return {}; + } + + size_t off = 0; + for (size_t i = 0; i + 1 < expected; i++) { + out_segments[i] = Segment{off, max_fragment_payload, true, static_cast(i)}; + off += max_fragment_payload; + } + + out_segments[expected - 1] = Segment{off, payload_len - off, false, 0}; + + return {}; +} + +// ── Reassembly (decode side) ──────────────────────────────────────────────── + +enum class ReasmResult { + kContinue = 0, // fragment accepted; more expected + kComplete = 1, // fragment accepted; reassembly finished -- call data()/size() + kErrOutOfOrder = 2, // an ms=true fragment's segment_num was not the expected next value; the fragment was not appended + kErrTooLarge = 3, // appending this fragment would exceed max_total_len (or this Reassembler's own fixed capacity); the fragment was not appended +}; + +// Human-readable, non-null, distinct message for every ReasmResult value, +// including unrecognized ones. Mirrors c-RCP's own +// rcp_fragment_reasm_result_string(); kept as a standalone function (rather +// than folded into an std::error_code category, as FragmentErrc's plan() +// errors are above) because ReasmResult's two non-error outcomes +// (kContinue/kComplete) have no analog in std::error_code's +// zero-means-success convention. +inline const char* to_string(ReasmResult r) noexcept { + switch (r) { + case ReasmResult::kContinue: return "rcp/fragment: fragment accepted, more expected"; + case ReasmResult::kComplete: return "rcp/fragment: fragment accepted, reassembly complete"; + case ReasmResult::kErrOutOfOrder: return "rcp/fragment: out-of-order segment_num"; + case ReasmResult::kErrTooLarge: return "rcp/fragment: reassembled payload would exceed max_total_len"; + default: return "rcp/fragment: unknown result"; + } +} + +// The compile-time capacity backing every Reassembler's own embedded +// std::array buffer — see this file's +// header comment ("Fixed-capacity from day one") for why this module owns +// no heap-growable storage at all. Sized with headroom over c-RCP's own +// documented worst real case (TC18 §13.7.11.3, CAN XL's 2048-data-octet +// write producing a 2058-octet combined reassembled payload — the exact +// figure c-RCP issue #614/#616's own writeup cites, and the same figure +// that turned out to exceed RCP_ACF_ABB_MAX_PAYLOAD, motivating that fix) +// while also covering this module's two other documented deferred callers +// (UART RX FIFO drains, discovery general-register-slice reads), neither of +// which needs anywhere close to this many octets. +constexpr size_t kDefaultReassemblyCapacity = 4096; + +// Caller-owned reassembly accumulator for one request stream's worth of +// in-flight fragmented messages — see this file's header comment. One +// instance per stream a caller is reassembling fragments for. +class Reassembler { +public: + // Constructs r as empty/not-collecting, bounding the eventual + // reassembled payload to max_total_len octets (further capped, always, + // by this Reassembler's own fixed kDefaultReassemblyCapacity — see the + // file header). max_total_len defaults to that same fixed capacity. + explicit Reassembler(size_t max_total_len = kDefaultReassemblyCapacity) noexcept + : max_total_len_(max_total_len) {} + + // Discards any in-progress reassembly and any previously reassembled + // payload, returning this Reassembler to the same freshly-constructed + // state (same max_total_len it already had) — safe to call between + // logical messages to reuse one Reassembler for a whole stream's + // lifetime, and safe to call at any point (mid-reassembly or not) to + // abandon whatever has been collected so far. + void reset() noexcept { + collecting_ = false; + expected_segment_num_ = 0; + len_ = 0; + } + + // Feeds one already-decoded ACF fragment's ms bit and payload into + // this Reassembler. segment_num is read_size_or_segment_num's raw wire + // value; it is consulted only when ms is true — pass whatever value + // the frame actually carried when ms is false, it is ignored. payload + // may be nullptr iff payload_len == 0. + // + // A message that was never fragmented in the first place is fed as a + // single ms=false fragment to a freshly-constructed-or-reset + // Reassembler: this yields kComplete immediately, with payload as the + // whole reassembled result, without ever entering the collecting + // state. Otherwise, the first fragment fed must be an ms=true fragment + // carrying segment_num == 0 (any other segment_num yields + // kErrOutOfOrder, state left untouched); every subsequent ms=true + // fragment must carry exactly one more than the previous fragment's + // segment_num (same error otherwise); a final ms=false fragment + // completes the sequence regardless of its own segment_num field + // value. kErrTooLarge is returned, and state is left unchanged (this + // fragment is not appended), if accepting payload_len more octets + // would exceed max_total_len OR this Reassembler's own fixed + // kDefaultReassemblyCapacity. + ReasmResult feed(bool ms, uint16_t segment_num, const uint8_t* payload, + size_t payload_len) noexcept { + if (!collecting_) { + if (!fits(payload_len)) return ReasmResult::kErrTooLarge; + + if (!ms) { + append(payload, payload_len); + return ReasmResult::kComplete; + } + + if (segment_num != 0) return ReasmResult::kErrOutOfOrder; + + append(payload, payload_len); + collecting_ = true; + expected_segment_num_ = 1; + return ReasmResult::kContinue; + } + + if (ms && segment_num != expected_segment_num_) return ReasmResult::kErrOutOfOrder; + if (!fits(payload_len)) return ReasmResult::kErrTooLarge; + + append(payload, payload_len); + + if (ms) { + expected_segment_num_ = static_cast(expected_segment_num_ + 1); + return ReasmResult::kContinue; + } + + collecting_ = false; + return ReasmResult::kComplete; + } + + // True iff this Reassembler currently has a fragment sequence in + // progress (has accepted at least one ms=true fragment since the last + // completed reassembly or reset). A pure query. + bool is_collecting() const noexcept { return collecting_; } + + // Valid only immediately after feed() has returned kComplete + // (undefined otherwise). The pointer is owned by this Reassembler, + // valid until the next feed()/reset() call — not transferred to the + // caller, since this payload was assembled out of possibly-several + // original fragment buffers and has no single one of them to borrow + // from. May be nullptr iff size() == 0. + const uint8_t* data() const noexcept { return len_ == 0 ? nullptr : buf_.data(); } + size_t size() const noexcept { return len_; } + +private: + bool fits(size_t append_len) const noexcept { + size_t cap = std::min(max_total_len_, kDefaultReassemblyCapacity); + return append_len <= cap - len_; + } + + void append(const uint8_t* payload, size_t append_len) noexcept { + if (append_len == 0) return; + std::copy(payload, payload + append_len, buf_.begin() + static_cast(len_)); + len_ += append_len; + } + + std::array buf_{}; + size_t len_ = 0; + size_t max_total_len_ = 0; + bool collecting_ = false; + uint16_t expected_segment_num_ = 0; +}; + +} // namespace fragment +} // namespace rcp + +// Enable std::error_code construction from rcp::fragment::FragmentErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/loan.hpp b/include/rcp/loan.hpp index a08a229..0328302 100644 --- a/include/rcp/loan.hpp +++ b/include/rcp/loan.hpp @@ -4,6 +4,8 @@ // fusa:req REQ-LOAN-004 // fusa:req REQ-LOAN-005 // fusa:req REQ-LOAN-006 +// fusa:req REQ-LOAN-008 +// fusa:req REQ-LOAN-009 // BufferPool — zero-copy payload loaning for AVTPDU-framed request/response // construction, via a pool of pre-allocated byte vectors. @@ -30,10 +32,43 @@ // Zone/Command/Controller/Registry model rcp/rcp.hpp's own header comment // warns against building on) returns its buffer to the pool automatically // once it goes out of scope, or immediately via its own ret() method. +// +// ── Phase 17 fixed-capacity conversion (cpp-RCP issue #129) ───────────────── +// +// Ported from c-RCP's include/rcp/loan.h + src/loan.c, this project's +// RC5-spec-conformant reference implementation for this module: c-RCP's own +// rcp_loan_pool_t bounds its free-list bookkeeping to a fixed +// RCP_LOAN_POOL_MAX_ENTRIES (64) array embedded directly in the pool +// struct, not a realloc()-grown container — "once the free list already +// holds this many returned buffers, a further release simply frees the +// returned buffer outright instead of pooling it" (c-RCP's own loan.h +// comment). Only the ENTRY-COUNT bound is fixed on the c-RCP side; each +// individual pooled buffer's own payload bytes stay heap-allocated (sized +// by the caller's own runtime `size` argument, not a compile-time protocol +// constant) — c-RCP does not additionally cap payload size. +// +// This header previously diverged from that design in the one dimension +// that matters most for an ASIL-D-oriented no-dynamic-allocation-growth +// posture: BufferPool's own free list (`pool_`) was a plain +// `std::vector>>` with no capacity +// ceiling at all — unlike every other fixed-capacity table this codebase +// now uses (rcp/respqueue.hpp's kMaxEntries, this same phase), it could +// grow without bound as buffers were returned. Fixed below: `entries_` is +// now a `std::array` of exactly +// c-RCP's own RCP_LOAN_POOL_MAX_ENTRIES (64) — matching c-RCP's chosen +// entry-count bound exactly, not inventing a stricter or looser one — with +// each individual buffer's own payload bytes still heap-allocated exactly +// as c-RCP's own design leaves them (matching, not tightening, that half of +// the design). Once the free list already holds kPoolMaxEntries returned +// buffers, a further release() call frees the returned buffer outright +// (see loan()'s own release closure below) instead of growing the free +// list further — the same degradation c-RCP's own pool_append() documents, +// never a leak or corruption, only a forfeited reuse opportunity. #pragma once #include "rcp.hpp" +#include #include #include #include @@ -42,9 +77,16 @@ namespace rcp { namespace loan { +// Ported from c-RCP's RCP_LOAN_POOL_MAX_ENTRIES — see this file's own +// header comment for the fixed-capacity conversion this bound closes. +constexpr size_t kPoolMaxEntries = 64; + class BufferPool { public: - ~BufferPool() { close(); } + ~BufferPool() { + close(); + for (size_t i = 0; i < entries_len_; i++) delete entries_[i]; + } // loan returns a zeroed buffer of exactly size bytes, drawn from the // pool if a same-or-larger buffer is available for reuse, or freshly @@ -53,45 +95,69 @@ class BufferPool { if (closed_.load(std::memory_order_acquire)) return ErrClosed; if (size < 0) return std::make_error_code(std::errc::invalid_argument); - std::vector buf; + std::vector* raw = nullptr; { std::lock_guard lk(pool_mu_); - if (!pool_.empty()) { - buf = std::move(*pool_.back()); - pool_.pop_back(); + for (size_t i = 0; i < entries_len_; i++) { + if (entries_[i]->size() >= static_cast(size)) { + raw = entries_[i]; + entries_[i] = entries_[entries_len_ - 1]; + entries_len_--; + break; + } } } - buf.assign(static_cast(size), 0); // re-zero: no stale data leaks across reuse - auto* raw = new std::vector(std::move(buf)); + if (raw) { + raw->assign(static_cast(size), 0); // re-zero: no stale data leaks across reuse + } else { + raw = new std::vector(static_cast(size), 0); + } + out = std::make_unique( - *raw, + *raw, // Loan owns its own copy of the payload (rcp::Loan's own by-value contract) [this, raw]() mutable { std::lock_guard lk(pool_mu_); - pool_.push_back(std::unique_ptr>(raw)); + if (entries_len_ < kPoolMaxEntries) { + entries_[entries_len_] = raw; + entries_len_++; + return; + } + // Free list already at c-RCP's own RCP_LOAN_POOL_MAX_ENTRIES + // bound (see this file's header comment): no reuse + // possible, free the buffer outright rather than growing + // entries_ past its fixed capacity. + delete raw; }); return {}; } // close is idempotent — safe to call more than once, including while // Loans obtained before the call are still alive (their eventual - // release simply grows a pool nobody will draw from again). + // release simply grows a pool nobody will draw from again, up to + // kPoolMaxEntries). void close() { closed_.store(true, std::memory_order_release); } bool ok() const noexcept { return !closed_.load(std::memory_order_acquire); } // pooled_count reports how many released buffers are currently held // for reuse — introspection for tests, not part of the loan/release - // contract itself. + // contract itself. Always <= kPoolMaxEntries, by construction. size_t pooled_count() const { std::lock_guard lk(pool_mu_); - return pool_.size(); + return entries_len_; } private: std::atomic closed_{false}; mutable std::mutex pool_mu_; - std::vector>> pool_; + // Fixed-capacity free list (ported from c-RCP's RCP_LOAN_POOL_MAX_ENTRIES + // — see this file's own header comment): entries_ is a plain + // std::array of raw pointers, not a realloc()/std::vector-grown + // container — the SLOTS are static, each individual buffer's own bytes + // are not (matching c-RCP's own design exactly). + std::array*, kPoolMaxEntries> entries_{}; + size_t entries_len_ = 0; // always <= kPoolMaxEntries }; inline std::unique_ptr new_buffer_pool() { diff --git a/include/rcp/respqueue.hpp b/include/rcp/respqueue.hpp new file mode 100644 index 0000000..5598c3d --- /dev/null +++ b/include/rcp/respqueue.hpp @@ -0,0 +1,361 @@ +// fusa:req REQ-RMAP-059 +// fusa:req REQ-RMAP-061 +// fusa:req REQ-RMAP-062 +// fusa:req REQ-RMAP-063 +// fusa:req REQ-RMAP-064 +// fusa:req REQ-RMAP-065 +// fusa:req REQ-RMAP-085 + +// Per-response/acknowledge-stream transmit queue for the TC18 Remote +// Control Protocol wire layer (TC18 §12.7.9 Table 27, §12.9.4/§12.9.5) -- +// brand new to cpp-RCP. +// +// TC18 §12.7.9 Table 27 describes a per-response/ack-stream transmit queue +// with a configured memory reservation (queue_size, in 32-bit words) that +// responses and acknowledges from the RC Server's own endpoints are +// collected into for aggregated transmission. Nothing in cpp-RCP modeled +// that queue before this module: any per-endpoint inbound request queue +// elsewhere in this tree holds INBOUND requests awaiting execution, a +// structurally different concept -- this module is the OUTBOUND queue of +// framed responses/acknowledges awaiting transmission cpp-RCP was missing +// entirely. Ported from c-RCP's include/rcp/respqueue.h + src/respqueue.c, +// this project's RC5-spec-conformant reference implementation for this +// module. No spec prose or numeric constant is reproduced here. +// +// This module owns no register-map instance of its own (rcp/regmap.hpp's +// ResponseQueueConfig::queue_size/max_avtpdu_size are the configured values +// a caller reads and passes to RespQueue's constructor below -- the same +// "caller supplies already-classified inputs" convention this codebase +// uses throughout), no transport, and no knowledge of ACF/AVTP framing -- +// push()/pop() operate on caller-supplied byte buffers. +// +// ── queue_size (capacity_octets) overflow: evict-lowest-sequence_num ─────── +// +// TC18 §12.9.4 (response queue) and §12.9.5 (acknowledge queue) both give +// the same additional, mandatory rule: once a queue is completely full and +// not yet sent while the next response/acknowledge is delivered by an +// endpoint, the entry with the lowest sequence_num is removed from the +// queue to make space for the new one, and the overflow bit is set. +// push()/push_seq() implement exactly that -- entries are evicted in +// ascending sequence_num order (a genuine numeric minimum over every +// queued entry's own sequence_num each iteration, not merely the +// FIFO-oldest), repeatedly, as many times as needed to free enough BYTES +// for the incoming frame (a single eviction frees only its own evicted +// entry's own byte size, which may be smaller than the incoming frame). +// A frame whose own length exceeds capacity_octets outright is refused +// (queue unchanged, no eviction attempted) -- no amount of eviction could +// ever make room for it. +// +// ── kMaxEntries: a universal, fixed slot-count bound (no dynamic +// allocation of the queue's own storage) ───────────────────────────────── +// +// Ported from c-RCP's RCP_RESPQUEUE_MAX_ENTRIES (64, the same +// fixed-capacity convention c-RCP applies to every one of its own +// repeated-row tables): entries_ below is a std::array +// embedded directly in RespQueue, not a realloc()/std::vector-grown +// container, so RespQueue itself carries no heap allocation of its own +// regardless of how capacity_octets is configured (only each queued +// frame's own payload bytes remain individually heap-allocated, as a +// std::vector per entry -- see this file's own file comment for +// why that particular allocation stays out of scope: frame_len is +// caller/message-dependent, not a compile-time protocol constant). +// Consequently the eviction loop triggers on EITHER condition: +// capacity_octets != 0 and accepting frame_len would exceed the remaining +// byte budget, OR entries_len has reached kMaxEntries outright -- not only +// as a capacity_octets == 0 fallback. entries_len can never exceed +// kMaxEntries under any configuration, by construction. +// +// push() itself assigns sequence_num automatically, from an internal +// wrapping uint8_t counter; push_seq() is the same operation with an +// explicitly-supplied sequence_num, for a caller that already tracks its +// own (e.g. one that wants queue-internal sequence_num to agree with the +// eventual AVTPDU header's own sequence_num field). +// +// ── FIFO order, byte-budget capacity ──────────────────────────────────────── +// +// Entries drain in the order they were pushed. Capacity is primarily +// enforced in OCTETS, not entry count -- TC18's own queue_size register is +// a memory reservation ("assigned memory in 32bit words"), not a +// message-count limit. capacity_octets == 0 means unbounded byte budget +// (no reservation configured at all). +// +// ── Per-message Max_AVTPDUsize ceiling ────────────────────────────────────── +// +// REQ-RMAP-061 (TC18 §12.7.9): the maximum length of an AVTPDU sent by the +// RC Server is independently configurable and enforced -- a DIFFERENT +// ceiling than capacity_octets (the queue's own aggregate reservation), +// checked independently, per-message, on every push() call: a frame whose +// own length alone exceeds max_avtpdu_size_octets is refused (queue +// unchanged), never silently truncated or split (this module has no +// ACF/framing knowledge of its own to split with). A caller with a payload +// too large for one AVTPDU fragments it FIRST (max_fragment_payload() +// below, together with rcp/fragment.hpp's plan_count()/plan()) and pushes +// each resulting fragment as its own, individually-bounded push() call. +// max_avtpdu_size_octets == 0 means unbounded, the same fail-open default +// as capacity_octets. +#pragma once + +#include +#include +#include +#include +#include + +namespace rcp { +namespace respqueue { + +// Ported from c-RCP's RCP_RESPQUEUE_MAX_ENTRIES. +constexpr size_t kMaxEntries = 64; + +// The outbound per-response/ack-stream transmit queue (TC18 §12.7.9 Table +// 27, §12.9.4/§12.9.5) -- see this file's own header comment. +class RespQueue { +public: + // Constructs an empty queue with the given octet capacity and + // per-message ceiling (rcp/regmap.hpp's ResponseQueueConfig:: + // queue_size and max_avtpdu_size, already converted from quadlets to + // octets by the caller -- this module does that conversion nowhere). + // 0 for either means unbounded. + explicit RespQueue(size_t capacity_octets = 0, size_t max_avtpdu_size_octets = 0) noexcept + : capacity_octets_(capacity_octets), max_avtpdu_size_octets_(max_avtpdu_size_octets) {} + + // Appends a copy of frame[0..frame_len) to the tail (frame may be + // nullptr iff frame_len == 0), tagged with an internally-assigned + // sequence_num (then advances that counter, wrapping mod 256). + // Identical in every other respect to push_seq() below -- see its own + // doc comment for the full byte-budget-and-eviction behavior and the + // kMaxEntries slot-count ceiling. + bool push(const uint8_t* frame, size_t frame_len) noexcept { + bool ok = push_seq(frame, frame_len, next_sequence_num_); + if (ok) next_sequence_num_++; // wraps mod 256: next_sequence_num_ is uint8_t + return ok; + } + + // Same as push() above, except sequence_num is supplied by the caller + // rather than assigned from this queue's own internal counter (and + // next_sequence_num_ is left untouched). Returns true and grows + // octets() by frame_len on success. Returns false, leaving the queue + // entirely unchanged, if: + // - max_avtpdu_size_octets is nonzero and frame_len exceeds it + // (checked first, independently of capacity_octets); or + // - capacity_octets is nonzero and frame_len exceeds it outright (no + // amount of eviction could ever make room for a frame larger than + // the entire configured queue_size budget, even against a fully + // empty queue). + // + // Otherwise, TC18 §12.9.4/§12.9.5 applies: entries are evicted in + // ascending sequence_num order -- always the queued entry with the + // currently-lowest sequence_num, never merely the FIFO-oldest -- + // repeatedly, once for each of the following that still holds, until + // neither does: + // - capacity_octets is nonzero and accepting frame_len would still + // exceed the remaining budget; or + // - entries_len has reached kMaxEntries, this queue's own + // fixed-capacity slot-count ceiling -- enforced unconditionally, + // independently of capacity_octets. + // + // Every eviction latches overflow() true. This is NOT simply "evict + // the FIFO-oldest entry" -- sequence_num-order and FIFO-order coincide + // only until sequence_num wraps (256 values, uint8_t). + bool push_seq(const uint8_t* frame, size_t frame_len, uint8_t sequence_num) noexcept { + if (max_avtpdu_size_octets_ != 0 && frame_len > max_avtpdu_size_octets_) return false; + if (capacity_octets_ != 0 && frame_len > capacity_octets_) return false; + + while ((capacity_octets_ != 0 && frame_len > capacity_octets_ - octets_) || + entries_len_ == kMaxEntries) { + size_t lowest_idx = 0; + for (size_t i = 1; i < entries_len_; i++) { + if (entries_[i].sequence_num < entries_[lowest_idx].sequence_num) lowest_idx = i; + } + + octets_ -= entries_[lowest_idx].data.size(); + + // Close the gap left by the evicted slot, preserving FIFO + // order for the remaining entries. + for (size_t i = lowest_idx + 1; i < entries_len_; i++) { + entries_[i - 1] = std::move(entries_[i]); + } + entries_len_--; + overflow_ = true; + } + + Entry& e = entries_[entries_len_]; + e.data.assign(frame, frame_len == 0 ? frame : frame + frame_len); + e.sequence_num = sequence_num; + entries_len_++; + octets_ += frame_len; + return true; + } + + // TC18 §12.9.4/§12.9.5's overflow bit: true iff push()/push_seq() has + // evicted at least one entry since this queue was last constructed or + // last had clear_overflow() called. + bool overflow() const noexcept { return overflow_; } + + // Clears overflow() back to false. Safe to call whether or not it was + // ever set. + void clear_overflow() noexcept { overflow_ = false; } + + // REQ-RMAP-061's own remaining "MTU-consistency" half (TC18 §12.7.9: + // "The Max_AVTPDUsize shall always be configured such that the final + // network frame does not exceed the maximum transmit unit size of the + // network"). A config-time check, not a per-message queue concern -- + // push() already enforces the transmit-bounding half against a fixed, + // already-accepted max_avtpdu_size_octets; this function is what a + // caller uses BEFORE ever constructing a RespQueue, to decide whether + // a candidate Max_AVTPDUsize value is even acceptable for its own + // network. mtu_budget_octets is the caller's own already-adjusted + // ceiling (whatever "how many Max_AVTPDUsize octets fit under this + // deployment's real MTU" resolves to, netted of any header overhead + // its own network stack adds) -- this function does not itself add or + // assume any such overhead. Returns true iff max_avtpdu_size_octets + // does not exceed mtu_budget_octets; max_avtpdu_size_octets == 0 + // (unbounded) is never within budget for a nonzero mtu_budget_octets, + // and is vacuously true only when mtu_budget_octets is also 0. + static bool max_avtpdu_size_within_mtu(size_t max_avtpdu_size_octets, + size_t mtu_budget_octets) noexcept { + if (max_avtpdu_size_octets == 0) return mtu_budget_octets == 0; + return max_avtpdu_size_octets <= mtu_budget_octets; + } + + // Dequeues the oldest entry (FIFO) into out_frame and shrinks + // octets() by its length. Returns true on success. Returns false, + // leaving out_frame untouched, iff the queue is empty. + bool pop(std::vector& out_frame) noexcept { + if (entries_len_ == 0) return false; + + out_frame = std::move(entries_[0].data); + octets_ -= out_frame.size(); + for (size_t i = 1; i < entries_len_; i++) { + entries_[i - 1] = std::move(entries_[i]); + } + entries_len_--; + return true; + } + + // Number of entries currently queued. + size_t len() const noexcept { return entries_len_; } + + // Sum of every currently-queued entry's own length, in octets -- the + // same quantity push() checks against capacity_octets. + size_t octets() const noexcept { return octets_; } + + // REQ-RMAP-062 (TC18 §12.7.9): "In case an AVTPDU containing a single + // ACF_type would exceed the Max_AVTPDUsize, fragmentation... by the + // ms-bit will be performed." Computes the max_fragment_payload a + // caller hands to rcp/fragment.hpp's plan_count()/plan() so that + // every resulting fragment's own encoded AVTPDU (fixed ACF header + + // fragment payload + trailing pad) stays within max_avtpdu_size_octets + // -- header_len is the caller's own already-known ACF fixed-header + // length for the message kind being sent (rcp/acf.hpp's + // kAcfCommonHeaderLen or kAcfGbbMessageInfoLen; this module has no ACF + // knowledge of its own). Conservatively reserves the worst case 3 + // octets of trailing pad so the result is safe regardless of the + // actual pad a given fragment ends up needing. + // + // Returns 0 (matching FragmentErrc::disabled's own "fragmentation + // disabled" convention, rcp/fragment.hpp) if max_avtpdu_size_octets == + // 0 (unbounded) or if header_len + 3 already meets or exceeds + // max_avtpdu_size_octets (no payload budget remains at all once the + // fixed header and worst-case pad are reserved). + static size_t max_fragment_payload(size_t max_avtpdu_size_octets, size_t header_len) noexcept { + if (max_avtpdu_size_octets == 0) return 0; + + size_t reserved = header_len + 3; // fixed header + worst-case trailing pad + if (reserved >= max_avtpdu_size_octets) return 0; + + return max_avtpdu_size_octets - reserved; + } + + // REQ-RMAP-063 (TC18 §12.7.9, Table 27 relative address 0x0006): + // "Once a queue is filled with an amount of quadlets that is equal or + // larger than given by flush_on_count, the transmission of one or + // multiple AVTPDUs shall be initiated." True iff this queue currently + // holds at least flush_on_count_octets octets -- flush_on_count_octets + // is the register's own quadlet value already converted to octets by + // the caller (flush_on_count x 4). flush_on_count_octets == 0 always + // returns true iff the queue is non-empty (the register's own + // documented default, 1 quadlet, is the smallest possible nonzero + // threshold -- "immediate transmission"). + bool should_flush(size_t flush_on_count_octets) const noexcept { + if (entries_len_ == 0) return false; + if (flush_on_count_octets == 0) return true; + + return octets_ >= flush_on_count_octets; + } + + // REQ-RMAP-063: "Hereby only as much as fitting to the MAX_AVTPDUsize + // ACF_types will be included in a generated AVTPDU. Basically all + // ACF_types including the one which was exceeding the Flush_on_Count + // value will be transmitted, packed in a fitting number of AVTPDUs." + // Reports how many of this queue's own FIFO-ordered entries, starting + // from the front, fit together within max_avtpdu_size_octets octets + // total -- the membership of ONE generated AVTPDU. A caller builds + // that AVTPDU by calling pop() exactly that many times (draining + // exactly those entries), then calls this function again for the next + // AVTPDU, repeating until len() reaches 0. + // + // Always plans at least 1 entry when the queue is non-empty (an entry + // too large for max_avtpdu_size_octets on its own could never have + // been pushed in the first place -- push()'s own REQ-RMAP-061 + // enforcement already guarantees every queued entry individually + // fits). Returns 0 iff the queue is empty. max_avtpdu_size_octets == 0 + // (unbounded) plans every remaining entry into one AVTPDU. + size_t plan_batch(size_t max_avtpdu_size_octets) const noexcept { + if (entries_len_ == 0) return 0; + if (max_avtpdu_size_octets == 0) return entries_len_; + + size_t total = 0; + size_t i; + for (i = 0; i < entries_len_; i++) { + size_t next_total = total + entries_[i].data.size(); + if (i > 0 && next_total > max_avtpdu_size_octets) break; + total = next_total; + } + return i; + } + + // REQ-RMAP-064 (TC18 §12.7.9, Table 27 relative address 0x0008, + // microseconds, default 0 meaning "flush only by count"): the server + // shall initiate transmission from a response queue whenever the time + // since that queue's last transmission is equal to or greater than + // Flush_time, independently of flush_on_count. True iff flush_time_us + // is nonzero and elapsed_since_last_transmit_us has reached it. + // elapsed_since_last_transmit_us is a caller-tracked duration -- this + // module owns no clock. + // + // REQ-RMAP-065: deliberately independent of queue occupancy -- unlike + // should_flush() above (which has nothing meaningful to report on an + // empty queue and so treats one as never due), this trigger fires the + // same way whether the queue is empty or not, because the server must + // still transmit an empty heartbeat AVTPDU even when nothing is + // queued. A caller composes the full behaviour as: once this function + // returns true, either call plan_batch()+drain+encode a real batch (a + // nonzero plan_batch() result), or -- if plan_batch() reports 0 -- + // encode an empty-payload heartbeat AVTPDU directly. Actually + // SCHEDULING that composition against a real clock is a + // caller/integrator concern this library does not take on. + static bool should_flush_by_time(uint64_t elapsed_since_last_transmit_us, + uint64_t flush_time_us) noexcept { + if (flush_time_us == 0) return false; + + return elapsed_since_last_transmit_us >= flush_time_us; + } + +private: + struct Entry { + std::vector data; + uint8_t sequence_num = 0; + }; + + std::array entries_{}; // fixed-capacity (no realloc/vector growth) + size_t entries_len_ = 0; // always <= kMaxEntries, by construction + size_t octets_ = 0; // running total of entries_[i].data.size(), 0 <= i < entries_len_ + size_t capacity_octets_; + size_t max_avtpdu_size_octets_; + uint8_t next_sequence_num_ = 0; + bool overflow_ = false; +}; + +} // namespace respqueue +} // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b9cdcd0..b394e36 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,14 @@ add_executable(test_loan test_loan.cpp) target_link_libraries(test_loan PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_loan COMMAND test_loan) +add_executable(test_fragment test_fragment.cpp) +target_link_libraries(test_fragment PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_fragment COMMAND test_fragment) + +add_executable(test_respqueue test_respqueue.cpp) +target_link_libraries(test_respqueue PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_respqueue COMMAND test_respqueue) + add_executable(test_shmem test_shmem.cpp) target_link_libraries(test_shmem PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_shmem COMMAND test_shmem) diff --git a/tests/test_fragment.cpp b/tests/test_fragment.cpp new file mode 100644 index 0000000..090c81b --- /dev/null +++ b/tests/test_fragment.cpp @@ -0,0 +1,401 @@ +// fusa:test REQ-FRAG-001 +// fusa:test REQ-FRAG-002 +// fusa:test REQ-FRAG-003 +// fusa:test REQ-FRAG-004 +// fusa:test REQ-FRAG-005 +// fusa:test REQ-FRAG-006 +// fusa:test REQ-FRAG-007 +// fusa:test REQ-FRAG-008 +// fusa:test REQ-FRAG-009 +// fusa:test REQ-FRAG-010 +// fusa:test REQ-FRAG-011 +// fusa:test REQ-FRAG-012 +// fusa:test REQ-FRAG-013 +// fusa:test REQ-FRAG-014 +// fusa:test REQ-FRAG-015 +// fusa:test REQ-FRAG-017 +// fusa:test REQ-FRAG-018 + +// Tests for rcp/fragment.hpp -- multi-AVTPDU fragmentation/reassembly, the +// generic primitive underlying TC18 §13.7.11.3 (Phase 17, cpp-RCP issue +// #129). Ported from c-RCP's tests/test_fragment.c. +// +// REQ-FRAG-010/016 (rcp_fragment_reassembler_destroy() / +// RCP_FRAGMENT_REASM_ERR_ALLOC) have no direct C++ analog here: this +// module's Reassembler is backed by a fixed std::array member (see +// fragment.hpp's own "Fixed-capacity from day one" comment) rather than +// c-RCP's realloc()-grown heap buffer, so there is no separate +// destroy()/free step (RAII handles it) and no allocation-failure outcome +// to report -- any attempt to grow past the fixed capacity is folded into +// kErrTooLarge instead, exercised below by +// "feed() rejects a payload exceeding this Reassembler's own fixed +// capacity". + +#include +#include + +#include +#include + +using namespace rcp::fragment; + +// ── strerror / to_string ──────────────────────────────────────────────────── + +TEST_CASE("FragmentErrc category messages are non-null and distinct", + "[fragment][REQ-FRAG-001]") { + auto a = ErrDisabled.message(); + auto b = ErrTooManySegments.message(); + auto c = ErrBadSegmentCount.message(); + + REQUIRE_FALSE(a.empty()); + REQUIRE_FALSE(b.empty()); + REQUIRE_FALSE(c.empty()); + REQUIRE(a != b); + REQUIRE(b != c); + + // Unrecognized value still yields a non-empty message. + REQUIRE_FALSE(fragment_category().message(999).empty()); +} + +TEST_CASE("to_string(ReasmResult) is non-null and distinct for every value, including unrecognized", + "[fragment][REQ-FRAG-007]") { + const char* a = to_string(ReasmResult::kContinue); + const char* b = to_string(ReasmResult::kComplete); + const char* c = to_string(ReasmResult::kErrOutOfOrder); + const char* d = to_string(ReasmResult::kErrTooLarge); + const char* unk = to_string(static_cast(999)); + + REQUIRE(a != nullptr); + REQUIRE(b != nullptr); + REQUIRE(c != nullptr); + REQUIRE(d != nullptr); + REQUIRE(unk != nullptr); + + REQUIRE(std::strcmp(a, b) != 0); + REQUIRE(std::strcmp(b, c) != 0); + REQUIRE(std::strcmp(c, d) != 0); +} + +// ── plan_count ─────────────────────────────────────────────────────────────── + +TEST_CASE("plan_count: empty payload always plans one segment", "[fragment][REQ-FRAG-002]") { + REQUIRE(plan_count(0, 0) == 1); + REQUIRE(plan_count(0, 10) == 1); +} + +TEST_CASE("plan_count: payload fitting in one fragment plans one segment", "[fragment][REQ-FRAG-002]") { + REQUIRE(plan_count(10, 10) == 1); + REQUIRE(plan_count(5, 10) == 1); +} + +TEST_CASE("plan_count: disabled when payload exceeds a zero cap", "[fragment][REQ-FRAG-003]") { + REQUIRE(plan_count(1, 0) == 0); +} + +TEST_CASE("plan_count: exact multiple", "[fragment][REQ-FRAG-002]") { + REQUIRE(plan_count(30, 10) == 3); +} + +TEST_CASE("plan_count: remainder rounds up", "[fragment][REQ-FRAG-002]") { + REQUIRE(plan_count(31, 10) == 4); + REQUIRE(plan_count(11, 10) == 2); +} + +TEST_CASE("plan_count: too many segments returns 0", "[fragment][REQ-FRAG-003]") { + REQUIRE(plan_count(kMaxIntermediateSegments + 2, 1) == 0); +} + +TEST_CASE("plan_count: exactly at the max-intermediate boundary succeeds", "[fragment][REQ-FRAG-003]") { + REQUIRE(plan_count(kMaxIntermediateSegments + 1, 1) == kMaxIntermediateSegments + 1); +} + +// ── plan ───────────────────────────────────────────────────────────────────── + +TEST_CASE("plan: no fragmentation needed produces one ms=false segment", "[fragment][REQ-FRAG-004]") { + Segment seg; + auto ec = plan(7, 10, &seg, 1); + REQUIRE_FALSE(ec); + REQUIRE(seg.offset == 0); + REQUIRE(seg.len == 7); + REQUIRE_FALSE(seg.ms); +} + +TEST_CASE("plan: empty payload", "[fragment][REQ-FRAG-004]") { + Segment seg; + auto ec = plan(0, 4, &seg, 1); + REQUIRE_FALSE(ec); + REQUIRE(seg.offset == 0); + REQUIRE(seg.len == 0); + REQUIRE_FALSE(seg.ms); +} + +TEST_CASE("plan: multi-segment layout and numbering", "[fragment][REQ-FRAG-005]") { + // 25 octets, 10 per fragment -> 3 fragments: [0,10) ms=1 seg0, + // [10,20) ms=1 seg1, [20,25) ms=0 final. + Segment segs[3]; + auto ec = plan(25, 10, segs, 3); + REQUIRE_FALSE(ec); + + REQUIRE(segs[0].offset == 0); + REQUIRE(segs[0].len == 10); + REQUIRE(segs[0].ms); + REQUIRE(segs[0].segment_num == 0); + + REQUIRE(segs[1].offset == 10); + REQUIRE(segs[1].len == 10); + REQUIRE(segs[1].ms); + REQUIRE(segs[1].segment_num == 1); + + REQUIRE(segs[2].offset == 20); + REQUIRE(segs[2].len == 5); + REQUIRE_FALSE(segs[2].ms); +} + +TEST_CASE("plan: segment_num above 255 does not truncate (12-bit field)", "[fragment][REQ-FRAG-005]") { + // 300 fragments of 1 byte each: 299 intermediate (segment_num + // 0..298, well past an 8-bit ceiling) plus 1 final. + std::vector segs(300); + size_t count = plan_count(300, 1); + REQUIRE(count == 300); + auto ec = plan(300, 1, segs.data(), count); + REQUIRE_FALSE(ec); + + REQUIRE(segs[255].ms); + REQUIRE(segs[255].segment_num == 255); + REQUIRE(segs[298].ms); + REQUIRE(segs[298].segment_num == 298); + REQUIRE_FALSE(segs[299].ms); +} + +TEST_CASE("plan: segments contiguously cover the entire payload", "[fragment][REQ-FRAG-005]") { + size_t payload_len = 67; + size_t max_frag = 10; + size_t count = plan_count(payload_len, max_frag); + REQUIRE(count == 7); + + std::vector segs(count); + auto ec = plan(payload_len, max_frag, segs.data(), count); + REQUIRE_FALSE(ec); + + size_t covered = 0; + for (size_t i = 0; i < count; i++) { + REQUIRE(segs[i].offset == covered); + covered += segs[i].len; + if (i + 1 < count) { + REQUIRE(segs[i].ms); + REQUIRE(segs[i].segment_num == static_cast(i)); + } else { + REQUIRE_FALSE(segs[i].ms); + } + } + REQUIRE(covered == payload_len); +} + +TEST_CASE("plan: disabled", "[fragment][REQ-FRAG-006]") { + Segment seg; + auto ec = plan(5, 0, &seg, 999); + REQUIRE(ec == ErrDisabled); +} + +TEST_CASE("plan: too many segments", "[fragment][REQ-FRAG-006]") { + Segment seg; + auto ec = plan(kMaxIntermediateSegments + 2, 1, &seg, kMaxIntermediateSegments + 2); + REQUIRE(ec == ErrTooManySegments); +} + +TEST_CASE("plan: bad segment count", "[fragment][REQ-FRAG-006]") { + Segment segs[3]; + auto ec = plan(25, 10, segs, 2); + REQUIRE(ec == ErrBadSegmentCount); +} + +// ── Reassembler: init/reset postconditions ────────────────────────────────── + +TEST_CASE("Reassembler starts empty and not collecting", "[fragment][REQ-FRAG-008]") { + Reassembler r(1024); + REQUIRE_FALSE(r.is_collecting()); + REQUIRE(r.size() == 0); + REQUIRE(r.data() == nullptr); +} + +// ── Reassembler: single-segment (never fragmented) messages ──────────────── + +TEST_CASE("Reassembler: a single ms=false fragment completes immediately", + "[fragment][REQ-FRAG-011]") { + Reassembler r(1024); + const uint8_t data[] = {0xAA, 0xBB, 0xCC}; + + auto rc = r.feed(false, 0, data, sizeof(data)); + REQUIRE(rc == ReasmResult::kComplete); + REQUIRE_FALSE(r.is_collecting()); + + REQUIRE(r.size() == sizeof(data)); + REQUIRE(std::memcmp(r.data(), data, sizeof(data)) == 0); +} + +TEST_CASE("Reassembler: an empty single-segment message completes with size 0", + "[fragment][REQ-FRAG-011]") { + Reassembler r(16); + auto rc = r.feed(false, 0, nullptr, 0); + REQUIRE(rc == ReasmResult::kComplete); + REQUIRE(r.size() == 0); +} + +// ── Reassembler: multi-segment sequences ──────────────────────────────────── + +TEST_CASE("Reassembler: multi-segment sequence round-trips plan()'s own output", + "[fragment][REQ-FRAG-013][REQ-FRAG-014][REQ-FRAG-018]") { + uint8_t payload[67]; + for (size_t i = 0; i < sizeof(payload); i++) payload[i] = static_cast(i * 7 + 1); + + size_t count = plan_count(sizeof(payload), 10); + std::vector segs(count); + REQUIRE_FALSE(plan(sizeof(payload), 10, segs.data(), count)); + + Reassembler r(1024); + for (size_t i = 0; i < count; i++) { + auto rc = r.feed(segs[i].ms, segs[i].segment_num, &payload[segs[i].offset], segs[i].len); + if (i + 1 < count) { + REQUIRE(rc == ReasmResult::kContinue); + REQUIRE(r.is_collecting()); + } else { + REQUIRE(rc == ReasmResult::kComplete); + REQUIRE_FALSE(r.is_collecting()); + } + } + + REQUIRE(r.size() == sizeof(payload)); + REQUIRE(std::memcmp(r.data(), payload, sizeof(payload)) == 0); +} + +TEST_CASE("Reassembler: out-of-order first segment is rejected", "[fragment][REQ-FRAG-012]") { + Reassembler r(1024); + const uint8_t data[4] = {1, 2, 3, 4}; + + auto rc = r.feed(true, 1 /* should be 0 */, data, sizeof(data)); + REQUIRE(rc == ReasmResult::kErrOutOfOrder); + REQUIRE_FALSE(r.is_collecting()); +} + +TEST_CASE("Reassembler: out-of-order mid-sequence segment preserves already-collected state", + "[fragment][REQ-FRAG-013]") { + Reassembler r(1024); + const uint8_t data[4] = {1, 2, 3, 4}; + + REQUIRE(r.feed(true, 0, data, sizeof(data)) == ReasmResult::kContinue); + + // Expected next is 1; skip to 2. + auto rc = r.feed(true, 2, data, sizeof(data)); + REQUIRE(rc == ReasmResult::kErrOutOfOrder); + REQUIRE(r.is_collecting()); // already-collected segment 0 untouched +} + +TEST_CASE("Reassembler: final ms=false fragment's segment_num field is ignored", + "[fragment][REQ-FRAG-014]") { + Reassembler r(1024); + const uint8_t seg0[4] = {1, 2, 3, 4}; + const uint8_t fin[2] = {9, 9}; + + REQUIRE(r.feed(true, 0, seg0, sizeof(seg0)) == ReasmResult::kContinue); + + // An arbitrary value here (77) means something else once ms=false -- + // must not be checked against the sequence counter. + auto rc = r.feed(false, 77, fin, sizeof(fin)); + REQUIRE(rc == ReasmResult::kComplete); + + REQUIRE(r.size() == 6); + REQUIRE(r.data()[0] == 1); + REQUIRE(r.data()[5] == 9); +} + +TEST_CASE("Reassembler: exceeding max_total_len rejects and preserves state", + "[fragment][REQ-FRAG-015]") { + Reassembler r(8); // max_total_len == 8 + const uint8_t seg0[4] = {1, 2, 3, 4}; + const uint8_t big[10] = {0}; + + REQUIRE(r.feed(true, 0, seg0, sizeof(seg0)) == ReasmResult::kContinue); + + // 4 already accumulated + 10 more would exceed 8. + auto rc = r.feed(true, 1, big, sizeof(big)); + REQUIRE(rc == ReasmResult::kErrTooLarge); + REQUIRE(r.is_collecting()); +} + +TEST_CASE("Reassembler: exactly at max_total_len succeeds", "[fragment][REQ-FRAG-015]") { + Reassembler r(8); + const uint8_t seg0[4] = {1, 2, 3, 4}; + const uint8_t fin[4] = {5, 6, 7, 8}; + + REQUIRE(r.feed(true, 0, seg0, sizeof(seg0)) == ReasmResult::kContinue); + REQUIRE(r.feed(false, 0, fin, sizeof(fin)) == ReasmResult::kComplete); + REQUIRE(r.size() == 8); +} + +// New vs. c-RCP: c-RCP's own reassembler grows a realloc()-backed heap +// buffer up to max_total_len, with a separate RCP_FRAGMENT_REASM_ERR_ALLOC +// outcome purely for allocation failure. This Reassembler has no heap +// buffer at all (see fragment.hpp's "Fixed-capacity from day one" comment) +// -- exceeding its own fixed kDefaultReassemblyCapacity is folded into the +// same kErrTooLarge outcome as exceeding a caller-configured max_total_len, +// even when max_total_len itself is left at its (larger, or default) +// value. +TEST_CASE("Reassembler: feed() rejects a payload exceeding this Reassembler's own fixed capacity", + "[fragment][REQ-FRAG-015]") { + // max_total_len deliberately far larger than kDefaultReassemblyCapacity: + // the fixed std::array capacity, not max_total_len, is what actually + // binds here. + Reassembler r(kDefaultReassemblyCapacity * 4); + std::vector huge(kDefaultReassemblyCapacity + 1, 0x5A); + + auto rc = r.feed(false, 0, huge.data(), huge.size()); + REQUIRE(rc == ReasmResult::kErrTooLarge); + REQUIRE_FALSE(r.is_collecting()); +} + +TEST_CASE("Reassembler: a payload exactly at the fixed capacity boundary succeeds", + "[fragment][REQ-FRAG-015]") { + Reassembler r(kDefaultReassemblyCapacity); + std::vector exact(kDefaultReassemblyCapacity, 0x11); + + auto rc = r.feed(false, 0, exact.data(), exact.size()); + REQUIRE(rc == ReasmResult::kComplete); + REQUIRE(r.size() == kDefaultReassemblyCapacity); +} + +// ── Reassembler: reset/reuse across messages ──────────────────────────────── + +TEST_CASE("Reassembler: reset() discards in-progress state and allows reuse", + "[fragment][REQ-FRAG-009][REQ-FRAG-017]") { + Reassembler r(1024); + const uint8_t a[3] = {1, 2, 3}; + const uint8_t b[2] = {9, 8}; + + REQUIRE(r.feed(true, 0, a, sizeof(a)) == ReasmResult::kContinue); + REQUIRE(r.is_collecting()); + + r.reset(); + REQUIRE_FALSE(r.is_collecting()); + + // A fresh, unrelated single-segment message must work cleanly after + // reset, with no leftover state from the abandoned sequence. + auto rc = r.feed(false, 0, b, sizeof(b)); + REQUIRE(rc == ReasmResult::kComplete); + REQUIRE(r.size() == sizeof(b)); + REQUIRE(std::memcmp(r.data(), b, sizeof(b)) == 0); +} + +TEST_CASE("Reassembler: a completed reassembly can be reused for the next message without an explicit reset", + "[fragment][REQ-FRAG-011]") { + Reassembler r(1024); + const uint8_t a[3] = {1, 2, 3}; + const uint8_t b[3] = {4, 5, 6}; + + REQUIRE(r.feed(false, 0, a, sizeof(a)) == ReasmResult::kComplete); + + // Without an explicit reset, feeding the next logical message's own + // single-segment fragment appends atop (does not clear) the previous + // result -- matching c-RCP's own documented behavior exactly. + REQUIRE(r.feed(false, 0, b, sizeof(b)) == ReasmResult::kComplete); + REQUIRE(r.size() == sizeof(a) + sizeof(b)); +} diff --git a/tests/test_loan.cpp b/tests/test_loan.cpp index 437820f..2644987 100644 --- a/tests/test_loan.cpp +++ b/tests/test_loan.cpp @@ -4,10 +4,20 @@ // fusa:test REQ-LOAN-004 // fusa:test REQ-LOAN-005 // fusa:test REQ-LOAN-006 +// fusa:test REQ-LOAN-008 +// fusa:test REQ-LOAN-009 // Tests for rcp/loan.hpp — BufferPool, the zero-copy payload-loaning // primitive (ROADMAP.md milestone 58, "Auxiliary Transport & Cross-Cutting -// Rebind", v2.14.0). +// Rebind", v2.14.0; Phase 17 fixed-capacity conversion, cpp-RCP issue +// #129, ported from c-RCP's RCP_LOAN_POOL_MAX_ENTRIES). +// +// Not ported: c-RCP's own test_acquire_returns_null_when_loan_struct_ +// allocation_fails / test_acquire_returns_null_when_release_ctx_ +// allocation_fails have no C++ analog -- cpp-RCP has no +// allocation-failure-injection seam (c-RCP's rcp/alloc.h hook table is +// C-only), and std::make_unique/`new` cannot be made to fail +// deterministically and portably the way those tests do. #include #include @@ -96,3 +106,70 @@ TEST_CASE("loan::BufferPool::close is idempotent and safe with outstanding Loans // crash even though the pool has already been closed. loan_out.reset(); } + +// ── new_buffer_pool() ──────────────────────────────────────────────────────── + +TEST_CASE("new_buffer_pool returns a valid, open, empty pool", "[loan][REQ-LOAN-009]") { + auto pool = loan::new_buffer_pool(); + REQUIRE(pool != nullptr); + REQUIRE(pool->ok()); + REQUIRE(pool->pooled_count() == 0); + + std::unique_ptr loan_out; + REQUIRE_FALSE(pool->loan(8, loan_out)); + REQUIRE(loan_out != nullptr); +} + +// ── pool destruction frees every free-listed buffer (REQ-LOAN-008) ───────── + +TEST_CASE("BufferPool destruction frees every buffer still in the free list", + "[loan][REQ-LOAN-008]") { + loan::BufferPool pool; + + for (int i = 0; i < 8; i++) { + std::unique_ptr loan_out; + REQUIRE_FALSE(pool.loan(16 * (i + 1), loan_out)); + loan_out.reset(); // returns the buffer to the pool's free list + } + REQUIRE(pool.pooled_count() == 8); + + // pool's destructor (end of scope) must free every remaining + // free-listed buffer without leaking (ASan-checked in CI) -- nothing + // further to assert here beyond "does not crash", matching c-RCP's own + // test_pool_destroy_frees_every_returned_buffer. +} + +// ── Fixed-capacity free list (Phase 17: was an unbounded std::vector) ────── + +TEST_CASE("A released buffer beyond the free list's fixed capacity is freed outright, " + "and the pool remains fully usable", + "[loan][REQ-LOAN-004]") { + loan::BufferPool pool; + std::vector> loans; + loans.reserve(loan::kPoolMaxEntries); + + // Distinct sizes: every loan() below is a fresh allocation (the free + // list starts empty, so nothing to reuse yet). + for (size_t i = 0; i < loan::kPoolMaxEntries; i++) { + std::unique_ptr loan_out; + REQUIRE_FALSE(pool.loan(static_cast(i + 1), loan_out)); + loans.push_back(std::move(loan_out)); + } + for (auto& l : loans) l.reset(); // free list now holds exactly kPoolMaxEntries + REQUIRE(pool.pooled_count() == loan::kPoolMaxEntries); + + // A size larger than every already-freed capacity: guaranteed not to + // reuse, so this is a genuinely fresh allocation whose eventual + // release is the one that finds the free list already full. + std::unique_ptr overflow_loan; + REQUIRE_FALSE(pool.loan(static_cast(loan::kPoolMaxEntries) + 1, overflow_loan)); + overflow_loan.reset(); // free list full: buffer freed outright (not pooled) + + // pooled_count must not have grown past kPoolMaxEntries, and the pool + // must still be fully usable afterward. + REQUIRE(pool.pooled_count() == loan::kPoolMaxEntries); + + std::unique_ptr reacquired; + REQUIRE_FALSE(pool.loan(1, reacquired)); + REQUIRE(reacquired != nullptr); +} diff --git a/tests/test_respqueue.cpp b/tests/test_respqueue.cpp new file mode 100644 index 0000000..4372c29 --- /dev/null +++ b/tests/test_respqueue.cpp @@ -0,0 +1,457 @@ +// fusa:test REQ-RMAP-059 +// fusa:test REQ-RMAP-061 +// fusa:test REQ-RMAP-062 +// fusa:test REQ-RMAP-063 +// fusa:test REQ-RMAP-064 +// fusa:test REQ-RMAP-065 +// fusa:test REQ-RMAP-085 + +// Tests for rcp/respqueue.hpp -- the outbound per-response/ack-stream +// transmit queue (TC18 §12.7.9 Table 27, §12.9.4/§12.9.5), brand new to +// cpp-RCP. Ported from c-RCP's tests/test_respqueue.c. +// +// Not ported: c-RCP's own test_push_fails_when_frame_dup_allocation_fails +// has no C++ analog -- cpp-RCP has no allocation-failure-injection seam +// (rcp/alloc.h's hook table is c-RCP-only), and std::vector's own +// allocator cannot be made to fail deterministically and portably the way +// that test does. RespQueue's push()/push_seq() otherwise cannot fail for +// any reason other than the two explicit, directly-tested ceiling checks +// below. + +#include +#include +#include + +#include +#include + +using namespace rcp::respqueue; + +// ── Basic FIFO push/pop ───────────────────────────────────────────────────── + +TEST_CASE("push/pop is FIFO", "[respqueue][REQ-RMAP-059]") { + RespQueue q; + const uint8_t a[] = {1, 2, 3}; + const uint8_t b[] = {4, 5}; + + REQUIRE(q.len() == 0); + REQUIRE(q.octets() == 0); + + REQUIRE(q.push(a, sizeof(a))); + REQUIRE(q.push(b, sizeof(b))); + REQUIRE(q.len() == 2); + REQUIRE(q.octets() == 5); + + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(out.size() == 3); + REQUIRE(std::memcmp(a, out.data(), sizeof(a)) == 0); + REQUIRE(q.octets() == 2); + + REQUIRE(q.pop(out)); + REQUIRE(out.size() == 2); + REQUIRE(std::memcmp(b, out.data(), sizeof(b)) == 0); + + REQUIRE(q.len() == 0); + REQUIRE(q.octets() == 0); +} + +TEST_CASE("pop on an empty queue returns false and leaves out_frame untouched", + "[respqueue][REQ-RMAP-059]") { + RespQueue q; + std::vector out = {0xDE, 0xAD}; // sentinel + + REQUIRE_FALSE(q.pop(out)); + REQUIRE(out == std::vector{0xDE, 0xAD}); +} + +TEST_CASE("push_seq accepts a zero-length frame", "[respqueue][REQ-RMAP-085]") { + RespQueue q; + + REQUIRE(q.push_seq(nullptr, 0, 1)); + REQUIRE(q.len() == 1); + + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(out.empty()); +} + +// ── REQ-RMAP-059: capacity is an octet budget, not an entry-count limit ──── + +TEST_CASE("zero capacity is unbounded", "[respqueue][REQ-RMAP-059]") { + RespQueue q; + std::vector big(4096, 0); + + REQUIRE(q.push(big.data(), big.size())); + REQUIRE(q.octets() == big.size()); +} + +TEST_CASE("push is refused outright when a frame exceeds total capacity_octets", + "[respqueue][REQ-RMAP-059]") { + RespQueue q(10, 0); + const uint8_t five[5] = {1, 2, 3, 4, 5}; + const uint8_t eleven[11] = {0}; + + REQUIRE(q.push(five, sizeof(five))); + REQUIRE(q.octets() == 5); + + // 11 > capacity 10 outright: refused regardless of current occupancy. + REQUIRE_FALSE(q.push(eleven, sizeof(eleven))); + REQUIRE(q.len() == 1); + REQUIRE(q.octets() == 5); + REQUIRE_FALSE(q.overflow()); + + // Exactly at the remaining budget: accepted, no eviction needed. + REQUIRE(q.push(five, sizeof(five))); + REQUIRE(q.len() == 2); + REQUIRE(q.octets() == 10); + REQUIRE_FALSE(q.overflow()); +} + +TEST_CASE("pop frees capacity for a later push", "[respqueue][REQ-RMAP-059]") { + RespQueue q(8, 0); + const uint8_t frame[8] = {0}; + const uint8_t oversized[9] = {0}; + + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE_FALSE(q.push(oversized, sizeof(oversized))); + + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(q.octets() == 0); + + REQUIRE(q.push(frame, sizeof(frame))); +} + +TEST_CASE("a queue can be destroyed non-empty without leaking (RAII)", + "[respqueue][REQ-RMAP-059]") { + const uint8_t frame[3] = {1, 2, 3}; + { + RespQueue q; + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(q.push(frame, sizeof(frame))); + } // destructor runs here; ASan-checked in CI + SUCCEED(); +} + +// ── REQ-RMAP-061: a per-message Max_AVTPDUsize ceiling, independent of +// the aggregate queue_size capacity ───────────────────────────────────── + +TEST_CASE("zero max_avtpdu_size is unbounded", "[respqueue][REQ-RMAP-061]") { + RespQueue q; + std::vector frame(64, 0); + + REQUIRE(q.push(frame.data(), frame.size())); +} + +TEST_CASE("push is refused once a single frame exceeds max_avtpdu_size", + "[respqueue][REQ-RMAP-061]") { + RespQueue q(1000, 10); + uint8_t ok[10] = {0}; + uint8_t over[11] = {0}; + + REQUIRE(q.push(ok, sizeof(ok))); + REQUIRE_FALSE(q.push(over, sizeof(over))); + REQUIRE(q.len() == 1); + REQUIRE(q.octets() == 10); +} + +// ── TC18 §12.9.4/§12.9.5 (REQ-RMAP-085): slot-count eviction + overflow ──── + +TEST_CASE("push evicts the lowest sequence_num entry, not the FIFO-oldest", + "[respqueue][REQ-RMAP-085]") { + RespQueue q; // unbounded byte budget/message ceiling + + // Fill all kMaxEntries slots. The first kMaxEntries-1 pushes get + // increasing sequence numbers (1..63); the last one pushed (the + // FIFO-newest entry) is deliberately given sequence_num 0 -- lower + // than every entry already queued. This makes "lowest sequence_num" + // and "FIFO-oldest" name two different entries. + uint8_t frame[1]; + for (size_t i = 0; i + 1 < kMaxEntries; i++) { + frame[0] = static_cast(i); + REQUIRE(q.push_seq(frame, 1, static_cast(i + 1))); + } + frame[0] = 0xAA; + REQUIRE(q.push_seq(frame, 1, 0)); + REQUIRE(q.len() == kMaxEntries); + REQUIRE_FALSE(q.overflow()); + + // Queue is now completely full by slot count. TC18 §12.9.4/§12.9.5 + // requires evicting the LOWEST-sequence_num entry (seq 0, the 0xAA + // entry just pushed), NOT index 0 (seq 1, the true FIFO-oldest). + frame[0] = 0xBB; + REQUIRE(q.push_seq(frame, 1, 200)); + REQUIRE(q.len() == kMaxEntries); + REQUIRE(q.overflow()); + + // The true FIFO-oldest entry (payload 0, sequence_num 1) must still + // be present and still at the front. + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(out.size() == 1); + REQUIRE(out[0] == 0); + + bool saw_evicted = false, saw_new = false; + while (q.pop(out)) { + if (out.size() == 1 && out[0] == 0xAA) saw_evicted = true; + if (out.size() == 1 && out[0] == 0xBB) saw_new = true; + } + REQUIRE_FALSE(saw_evicted); + REQUIRE(saw_new); +} + +TEST_CASE("overflow flag latches until cleared", "[respqueue][REQ-RMAP-085]") { + RespQueue q; + uint8_t frame[1] = {0}; + + for (size_t i = 0; i < kMaxEntries; i++) { + REQUIRE(q.push(frame, 1)); + } + REQUIRE_FALSE(q.overflow()); // not full-and-pushed-past yet + + REQUIRE(q.push(frame, 1)); // triggers eviction, latches overflow + REQUIRE(q.overflow()); + + q.clear_overflow(); + REQUIRE_FALSE(q.overflow()); + + // Pop one entry so the queue is no longer completely full, then push + // again: no eviction this time, so overflow must stay clear. + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(q.push(frame, 1)); + REQUIRE_FALSE(q.overflow()); +} + +TEST_CASE("capacity_octets nonzero still enforces the kMaxEntries slot bound", + "[respqueue][REQ-RMAP-085]") { + size_t n = kMaxEntries + 10; + RespQueue q(n, 0); // n octets capacity, 1 octet per entry + uint8_t frame[1] = {0}; + + for (size_t i = 0; i < n; i++) { + REQUIRE(q.push(frame, 1)); + } + REQUIRE(q.len() == kMaxEntries); + REQUIRE(q.octets() == kMaxEntries); + REQUIRE(q.overflow()); +} + +TEST_CASE("max_avtpdu_size rejection is unaffected by slot-count eviction", + "[respqueue][REQ-RMAP-061][REQ-RMAP-085]") { + RespQueue q(1000, 10); + uint8_t ok[10] = {0}; + uint8_t over[11] = {0}; + + REQUIRE(q.push_seq(ok, sizeof(ok), 1)); + REQUIRE_FALSE(q.push_seq(over, sizeof(over), 2)); + REQUIRE(q.len() == 1); + REQUIRE(q.octets() == 10); + REQUIRE_FALSE(q.overflow()); +} + +// ── capacity_octets exhaustion triggers eviction, not reject-and-unchanged ── + +TEST_CASE("push evicts a single entry when capacity_octets would be exceeded", + "[respqueue][REQ-RMAP-085]") { + RespQueue q(10, 0); + const uint8_t five[5] = {1, 2, 3, 4, 5}; + const uint8_t six[6] = {1, 2, 3, 4, 5, 6}; + + REQUIRE(q.push_seq(five, sizeof(five), 7)); + REQUIRE(q.len() == 1); + REQUIRE(q.octets() == 5); + REQUIRE_FALSE(q.overflow()); + + // 5 (queued) + 6 (incoming) = 11 > capacity 10: the only queued entry + // (seq 7) is evicted, freeing 5 octets, and the 6-octet frame admits. + REQUIRE(q.push_seq(six, sizeof(six), 8)); + REQUIRE(q.len() == 1); + REQUIRE(q.octets() == 6); + REQUIRE(q.overflow()); +} + +TEST_CASE("push evicts multiple entries when one eviction is not enough bytes", + "[respqueue][REQ-RMAP-085]") { + RespQueue q(10, 0); + const uint8_t a[2] = {0xA1, 0xA2}; + const uint8_t b[3] = {0xB1, 0xB2, 0xB3}; + const uint8_t c[2] = {0xC1, 0xC2}; + const uint8_t d[8] = {0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8}; + + REQUIRE(q.push_seq(a, sizeof(a), 1)); // octets: 2 + REQUIRE(q.push_seq(b, sizeof(b), 2)); // octets: 5 + REQUIRE(q.push_seq(c, sizeof(c), 3)); // octets: 7 + REQUIRE(q.len() == 3); + REQUIRE(q.octets() == 7); + REQUIRE_FALSE(q.overflow()); + + // Incoming d needs 8 octets; only 3 free. Evicting `a` (seq 1, lowest, + // 2 octets) frees 5 total -- still not enough. `b` (seq 2, now + // lowest remaining) must also be evicted (8 free total) before d + // fits. `c` (seq 3) must survive. + REQUIRE(q.push_seq(d, sizeof(d), 4)); + REQUIRE(q.len() == 2); // c and d remain + REQUIRE(q.octets() == 10); + REQUIRE(q.overflow()); + + bool saw_a = false, saw_b = false, saw_c = false, saw_d = false; + std::vector out; + while (q.pop(out)) { + if (out.size() == sizeof(a) && std::memcmp(out.data(), a, sizeof(a)) == 0) saw_a = true; + if (out.size() == sizeof(b) && std::memcmp(out.data(), b, sizeof(b)) == 0) saw_b = true; + if (out.size() == sizeof(c) && std::memcmp(out.data(), c, sizeof(c)) == 0) saw_c = true; + if (out.size() == sizeof(d) && std::memcmp(out.data(), d, sizeof(d)) == 0) saw_d = true; + } + REQUIRE_FALSE(saw_a); // evicted + REQUIRE_FALSE(saw_b); // evicted + REQUIRE(saw_c); // survived: never the lowest remaining seq + REQUIRE(saw_d); // the push that triggered the eviction +} + +TEST_CASE("capacity_octets eviction prefers lowest sequence_num, not FIFO-oldest", + "[respqueue][REQ-RMAP-085]") { + RespQueue q(8, 0); + const uint8_t oldest_but_highest_seq[4] = {1, 1, 1, 1}; + const uint8_t newest_but_lowest_seq[4] = {2, 2, 2, 2}; + const uint8_t incoming[4] = {3, 3, 3, 3}; + + REQUIRE(q.push_seq(oldest_but_highest_seq, 4, 200)); + REQUIRE(q.push_seq(newest_but_lowest_seq, 4, 5)); + REQUIRE(q.octets() == 8); + + // capacity 8, already full: incoming 4-octet frame needs an eviction. + // Lowest sequence_num is 5 (the FIFO-newest entry) -- it must be + // evicted, not index 0 (the FIFO-oldest, seq 200). + REQUIRE(q.push_seq(incoming, 4, 201)); + REQUIRE(q.len() == 2); + REQUIRE(q.octets() == 8); + REQUIRE(q.overflow()); + + std::vector out; + REQUIRE(q.pop(out)); + REQUIRE(std::memcmp(out.data(), oldest_but_highest_seq, 4) == 0); // survived + + REQUIRE(q.pop(out)); + REQUIRE(std::memcmp(out.data(), incoming, 4) == 0); +} + +TEST_CASE("capacity_octets == 0 falls back to the kMaxEntries bound", + "[respqueue][REQ-RMAP-085]") { + RespQueue q; + uint8_t frame[1] = {0}; + + for (size_t i = 0; i < kMaxEntries; i++) { + REQUIRE(q.push(frame, 1)); + } + REQUIRE_FALSE(q.overflow()); + + REQUIRE(q.push(frame, 1)); + REQUIRE(q.len() == kMaxEntries); + REQUIRE(q.overflow()); +} + +// ── REQ-RMAP-062: the fragmentation-budget helper ─────────────────────────── + +TEST_CASE("max_fragment_payload reserves the header and worst-case pad", + "[respqueue][REQ-RMAP-062]") { + // max_avtpdu_size_octets=32, ABB header=8: 32 - 8 - 3(pad) = 21. + REQUIRE(RespQueue::max_fragment_payload(32, rcp::acf::kAcfCommonHeaderLen) == 21); + + // Same total, but the larger GBB header (16) leaves less budget. + REQUIRE(RespQueue::max_fragment_payload(32, rcp::acf::kAcfGbbMessageInfoLen) == 13); +} + +TEST_CASE("max_fragment_payload is zero when unbounded or no budget remains", + "[respqueue][REQ-RMAP-062]") { + REQUIRE(RespQueue::max_fragment_payload(0, rcp::acf::kAcfCommonHeaderLen) == 0); + REQUIRE(RespQueue::max_fragment_payload(11, rcp::acf::kAcfCommonHeaderLen) == 0); + REQUIRE(RespQueue::max_fragment_payload(5, rcp::acf::kAcfCommonHeaderLen) == 0); +} + +// ── REQ-RMAP-063: the flush_on_count trigger and AVTPDU packing plan ─────── + +TEST_CASE("should_flush triggers once octets reach the threshold", "[respqueue][REQ-RMAP-063]") { + RespQueue q; + uint8_t frame[5] = {0}; + + REQUIRE_FALSE(q.should_flush(8)); // empty + + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE_FALSE(q.should_flush(8)); // 5 < 8 + + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(q.should_flush(8)); // 10 >= 8 +} + +TEST_CASE("should_flush with a zero threshold means any nonempty queue", + "[respqueue][REQ-RMAP-063]") { + RespQueue q; + uint8_t frame[1] = {0}; + + REQUIRE_FALSE(q.should_flush(0)); + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(q.should_flush(0)); +} + +TEST_CASE("plan_batch is zero for an empty queue", "[respqueue][REQ-RMAP-063]") { + RespQueue q; + REQUIRE(q.plan_batch(100) == 0); +} + +TEST_CASE("plan_batch always keeps at least one entry", "[respqueue][REQ-RMAP-063]") { + RespQueue q; + std::vector big(50, 0); + + REQUIRE(q.push(big.data(), big.size())); + + // A budget smaller than the one queued entry still plans that one + // entry -- push()'s own REQ-RMAP-061 enforcement already guarantees + // every queued entry individually fits. + REQUIRE(q.plan_batch(10) == 1); +} + +TEST_CASE("plan_batch packs as many entries as fit", "[respqueue][REQ-RMAP-063]") { + RespQueue q; + uint8_t frame[5] = {0}; + + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(q.push(frame, sizeof(frame))); + + // Budget 12: entries 1+2 (10 octets) fit, entry 3 would push to 15. + REQUIRE(q.plan_batch(12) == 2); + + // Budget 0 (unbounded): every remaining entry packs into one AVTPDU. + REQUIRE(q.plan_batch(0) == 3); +} + +// ── REQ-RMAP-064/065: the Flush_time trigger, independent of queue state ─── + +TEST_CASE("should_flush_by_time: zero flush_time means count-only", "[respqueue][REQ-RMAP-064]") { + REQUIRE_FALSE(RespQueue::should_flush_by_time(0, 0)); + REQUIRE_FALSE(RespQueue::should_flush_by_time(1000000, 0)); +} + +TEST_CASE("should_flush_by_time fires at or past the configured interval", + "[respqueue][REQ-RMAP-064]") { + REQUIRE_FALSE(RespQueue::should_flush_by_time(999, 1000)); + REQUIRE(RespQueue::should_flush_by_time(1000, 1000)); + REQUIRE(RespQueue::should_flush_by_time(1001, 1000)); +} + +TEST_CASE("should_flush_by_time is independent of queue state", "[respqueue][REQ-RMAP-065]") { + RespQueue q; + uint8_t frame[3] = {0}; + + // REQ-RMAP-065: the Flush_time trigger must fire the same way whether + // the queue is empty or not -- unlike should_flush(), the + // flush_on_count trigger, which is false for an empty queue. + REQUIRE(RespQueue::should_flush_by_time(2000, 1000)); + REQUIRE(q.plan_batch(100) == 0); + + REQUIRE(q.push(frame, sizeof(frame))); + REQUIRE(RespQueue::should_flush_by_time(2000, 1000)); + REQUIRE(q.plan_batch(100) == 1); +}