diff --git a/include/rcp/request.hpp b/include/rcp/request.hpp index 9ddaca0..776af22 100644 --- a/include/rcp/request.hpp +++ b/include/rcp/request.hpp @@ -10,6 +10,79 @@ // fusa:req REQ-SEQ-010 // fusa:req REQ-SEQ-011 // fusa:req REQ-SEQ-012 +// fusa:req REQ-CMP-001 +// fusa:req REQ-CMP-002 +// fusa:req REQ-CMP-003 +// fusa:req REQ-CMP-008 +// fusa:req REQ-CMP-009 +// fusa:req REQ-CMP-010 +// fusa:req REQ-CMP-011 +// fusa:req REQ-CMP-012 +// fusa:req REQ-CMP-013 +// fusa:req REQ-CMP-014 +// fusa:req REQ-CMP-015 +// fusa:req REQ-CMP-016 +// fusa:req REQ-CMP-017 +// fusa:req REQ-CMP-018 +// fusa:req REQ-CMP-019 +// fusa:req REQ-CMP-020 +// fusa:req REQ-CMP-021 +// fusa:req REQ-CMP-022 +// fusa:req REQ-CMP-023 +// fusa:req REQ-CMP-024 +// fusa:req REQ-CMP-025 +// fusa:req REQ-CMP-026 +// fusa:req REQ-CMP-027 +// fusa:req REQ-CMP-028 +// fusa:req REQ-CMP-029 +// fusa:req REQ-TRIG-001 +// fusa:req REQ-TRIG-003 +// fusa:req REQ-TRIG-004 +// fusa:req REQ-TRIG-005 +// fusa:req REQ-TRIG-006 +// fusa:req REQ-TRIG-007 +// fusa:req REQ-TRIG-008 +// fusa:req REQ-TRIG-009 +// fusa:req REQ-TRIG-010 +// fusa:req REQ-TRIG-011 +// fusa:req REQ-TRIG-012 +// fusa:req REQ-TRIG-013 +// fusa:req REQ-CHAIN-002 +// fusa:req REQ-CHAIN-003 +// fusa:req REQ-CHAIN-004 +// fusa:req REQ-CHAIN-005 +// fusa:req REQ-CHAIN-006 +// fusa:req REQ-CHAIN-007 +// fusa:req REQ-CHAIN-010 +// fusa:req REQ-CHAIN-011 +// fusa:req REQ-CHAIN-012 +// fusa:req REQ-TIMED-002 +// fusa:req REQ-TIMED-003 +// fusa:req REQ-TIMED-004 +// fusa:req REQ-TIMED-005 +// fusa:req REQ-TIMED-006 +// fusa:req REQ-TIMED-007 +// fusa:req REQ-TIMED-008 +// fusa:req REQ-TIMED-009 +// fusa:req REQ-TIMED-010 +// fusa:req REQ-TIMED-011 +// fusa:req REQ-CANCEL-002 +// fusa:req REQ-CANCEL-003 +// fusa:req REQ-CANCEL-004 +// fusa:req REQ-CANCEL-005 +// fusa:req REQ-CANCEL-006 +// fusa:req REQ-CANCEL-007 +// fusa:req REQ-CANCEL-008 +// fusa:req REQ-CANCEL-009 +// fusa:req REQ-CANCEL-010 +// fusa:req REQ-CANCEL-011 +// fusa:req REQ-CANCEL-013 +// fusa:req REQ-CANCEL-014 +// fusa:req REQ-CANCEL-015 +// fusa:req REQ-SCHED-002 +// fusa:req REQ-SCHED-003 +// fusa:req REQ-SCHED-007 +// fusa:req REQ-SCHED-008 // Conditional-request taxonomy and sequencer-state primitives — the // message_timestamp-repurposing decode, the five conditional request kinds @@ -17,49 +90,159 @@ // sequencer-state advance/reset, cancellation semantics, and the request // lifecycle state machine an OPEN Alliance TC18 Remote Control Protocol // Specification v0.5.1_RC server needs once it goes beyond the mandatory -// "standard" request kind (extraction §2.4, §2.7, §3.1, §3.11, §3.14). This -// is RELAY spec §13.7.2's `request` standard-module-name entry: "conditional- -// request taxonomy (compound, compound-wait, triggered, chained, timed) and -// sequencers" — both halves already lived together in this one file, so the -// module (originally rcp/sequencer.hpp) is renamed rcp/request.hpp to match -// the registry rather than split; SequencerTable stays as the internal name -// for the sequencer-state sub-concept it specifically models. +// "standard" request kind. // -// ROADMAP.md milestone 49, "Conditional-Request Taxonomy & Sequencers -// (v2.5.0)": this header rides on top of rcp/acf.hpp's existing ACF_GBB -// support (v2.0.0, split from the original rcp/wire.hpp into rcp/avtp.hpp -// and rcp/acf.hpp per RELAY spec §13.7.2) and rcp/regmap.hpp's existing -// `sequencer_states` storage (v2.1.0) without changing either — the mtv=0 -// repurposing trick is a new decode path over already-implemented fields, -// and sequencer *behavior* (default value, advance-on-finalization) is built -// on top of regmap.hpp's already-allocated storage rather than duplicating -// it. It also consumes rcp/spi.hpp's compound_wait_matches and -// rcp/i2c.hpp's compound_wait_matches_bits as the per-endpoint-type -// condition-comparison rules a real compound-wait dispatch loop would call -// once it decides a compound-wait request is due; this header does not -// re-implement either. +// ── Phase 1 rewrite (cpp-RCP issue #129, ROADMAP.md "Phase 17") ───────────── +// This is a from-c-RCP content re-derivation, not a restructuring: c-RCP +// (this project's RC5-spec-conformant reference) models the same taxonomy +// as three modules — request.h/request.c (compound/compound-wait/triggered/ +// chained/timed/cancellation), request_sequencer.h/request_sequencer.c (the +// shared sequencer-state bank), and scheduler.h/scheduler.c (request-kind +// classification and execution-priority ordering). This file keeps cpp-RCP's +// own, already-established unification of all three into one module — that +// shape predates this rewrite and stays; what changed is the *content*, +// re-derived from c-RCP's current, RC5-correct behavior. c-RCP's own +// multi-request-per-frame splitting (scheduler.c's +// rcp_sched_split_frame_members) is deliberately NOT re-implemented here — +// rcp/acf.hpp's decode_acf_messages() already does that at the ACF-framing +// layer cpp-RCP settled on, so nothing in this file needs to walk raw ACF +// message boundaries itself. // -// ROADMAP.md milestone 50, "E2E CRC Safe Points & Safety-Request Variants -// (v2.6.0)", extends this taxonomy in place rather than superseding it: the -// three MSB-set (0x8x) safety-tagged opcodes (CompoundSafety, -// CompoundWaitSafety, TriggeredSafety), is_safety_variant(), and -// request_record_for() are new; everything else in this file — the -// lifecycle state machine, cancellation semantics (including -// cancel_all(non_safestate_only), which already implements v2.6.0's -// watchdog-overflow purge-normal/retain-safety rule), and the priority -// ordering — is unchanged. Safe-state execution eligibility itself -// (whether a safety-tagged request's endpoint is actually in its -// configured safe state right now) is evaluated by rcp/e2e.hpp, not here — -// this header only decodes and categorizes the opcode. +// Real content deltas found while re-deriving this file from c-RCP and +// fixed as part of this pass: +// +// 1. cpp-RCP issue #58 — cs-bit polarity inverted in +// should_execute_chained(): the old body was +// `return cs || !predecessor_errored;`, meaning cs=true (this codebase's +// prior, backwards reading of "execute regardless") never aborted a +// successor no matter what its predecessor did. c-RCP's own +// rcp_chained_advance() (src/request.c) and its test +// test_advance_abort_on_error_stops_the_chain (tests/ +// test_request_chained.c) pin the opposite, TC18-correct polarity: cs=0 +// (RCP_CHAINED_CS_CONTINUE_ON_ERROR) executes regardless of a +// predecessor's error, cs=1 (RCP_CHAINED_CS_ABORT_ON_ERROR) aborts this +// member (and, transitively, the rest of the chain) when the +// predecessor errored. Fixed below to +// `return !cs || !predecessor_errored;`. +// +// 2. compound_wait_check_of()/CompoundWaitCheck — this cpp-RCP-original +// concept (cs selects "check immediately" vs "check only after the +// monitored value changes" for a compound-wait request) has NO +// counterpart in c-RCP: c-RCP's rcp_compound_encode_request() hard-codes +// cs=0 for every compound/compound-wait request it builds, and +// rcp_compound_decode_request() does not surface a decoded cs at all — +// acf.h's own comment that "compound-wait... assign[s] cs a meaning of +// its own" (TC18 §11.2.2.3 Table 8) is round-tripped but never actually +// interpreted anywhere in c-RCP's implementation (same "round-trip now, +// activate later" precedent request.h's own file header documents for +// the 0x8x safety-tagged opcodes). What compound-wait's condition +// *comparison mode* actually uses, per c-RCP and per this project's own +// acf.hpp, is the ACF header's evt[2:0] field — acf:: +// compound_wait_evt_valid()/compound_wait_match() (added in the +// just-merged acf/avtp port) — never cs. compound_wait_check_of() and +// CompoundWaitCheck are removed below (not ported: there is nothing +// correct to port); this header's compound/compound-wait request codec +// takes an evt_op parameter and callers use acf::compound_wait_evt_ +// valid()/compound_wait_match() directly, exactly like every other +// endpoint header in this codebase already does for its own compare- +// mode dispatch. cs keeps exactly one meaning in this file now: the +// chained abort-on-error selector above. +// +// 3. RequestLedger::cancel_single() collapsed two different c-RCP outcomes +// into one error: c-RCP's rcp_cancel_attempt() distinguishes +// RCP_CANCEL_RESULT_NOT_FOUND (the target transaction_num was never +// tracked at all) from RCP_CANCEL_RESULT_NOT_CANCELLABLE (found, but +// already past the queued/executing window). cpp-RCP reported +// request_not_found for both. Fixed below: a target that exists but has +// moved to UnderExecution/Finalized/Canceled now reports the new +// RequestErrc::request_not_cancellable; only a genuinely untracked +// transaction_num reports request_not_found. +// +// 4. SequencerTable::try_advance() modeled compound/compound-wait +// finalization as "advance by exactly one, wrapping, iff still at the +// expected start state" — this codebase's own invention, and wrong +// relative to c-RCP: c-RCP's rcp_compound_step_t carries an explicit +// next_state sub-field (0 is the "leave the sequencer exactly where it +// is" sentinel, not "advance to state 0"), and rcp_compound_advance_ +// guard()/rcp_compound_start_condition_met() are two DIFFERENT +// predicates — the guard requires the sequencer literally sitting in +// start_state (and never true for a disabled, state==0 sequencer, REQ- +// SEQ-012); the start condition additionally treats start_state==0 as +// an "any state" wildcard for whether the request may *begin* at all. +// Replaced below with CompoundStep (carrying next_state) plus +// SequencerTable::advance_guard()/start_condition_met()/ +// apply_next_state()/tick()/wait_tick(), ported directly from c-RCP's +// rcp_compound_advance_guard()/_start_condition_met()/apply_next_state() +// (static helper)/rcp_compound_tick()/rcp_compound_wait_tick(). +// +// Genuinely new behavior ported from c-RCP that cpp-RCP never had at all +// (not a correction — c-RCP has this, cpp-RCP simply lacked it): +// - Structured wire sub-fields for every conditional-request kind +// (CompoundStep, TriggeredStep, chained's chain_exec_delay, timed's +// presentation_time, clear-single's clear_transaction_num) plus their +// encode/decode pairs over acf::encode_acf_gbb/decode_acf_gbb, reserved- +// octet rejection, and (for the three cancellation kinds plus clear-non- +// safestate) the evt[2:0]/hs/cs-must-be-zero wire validation. +// - TriggeredRuntime and its occurrence-counter/threshold/fire-tick +// primitives (ported from rcp_triggered_runtime_t and rcp_triggered_ +// runtime_enter_started()/_record_occurrence()/rcp_triggered_threshold_ +// reached()/rcp_triggered_tick()) — a triggered request has no sequencer +// of its own; this state is independent of SequencerTable entirely. +// - Timed-request admission: timed_too_far()/timed_admit()/timed_due(), +// ported from rcp_timed_too_far()/_admit()/_due(), plus timed_feature_ +// enabled() and wire_error_for() mapping an admission outcome onto +// acf::WireErrorCode::GptpFail/PresentationTimeTooFar (this project's +// own numbered-wire-error-code enum, already covering both values — +// no new error table needed). cpp-RCP's svr_implemented_options bitmask +// (regmap::kOptConditionalRequests) is coarser than c-RCP's own four +// independent per-feature bits (compound-wait/trigger/chained/time-sync) +// — timed_feature_enabled() below gates on kOptConditionalRequests, +// the one bit this codebase already uses for "any conditional-request +// kind is implemented", rather than inventing a cpp-RCP-only time-sync +// bit no other module in this tree would ever set. +// +// Deliberately NOT ported (a "genuinely better shape" call, not an +// oversight — see the file's own established precedent for documenting such +// calls): +// - c-RCP's rcp_chained_advance()/rcp_cancel_chain_should_cascade() model +// a chain positionally (has_predecessor bool, 0-based chain_position) +// because c-RCP's request.c is a pure-function library with no request +// store of its own. cpp-RCP's RequestLedger already tracks chained_ +// predecessor/chained_successors as a real graph and cascades +// cancellation/abort by walking it (cascade_cancel/ +// propagate_chain_completion) — strictly more capable than a caller +// re-deriving position-based cascade logic itself, so it stays as the +// one chained-sequencing model in this file. should_execute_chained() +// below is still ported 1:1 (fixed for polarity, see delta #1 above) as +// the pure per-successor predicate that graph walk consults. +// +// TODO(phase1-followup): c-RCP's REQ-SEQ-013/014 (request_sequencer.h) give +// each sequencer its own owner (Request_stream_index) and a fail-closed +// rcp_sequencer_access_permitted()/access_check() gate — "a sequencer with +// no owner yet configured permits no client at all". SequencerTable below +// has no owner storage at all (it remains a thin behavior layer over +// regmap::RegisterMap::sequencer_states, which itself has no parallel +// owner-per-sequencer vector) — adding it means extending regmap.hpp's +// storage, which is out of this pass's stated scope (request.hpp content +// only). Left for a follow-up that touches regmap.hpp deliberately. +// +// TODO(phase1-followup): c-RCP's rcp_compound_peek_request_type() / +// rcp_timed_encode_request_tscf() are thin convenience wrappers (peek the +// opcode byte without a full decode; build a TSCF-headed ACF_ABB timed +// request instead of the NTSCF/repurposed-timestamp path) this file does +// not reproduce — a caller already has acf::peek_acf_msg_type() plus this +// file's own decode_* functions for the first, and acf::encode_acf_abb() + +// avtp::encode_tscf() (composed exactly as c-RCP's own thin wrapper does) +// for the second; neither is core protocol behavior, just a named +// convenience c-RCP happens to also offer. // // Field names and behavior below implement TC18's *behavior* as described in -// an internal structured extraction of the specification named above; no -// text from that document is reproduced here. The concrete opcode-to-byte -// mapping, the param-byte layout within the repurposed timestamp slot, and -// the request-ledger data model chosen in this file are this -// implementation's own encoding of that behavior, same as the equivalent -// disclaimers in rcp/avtp.hpp, rcp/acf.hpp, rcp/regmap.hpp, -// rcp/endpoint.hpp, rcp/spi.hpp, and rcp/i2c.hpp. This header models the +// an internal structured extraction of the specification; no text from that +// document is reproduced here. The concrete opcode-to-byte mapping, the +// param-byte layout within the repurposed timestamp slot, and the request- +// ledger data model chosen in this file are this implementation's own +// encoding of that behavior, same as the equivalent disclaimers in +// rcp/avtp.hpp, rcp/acf.hpp, and rcp/regmap.hpp. This header models the // taxonomy, the bundling rules, and the state machine's transitions and // effects — it does not implement a running scheduler thread; wiring // select_next_due()'s output into an actual dispatch loop is left to the @@ -91,9 +274,14 @@ enum class RequestErrc : int { unknown_transaction = 4, // RequestLedger lookup by transaction_num failed invalid_lifecycle_transition = 5, // requested state transition is not the single next step transaction_num_collision = 6, // submit() with a transaction_num already tracked - request_not_found = 7, // REQUEST_NOT_FOUND — cancel_single target unknown or past cancellation + request_not_found = 7, // REQUEST_NOT_FOUND — cancel_single target unknown request_canceled = 8, // REQUEST_CANCELED — recorded outcome of a canceled request compound_bundle_incomplete = 9, // compound/compound-wait claimed without every required companion capability + request_not_cancellable = 10, // found, but past the queued/executing window (c-RCP RCP_CANCEL_RESULT_NOT_CANCELLABLE) + reserved_field_nonzero = 11, // a reserved wire sub-field octet carries a set bit + evt_hs_cs_nonzero = 12, // evt[2:0]/hs/cs must be zero for this request kind and are not + unsupported_cmd = 13, // hs and/or cs set on a Timed request + ledger_full = 14, // RequestLedger has reached its fixed capacity (kMaxTrackedRequests) }; inline const std::error_category& request_category() noexcept { @@ -120,6 +308,16 @@ inline const std::error_category& request_category() noexcept { case RequestErrc::compound_bundle_incomplete: return "rcp/request: compound support requires compound-wait, clear-non-safestate, " "and >=4 sequencers together, not compound alone"; + case RequestErrc::request_not_cancellable: + return "rcp/request: found, but past the queued/executing cancellable window"; + case RequestErrc::reserved_field_nonzero: + return "rcp/request: a reserved wire sub-field octet is not zero"; + case RequestErrc::evt_hs_cs_nonzero: + return "rcp/request: evt[2:0], hs, or cs must be zero for this request kind"; + case RequestErrc::unsupported_cmd: + return "rcp/request: hs/cs must be clear on a Timed request"; + case RequestErrc::ledger_full: + return "rcp/request: RequestLedger has reached its fixed capacity"; default: return "rcp/request: unknown error"; } @@ -138,45 +336,16 @@ inline std::error_code make_error_code(RequestErrc e) noexcept { // slot it would otherwise carry (and which ACF_GBB always reserves space for // regardless of `mtv`, per rcp/acf.hpp) is repurposed: its first byte // becomes this opcode, and the remaining 7 bytes carry opcode-specific -// parameters (extraction §2.7). The eight opcodes below are every kind this -// milestone defines: five conditional request kinds plus three cancellation -// kinds. The mandatory "standard" request kind (rcp::acf::RequestKind) has -// no opcode here at all — it is always carried as ACF_ABB, which has no +// parameters. The eleven opcodes below are every kind this module defines: +// five conditional request kinds, three cancellation kinds, and the three +// MSB-set (0x8x) safety-tagged variants of compound/compound-wait/triggered. +// The mandatory "standard" request kind (rcp::acf::RequestKind) has no +// opcode here at all — it is always carried as ACF_ABB, which has no // message_timestamp slot to repurpose in the first place. // -// Wire-position note (v2.22.0): this header deals only in the *value* of -// that 64-bit slot, never in where it sits on the wire, so the v2.22.0 fix -// that moved the slot to its real position (spliced between the ACF_GBB -// Message Info block's two header quadlets, octets 4..11, rather than -// after both of them at octets 8..15 — see rcp/acf.hpp's -// kAcfGbbTimestampOffset comment block) changed nothing here. The -// specification's own compound-request figure independently confirms this -// header's byte assignment within the slot: at octets 4..11 of an mtv=0 -// ACF_GBB it draws request_type / cmp_start_state / cmp_next_state / -// cmp_sequencer then cmp_exec_delay / cmp_repetitions — i.e. the opcode is -// the slot's first (most significant) byte and the parameters are the -// remaining seven, exactly as encode_request_type/decode_request_type -// below already treated them. - -// ROADMAP.md milestone 50 (v2.6.0) adds three more: the MSB-set (`0x8x`) -// safety-tagged variants of compound, compound-wait, and triggered. Each -// shares its base opcode's low 7 bits with its normal counterpart -// (Compound 0x0F -> CompoundSafety 0x8F, CompoundWait 0x0B -> -// CompoundWaitSafety 0x8B, Triggered 0x0E -> TriggeredSafety 0x8E) — the -// MSB alone is what marks a request as safety-tagged (extraction §2.7, §6 -// item 4); is_safety_variant() below is the single place that bit gets -// interpreted, rather than every call site re-deriving it from the raw -// byte value. A safety-tagged request only actually executes once its -// endpoint is in its configured safe state — see rcp/e2e.hpp's -// endpoint_in_configured_safe_state()/may_execute_now(), which this header -// does not itself evaluate (no dependency on rcp/regmap.hpp's -// RequestStreamConfig watchdog/safe-state fields here, consistent with -// this file's existing "primitives, not policy" scope). Cancellation's -// existing non_safestate_only bundle (0x06 / cancel_all(true)) already -// implements the watchdog-overflow purge-normal/retain-safety queue -// behavior the roadmap calls for — see rcp/e2e.hpp's -// apply_watchdog_overflow(), which is a thin wrapper around that call, not -// a reimplementation of it. +// A safety-tagged request only actually executes once its endpoint is in its +// configured safe state — see rcp/e2e.hpp's endpoint_in_configured_safe_ +// state()/may_execute_now(), which this header does not itself evaluate. enum class RequestTypeOpcode : uint8_t { Chained = 0x01, @@ -212,10 +381,8 @@ constexpr bool is_valid_request_type(uint8_t byte) noexcept { } // is_safety_variant reports whether `type` is one of the three MSB-set -// (`0x8x`) safety-tagged opcodes above. This is the single source of truth -// RequestRecord::is_safety (below) is derived from — see -// request_record_for() — rather than something a caller sets by hand and -// might forget for a safety-tagged decode path. +// (`0x8x`) safety-tagged opcodes above — the single source of truth +// RequestRecord::is_safety is derived from (see request_record_for()). constexpr bool is_safety_variant(RequestTypeOpcode type) noexcept { switch (type) { case RequestTypeOpcode::CompoundWaitSafety: @@ -227,11 +394,21 @@ constexpr bool is_safety_variant(RequestTypeOpcode type) noexcept { } } +// is_compound/is_compound_wait/is_triggered — ported from c-RCP's +// rcp_request_type_is_compound()/_is_compound_wait()/_is_triggered(): each +// recognizes its own opcode AND its safety-tagged counterpart. +constexpr bool is_compound(RequestTypeOpcode type) noexcept { + return type == RequestTypeOpcode::Compound || type == RequestTypeOpcode::CompoundSafety; +} +constexpr bool is_compound_wait(RequestTypeOpcode type) noexcept { + return type == RequestTypeOpcode::CompoundWait || type == RequestTypeOpcode::CompoundWaitSafety; +} +constexpr bool is_triggered(RequestTypeOpcode type) noexcept { + return type == RequestTypeOpcode::Triggered || type == RequestTypeOpcode::TriggeredSafety; +} + // encode_request_type packs `type` into the top byte and `params` into the -// remaining 7 bytes of a 64-bit value, most-significant byte first — the -// same big-endian convention rcp/avtp.hpp's put_u64 uses elsewhere in this -// codebase, kept consistent here even though this file does not call that -// (internal, unexported) helper directly. +// remaining 7 bytes of a 64-bit value, most-significant byte first. constexpr uint64_t encode_request_type(RequestTypeOpcode type, const std::array& params) noexcept { uint64_t v = static_cast(static_cast(type)) << 56; for (size_t i = 0; i < 7; ++i) @@ -240,12 +417,9 @@ constexpr uint64_t encode_request_type(RequestTypeOpcode type, const std::array< } // decode_request_type is the inverse of encode_request_type, gated on `mtv` -// being clear — a caller that has not first checked AcfMessageInfo::mtv (or -// that is decoding a genuinely timestamped ACF_GBB message) gets a decode -// failure rather than silently misreading a real timestamp's high byte as an -// opcode, matching this codebase's established preference (see -// rcp/discovery.hpp's NTSCF-only enforcement) for modeling a precondition -// violation as a returned error rather than an implicit caller obligation. +// being clear — a caller that has not first checked AcfMessageInfo::mtv gets +// a decode failure rather than silently misreading a real timestamp's high +// byte as an opcode. inline std::error_code decode_request_type(bool mtv, uint64_t message_timestamp, RequestTypeOpcode& out_type, std::array& out_params) noexcept { @@ -261,10 +435,8 @@ inline std::error_code decode_request_type(bool mtv, uint64_t message_timestamp, // make_conditional_request builds the AcfMessageInfo header for a // conditional/cancellation request: always ACF_GBB, always mtv=false (the // repurposing trick above requires it), with `cs` set per the caller's -// request-kind-specific meaning (see the `cs` section below). Callers pass -// the result to rcp::acf::encode_acf_gbb alongside encode_request_type's -// output as the message_timestamp argument — no new wire encoding is -// introduced here, only this semantic layer over the existing one. +// request-kind-specific meaning — after this pass's delta #2 above, the only +// kind that gives cs real meaning is chained (should_execute_chained). inline acf::AcfMessageInfo make_conditional_request(avtp::ByteBusId bus_id, uint8_t transaction_num, bool cs) noexcept { acf::AcfMessageInfo info; @@ -276,11 +448,161 @@ inline acf::AcfMessageInfo make_conditional_request(avtp::ByteBusId bus_id, uint return info; } +namespace detail { + +// decode_repurposed is the shared first stage every kind-specific decode_* +// function below runs: decode the ACF_GBB envelope, then reinterpret its +// message_timestamp as a request_type opcode + 7 parameter bytes. Kind- +// specific decoders take it from here (validate the opcode is one they +// recognize, unpack their own sub-fields, apply their own reserved/evt/hs/cs +// rules). +inline std::error_code decode_repurposed(const uint8_t* b, size_t len, acf::AcfMessageInfo& out_info, + RequestTypeOpcode& out_type, std::array& out_params, + std::vector& out_payload) noexcept { + uint64_t ts = 0; + auto ec = acf::decode_acf_gbb(b, len, out_info, ts, out_payload); + if (ec) return ec; + return decode_request_type(out_info.mtv, ts, out_type, out_params); +} + +// reserved_zero/reserved_nonzero_error: small shared helpers so each kind's +// own reserved-octet check below is a one-liner instead of a hand-rolled +// loop repeated five times. +constexpr bool all_zero(const std::array& p, std::initializer_list indices) noexcept { + for (size_t i : indices) + if (p[i] != 0) return false; + return true; +} + +inline std::error_code check_evt_hs_cs_zero(const acf::AcfMessageInfo& info) noexcept { + if (info.evt_op != 0 || info.hs || info.cs) return make_error_code(RequestErrc::evt_hs_cs_nonzero); + return {}; +} + +} // namespace detail + +// ── Compound / compound-wait (0x0F/0x8F, 0x0B/0x8B) ─────────────────────────── +// Compound and compound-wait share one on-wire sub-field shape — the +// specification defines both kinds with identical sub-field widths and +// offsets (only the field-name prefix differs) — so one struct, CompoundStep, +// models both, ported from c-RCP's rcp_compound_step_t. + +constexpr uint16_t kCompoundRepeatInfinite = 0xFFFF; + +struct CompoundStep { + regmap::SequencerState start_state = 0; // the sequencer state this step requires + regmap::SequencerState next_state = 0; // the state this step advances the sequencer to; 0 = "leave it where it is" + uint8_t sequencer_index = 0; // which of a table's sequencers this step targets + uint16_t exec_delay = 0; // counted in multiples of the endpoint's own ep_delay_time, NOT ms + uint16_t repeat_count = 0; // remaining repetitions; kCompoundRepeatInfinite = never decrement +}; + +// Octet offsets within the repurposed 8-byte message_timestamp region: +// 0 request_type | 1 start_state | 2 next_state | 3 sequencer_index | +// 4..5 exec_delay (BE) | 6..7 repeat_count (BE) +constexpr std::array encode_compound_step_params(const CompoundStep& step) noexcept { + return { + step.start_state, + step.next_state, + step.sequencer_index, + static_cast((step.exec_delay >> 8) & 0xFF), + static_cast(step.exec_delay & 0xFF), + static_cast((step.repeat_count >> 8) & 0xFF), + static_cast(step.repeat_count & 0xFF), + }; +} + +constexpr CompoundStep decode_compound_step_params(const std::array& p) noexcept { + CompoundStep step; + step.start_state = p[0]; + step.next_state = p[1]; + step.sequencer_index = p[2]; + step.exec_delay = static_cast((static_cast(p[3]) << 8) | p[4]); + step.repeat_count = static_cast((static_cast(p[5]) << 8) | p[6]); + return step; +} + +struct CompoundRequest { + RequestTypeOpcode type{}; + avtp::ByteBusId byte_bus_id = 0; + CompoundStep step; + uint8_t evt_op = 0; // ACF header evt[2:0] — compound-wait's own comparison-mode + // selector, see acf::compound_wait_evt_valid()/_match() + uint8_t transaction_num = 0; + std::vector payload; +}; + +// encode_compound_request builds an ACF_GBB-framed compound or compound-wait +// request. `type` must be one of Compound[Safety]/CompoundWait[Safety]; `cs` +// is always forced false (delta #2 above — cs has no meaning here in +// c-RCP's own implementation). evt_op is packed into the ACF header's own +// evt[2:0] field, never into one of `step`'s repurposed sub-field bytes. +inline std::vector encode_compound_request(RequestTypeOpcode type, avtp::ByteBusId byte_bus_id, + const CompoundStep& step, uint8_t evt_op, + uint8_t transaction_num, + const std::vector& payload = {}) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.evt_op = static_cast(evt_op & 0x7); + info.cs = false; + info.transaction_num = transaction_num; + const uint64_t ts = encode_request_type(type, encode_compound_step_params(step)); + return acf::encode_acf_gbb(info, ts, payload); +} + +inline std::error_code decode_compound_request(const uint8_t* b, size_t len, CompoundRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + auto ec = detail::decode_repurposed(b, len, info, type, params, out.payload); + if (ec) return ec; + if (!is_compound(type) && !is_compound_wait(type)) return make_error_code(RequestErrc::unknown_request_type); + + out.type = type; + out.byte_bus_id = info.byte_bus_id; + out.step = decode_compound_step_params(params); + out.evt_op = info.evt_op; + out.transaction_num = info.transaction_num; + return {}; +} + +// ── clear-non-safestate (0x06) ──────────────────────────────────────────────── +// Part of the compound bundle (see FeatureSet below): cancels every pending/ +// started non-safety-tagged request. Carries no sub-field of its own beyond +// the opcode byte; every trailing octet, and evt[2:0]/hs/cs, must be zero. + +struct ClearNonSafestateRequest { + avtp::ByteBusId byte_bus_id = 0; + uint8_t transaction_num = 0; +}; + +inline std::vector encode_clear_non_safestate(avtp::ByteBusId byte_bus_id, uint8_t transaction_num) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + const uint64_t ts = encode_request_type(RequestTypeOpcode::ClearNonSafestate, {}); + return acf::encode_acf_gbb(info, ts, {}); +} + +inline std::error_code decode_clear_non_safestate(const uint8_t* b, size_t len, + ClearNonSafestateRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + std::vector payload; + auto ec = detail::decode_repurposed(b, len, info, type, params, payload); + if (ec) return ec; + if (type != RequestTypeOpcode::ClearNonSafestate) return make_error_code(RequestErrc::unknown_request_type); + if (!detail::all_zero(params, {0, 1, 2, 3, 4, 5, 6})) return make_error_code(RequestErrc::reserved_field_nonzero); + ec = detail::check_evt_hs_cs_zero(info); + if (ec) return ec; + + out.byte_bus_id = info.byte_bus_id; + out.transaction_num = info.transaction_num; + return {}; +} + // ── Request categories & execution-priority ordering ────────────────────────── -// The seven categories simultaneously-due requests are ordered across -// (extraction §3.14). Numeric values double as priority rank — lower value -// wins — so priority_rank below is a trivial cast rather than a lookup -// table that could drift out of sync with declaration order. enum class RequestCategory : uint8_t { Cancellation = 0, @@ -293,12 +615,8 @@ enum class RequestCategory : uint8_t { }; // category_of maps a decoded request_type opcode onto its priority category; -// `std::nullopt` denotes the mandatory standard request kind (no opcode, per -// the header comment above). A safety-tagged (0x8x) opcode shares its base -// opcode's category — the MSB affects safe-state execution eligibility and -// watchdog-purge treatment (rcp/e2e.hpp), not where it sits in the -// cancellation > triggered > timed > compound > compound-wait > chained > -// standard priority ordering (extraction §3.14). +// `std::nullopt` denotes the mandatory standard request kind (no opcode). A +// safety-tagged (0x8x) opcode shares its base opcode's category. constexpr RequestCategory category_of(std::optional kind) noexcept { if (!kind.has_value()) return RequestCategory::Standard; switch (*kind) { @@ -326,21 +644,14 @@ constexpr RequestCategory category_of(std::optional kind) noe constexpr uint8_t priority_rank(RequestCategory c) noexcept { return static_cast(c); } -// DueCandidate is the minimal shape select_next_due needs: which category a -// due request belongs to, and its arrival order for the FIFO tie-break. It -// deliberately does not reference RequestRecord below, so the priority -// algorithm stays testable independent of the ledger that owns real -// records. struct DueCandidate { RequestCategory category = RequestCategory::Standard; size_t arrival_seq = 0; }; -// select_next_due picks the winning index out of `due` — every entry is -// assumed already due, this function only orders them — by lowest -// priority_rank, breaking ties by lowest arrival_seq (extraction §3.14: -// "ties resolved FIFO"). Returns std::nullopt for an empty input rather than -// an out-of-range index. +// select_next_due picks the winning index out of `due` by lowest +// priority_rank, breaking ties by lowest arrival_seq (FIFO). Returns +// std::nullopt for an empty input rather than an out-of-range index. inline std::optional select_next_due(const std::vector& due) noexcept { if (due.empty()) return std::nullopt; size_t best = 0; @@ -356,39 +667,96 @@ inline std::optional select_next_due(const std::vector& du return best; } -// ── The `cs` field's two request-kind-specific meanings ─────────────────────── -// AcfMessageInfo::cs (rcp/acf.hpp) is a single wire bit whose meaning -// depends entirely on which conditional request kind it rides on (extraction -// §2.4): for compound-wait it selects when the wait condition is first -// checked; for chained it selects whether a successor cares about its -// predecessor's outcome at all. - -enum class CompoundWaitCheck : uint8_t { - Immediate = 0, // evaluate the condition against current status right away - AfterChangeOnly = 1, // only evaluate once the monitored value changes from its current one -}; - -constexpr CompoundWaitCheck compound_wait_check_of(bool cs) noexcept { - return cs ? CompoundWaitCheck::Immediate : CompoundWaitCheck::AfterChangeOnly; +// frame_timing_consistent — ported from c-RCP's rcp_sched_frame_timing_ +// consistent(): a TSCF-headed AVTPDU's single avtp_timestamp applies +// uniformly to every ACF member packed inside it, so a frame mixing a Timed +// (0x0A) member with any non-Timed member is never well-formed — either +// every member in a TSCF frame is itself Timed, or none are. NTSCF frames +// carry no shared presentation time and are exempt entirely. +// member_is_timed.empty() is trivially consistent (true), matching c-RCP's +// own count==0 case. +inline bool frame_timing_consistent(bool is_tscf, const std::vector& member_is_timed) noexcept { + if (!is_tscf || member_is_timed.empty()) return true; + for (size_t i = 1; i < member_is_timed.size(); ++i) + if (member_is_timed[i] != member_is_timed[0]) return false; + return true; } +// ── The `cs` field's one remaining meaning: chained abort-on-error ─────────── +// AcfMessageInfo::cs (rcp/acf.hpp) is a single wire bit. After this pass's +// delta #2 above, chained is the only request kind in this file that gives +// it real meaning: cs is read off the member about to run, about its +// predecessor's outcome. + // should_execute_chained answers, for one chained successor: given its own // `cs` bit and whether its predecessor finished in error, should this -// successor still run? cs=true means "execute regardless"; cs=false means -// "abort if the predecessor errored". A predecessor that did *not* error -// always permits the successor to run, independent of cs. +// successor still run? cs=false (RCP_CHAINED_CS_CONTINUE_ON_ERROR) means +// "execute regardless"; cs=true (RCP_CHAINED_CS_ABORT_ON_ERROR) means "abort +// if the predecessor errored". A predecessor that did *not* error always +// permits the successor to run, independent of cs. (cpp-RCP issue #58: this +// polarity was inverted before this pass — see delta #1 above.) constexpr bool should_execute_chained(bool cs, bool predecessor_errored) noexcept { - return cs || !predecessor_errored; + return !cs || !predecessor_errored; +} + +// ── Chained (0x01) ───────────────────────────────────────────────────────────── +// A chain member carries exactly one sub-field of its own, chain_exec_delay, +// at octets 4..5; octets 1..3 and 6..7 are reserved (all-zero). cs (the +// member's own abort/continue selector, see should_execute_chained above) +// rides the ACF header's own cs bit, not one of the repurposed sub-fields. + +struct ChainedMember { + avtp::ByteBusId byte_bus_id = 0; + uint16_t chain_exec_delay = 0; // measured from the predecessor's own finalization + bool cs = false; + uint8_t transaction_num = 0; + std::vector payload; +}; + +inline std::vector encode_chained_member(avtp::ByteBusId byte_bus_id, uint16_t chain_exec_delay, + bool cs, uint8_t transaction_num, + const std::vector& payload = {}) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.cs = cs; + info.transaction_num = transaction_num; + const std::array params = { + 0, 0, 0, + static_cast((chain_exec_delay >> 8) & 0xFF), + static_cast(chain_exec_delay & 0xFF), + 0, 0, + }; + const uint64_t ts = encode_request_type(RequestTypeOpcode::Chained, params); + return acf::encode_acf_gbb(info, ts, payload); +} + +inline std::error_code decode_chained_member(const uint8_t* b, size_t len, ChainedMember& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + auto ec = detail::decode_repurposed(b, len, info, type, params, out.payload); + if (ec) return ec; + if (type != RequestTypeOpcode::Chained) return make_error_code(RequestErrc::unknown_request_type); + if (!detail::all_zero(params, {0, 1, 2, 5, 6})) return make_error_code(RequestErrc::reserved_field_nonzero); + + out.byte_bus_id = info.byte_bus_id; + out.chain_exec_delay = static_cast((static_cast(params[3]) << 8) | params[4]); + out.cs = info.cs; + out.transaction_num = info.transaction_num; + return {}; +} + +inline bool chained_exec_delay_elapsed(uint16_t chain_exec_delay, uint32_t elapsed) noexcept { + return elapsed >= static_cast(chain_exec_delay); } // ── Optional-feature bundling ────────────────────────────────────────────────── -// The roadmap is explicit that compound support cannot be claimed piecemeal: -// a repo implementing compound (0x0F) must also implement compound-wait -// (0x0B), clear-non-safestate cancellation (0x06), and at least 4 sequencer -// slots — all four together, or none of them advertised as supported. -// Triggered, chained, and timed are each independently flaggable; clear- -// single is its own "enhanced cancellation" capability with no further -// dependency modeled here (extraction §3.1, §2.7). +// Compound support cannot be claimed piecemeal: a repo implementing compound +// (0x0F) must also implement compound-wait (0x0B), clear-non-safestate +// cancellation (0x06), and at least 4 sequencer slots — all four together, +// or none of them advertised as supported. Triggered, chained, and timed are +// each independently flaggable; clear-single is its own "enhanced +// cancellation" capability with no further dependency modeled here. constexpr size_t kMinCompoundSequencers = 4; @@ -403,9 +771,6 @@ struct FeatureSet { size_t sequencer_count = 0; }; -// validate_feature_bundles enforces the compound/compound-wait bundle rule -// above. It takes no position on triggered/chained/timed/clear_single, which -// this milestone's scope leaves independently flaggable. inline std::error_code validate_feature_bundles(const FeatureSet& f) noexcept { const bool claims_compound_support = f.compound || f.compound_wait; if (!claims_compound_support) return {}; @@ -416,9 +781,7 @@ inline std::error_code validate_feature_bundles(const FeatureSet& f) noexcept { // implemented_options_bits reports the svr_implemented_options bit(s) // (rcp/regmap.hpp) a register map should advertise for a validated feature -// set. Returns 0 for a feature set with nothing conditional enabled at all — -// callers should not report kOptConditionalRequests unless at least one of -// the five conditional kinds is actually implemented. +// set. Returns 0 for a feature set with nothing conditional enabled at all. inline uint32_t implemented_options_bits(const FeatureSet& f) noexcept { const bool any_conditional = f.compound || f.compound_wait || f.triggered || f.chained || f.timed; return any_conditional ? regmap::kOptConditionalRequests : 0; @@ -426,13 +789,12 @@ inline uint32_t implemented_options_bits(const FeatureSet& f) noexcept { // ── Sequencer-state registers ────────────────────────────────────────────────── // SequencerTable is the behavior layer over rcp/regmap.hpp's already- -// allocated `RegisterMap::sequencer_states` storage (extraction §3.11, -// §3.14): every sequencer defaults to kDefaultState (not the vector's own -// zero-initialization default), and a sequencer only advances when a -// compound request finalizes while that sequencer is still holding its -// expected start value — a mismatch leaves the sequencer untouched rather -// than erroring, since "the sequencer moved on already" is an ordinary -// outcome of concurrent compound requests racing, not a fault. +// allocated `RegisterMap::sequencer_states` storage: every sequencer +// defaults to kDefaultState (RCP_SEQUENCER_POWER_ON_STATE, 1) rather than the +// vector's own zero default, and a sequencer with state == 0 is DISABLED +// (REQ-SEQ-012, TC18 Table 28) — no compound/compound-wait step may ever +// start from or advance through state 0, including a step whose own +// start_state is the "any state" wildcard below. class SequencerTable { public: static constexpr regmap::SequencerState kDefaultState = 1; @@ -454,39 +816,473 @@ class SequencerTable { return {}; } - // try_advance implements the compound-request finalization rule - // directly: the sequencer at `index` advances by one (wrapping at the - // 8-bit storage width, matching the "persistent 8-bit state" the roadmap - // specifies) iff its current value equals `expected_start`; otherwise it - // is left untouched. `out_advanced` reports which happened; the returned - // std::error_code is reserved for the index-out-of-range case only, so - // callers cannot mistake "didn't advance because the expected state did - // not match" for a failure of the call itself. - std::error_code try_advance(size_t index, regmap::SequencerState expected_start, - bool& out_advanced) noexcept { + std::error_code set_state(size_t index, regmap::SequencerState state) noexcept { if (index >= states_.size()) return make_error_code(RequestErrc::index_out_of_range); - if (states_[index] == expected_start) { - states_[index] = static_cast(states_[index] + 1); - out_advanced = true; - } else { - out_advanced = false; - } + states_[index] = state; return {}; } void reset_all_to_default() noexcept { std::fill(states_.begin(), states_.end(), kDefaultState); } + // advance_guard — ported from c-RCP's rcp_compound_advance_guard(): + // true iff step.sequencer_index is currently sitting in step.start_state + // AND that state is not 0 (a disabled sequencer can never satisfy this, + // even if start_state itself happens to be written as 0 — REQ-SEQ-012). + bool advance_guard(const CompoundStep& step) const noexcept { + regmap::SequencerState cur = 0; + if (state_of(step.sequencer_index, cur)) return false; + if (cur == 0) return false; + return cur == step.start_state; + } + + // start_condition_met — ported from c-RCP's rcp_compound_start_ + // condition_met(): true iff this step's *start* condition is satisfied, + // i.e. the request may begin at all. Deliberately NOT the same predicate + // as advance_guard(): for a start_state of zero, the request starts in + // any state (the "any state" wildcard), but advance_guard() above still + // only reports true when the sequencer happens to already be sitting in + // literal state 0 — which itself can never hold, since 0 means disabled. + // A disabled (state==0) sequencer never satisfies this either, checked + // before the wildcard so "any state" cannot itself paper over disabled. + bool start_condition_met(const CompoundStep& step) const noexcept { + regmap::SequencerState cur = 0; + if (state_of(step.sequencer_index, cur)) return false; + if (cur == 0) return false; + if (step.start_state == 0) return true; // start in any (enabled) state + return cur == step.start_state; + } + + // apply_next_state honours the "remain in the current state" sentinel: a + // next_state of zero leaves the sequencer exactly where it is (still + // validating sequencer_index) rather than driving it to state zero + // (which would disable it — never this function's job to do implicitly). + std::error_code apply_next_state(const CompoundStep& step) noexcept { + if (step.next_state == 0) { + regmap::SequencerState dummy = 0; + return state_of(step.sequencer_index, dummy); // validates the index only + } + return set_state(step.sequencer_index, step.next_state); + } + + static bool exec_delay_elapsed(const CompoundStep& step, uint32_t elapsed) noexcept { + return elapsed >= static_cast(step.exec_delay); + } + + // tick — compound's own: advances iff both exec_delay_elapsed and + // advance_guard hold; otherwise the table is left entirely unchanged. + bool tick(const CompoundStep& step, uint32_t elapsed) noexcept { + if (!exec_delay_elapsed(step, elapsed)) return false; + if (!advance_guard(step)) return false; + return !apply_next_state(step); + } + + // wait_tick — compound-wait's own: advances iff both condition_met (the + // caller's own already-evaluated acf::compound_wait_match() result) and + // advance_guard hold. Unlike tick(), elapsing exec_delay alone is never + // sufficient here. + bool wait_tick(const CompoundStep& step, bool condition_met) noexcept { + if (!condition_met) return false; + if (!advance_guard(step)) return false; + return !apply_next_state(step); + } + private: std::vector& states_; }; +// ── Triggered (0x0E/0x8E) ────────────────────────────────────────────────────── +// A triggered request's execution condition is "a named trigger signal, +// emitted by a named endpoint, has occurred at least a named number of +// times". A triggered request has no sequencer of its own and no start/next +// state — it has no dependency on SequencerTable at all. + +constexpr uint16_t kTriggeredRepeatInfinite = 0xFFFF; + +struct TriggeredStep { + uint8_t trigger_source_ep = 0; // the endpoint whose trigger signal this request waits on + uint8_t trigger_signal_nr = 0; // which of that endpoint's trigger signals + uint8_t trigger_threshold = 0; // occurrences that must precede execution: 0 fires on the first, N on the (N+1)th + uint16_t exec_delay = 0; + uint16_t repeat_count = 0; // kTriggeredRepeatInfinite = never decrement +}; + +// Octet offsets: 0 request_type | 1 trigger_source_ep | 2 trigger_signal_nr | +// 3 trigger_threshold | 4..5 exec_delay (BE) | 6..7 repeat_count (BE) +constexpr std::array encode_triggered_step_params(const TriggeredStep& step) noexcept { + return { + step.trigger_source_ep, + step.trigger_signal_nr, + step.trigger_threshold, + static_cast((step.exec_delay >> 8) & 0xFF), + static_cast(step.exec_delay & 0xFF), + static_cast((step.repeat_count >> 8) & 0xFF), + static_cast(step.repeat_count & 0xFF), + }; +} + +constexpr TriggeredStep decode_triggered_step_params(const std::array& p) noexcept { + TriggeredStep step; + step.trigger_source_ep = p[0]; + step.trigger_signal_nr = p[1]; + step.trigger_threshold = p[2]; + step.exec_delay = static_cast((static_cast(p[3]) << 8) | p[4]); + step.repeat_count = static_cast((static_cast(p[5]) << 8) | p[6]); + return step; +} + +struct TriggeredRequest { + RequestTypeOpcode type{}; + avtp::ByteBusId byte_bus_id = 0; + TriggeredStep step; + uint8_t transaction_num = 0; + std::vector payload; +}; + +inline std::vector encode_triggered_request(RequestTypeOpcode type, avtp::ByteBusId byte_bus_id, + const TriggeredStep& step, uint8_t transaction_num, + const std::vector& payload = {}) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + const uint64_t ts = encode_request_type(type, encode_triggered_step_params(step)); + return acf::encode_acf_gbb(info, ts, payload); +} + +inline std::error_code decode_triggered_request(const uint8_t* b, size_t len, TriggeredRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + auto ec = detail::decode_repurposed(b, len, info, type, params, out.payload); + if (ec) return ec; + if (!is_triggered(type)) return make_error_code(RequestErrc::unknown_request_type); + + out.type = type; + out.byte_bus_id = info.byte_bus_id; + out.step = decode_triggered_step_params(params); + out.transaction_num = info.transaction_num; + return {}; +} + +// TriggeredRuntime is one triggered request's own runtime (not wire-carried) +// state: how many matching trigger occurrences have been observed since +// entering "started". Ported from c-RCP's rcp_triggered_runtime_t. +struct TriggeredRuntime { + uint32_t occurrence_count = 0; + bool started = false; +}; + +inline void triggered_enter_started(TriggeredRuntime& rt) noexcept { + rt.occurrence_count = 0; + rt.started = true; +} + +// triggered_record_occurrence increments rt.occurrence_count, and returns +// true, iff rt.started AND the occurrence matches this request's own +// selection. A non-matching occurrence, or one arriving while rt has not +// entered "started", leaves rt entirely unchanged. Independent of any +// endpoint idle/busy status. +inline bool triggered_record_occurrence(TriggeredRuntime& rt, const TriggeredStep& step, uint8_t source_ep, + uint8_t signal_nr) noexcept { + if (!rt.started) return false; + if (source_ep != step.trigger_source_ep || signal_nr != step.trigger_signal_nr) return false; + ++rt.occurrence_count; + return true; +} + +inline bool triggered_threshold_reached(const TriggeredStep& step, const TriggeredRuntime& rt) noexcept { + return rt.occurrence_count > static_cast(step.trigger_threshold); +} + +inline bool triggered_exec_delay_elapsed(const TriggeredStep& step, uint32_t elapsed) noexcept { + return elapsed >= static_cast(step.exec_delay); +} + +// triggered_tick is the fire transition: resets rt (occurrence_count=0, +// started=false) and returns true iff ALL of started, threshold_reached, +// exec_delay_elapsed, and endpoint_idle hold. Otherwise rt is left entirely +// unchanged. endpoint_idle gates only the fire transition — the occurrence +// counter itself is deliberately not gated on it. Advances no sequencer: a +// triggered request has none. +inline bool triggered_tick(const TriggeredStep& step, TriggeredRuntime& rt, uint32_t elapsed, + bool endpoint_idle) noexcept { + if (!rt.started) return false; + if (!triggered_threshold_reached(step, rt)) return false; + if (!triggered_exec_delay_elapsed(step, elapsed)) return false; + if (!endpoint_idle) return false; + rt.occurrence_count = 0; + rt.started = false; + return true; +} + +// ── Timed (0x0A) ─────────────────────────────────────────────────────────────── +// A per-request alternative to a TSCF header: presentation_time is a 48-bit +// gPTP-domain instant in nanoseconds, reduced modulo 2^48 (rolls over every +// few days), packed directly into the repurposed message_timestamp region +// (octets 2..7; octet 1 is reserved) instead of a TSCF header's shared +// avtp_timestamp. + +constexpr uint64_t kTimedPresentationTimeMax = 0x0000FFFFFFFFFFFFull; +constexpr uint64_t kTimedPresentationTimeModulus = 0x0001000000000000ull; + +struct TimedRequest { + avtp::ByteBusId byte_bus_id = 0; + uint64_t presentation_time = 0; // in [0, kTimedPresentationTimeMax] + uint8_t transaction_num = 0; + std::vector payload; +}; + +// timed_feature_enabled — ported from c-RCP's rcp_timed_feature_enabled(), +// which requires c-RCP's own RCP_REGMAP_OPT_TIME_SYNC bit. cpp-RCP's +// svr_implemented_options is a coarser bitmask than c-RCP's four independent +// per-feature bits; kOptConditionalRequests ("compound/triggered/timed/ +// chained requests") is the one bit this codebase already uses to mean "some +// conditional-request kind, including Timed, is implemented". +inline bool timed_feature_enabled(uint32_t options) noexcept { + return (options & regmap::kOptConditionalRequests) != 0; +} + +// encode_timed_request returns std::nullopt if presentation_time exceeds +// kTimedPresentationTimeMax — never silently truncated, matching c-RCP's own +// "zeroed rcp_bytes_t on invalid input" convention for this one validated +// case (rcp_timed_encode_request()). +inline std::optional> encode_timed_request(avtp::ByteBusId byte_bus_id, + uint64_t presentation_time, + uint8_t transaction_num, + const std::vector& payload = {}) { + if (presentation_time > kTimedPresentationTimeMax) return std::nullopt; + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + const std::array params = { + 0, // reserved (octet 1) + static_cast((presentation_time >> 40) & 0xFF), + static_cast((presentation_time >> 32) & 0xFF), + static_cast((presentation_time >> 24) & 0xFF), + static_cast((presentation_time >> 16) & 0xFF), + static_cast((presentation_time >> 8) & 0xFF), + static_cast(presentation_time & 0xFF), + }; + const uint64_t ts = encode_request_type(RequestTypeOpcode::Timed, params); + return acf::encode_acf_gbb(info, ts, payload); +} + +inline std::error_code decode_timed_request(const uint8_t* b, size_t len, TimedRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + auto ec = detail::decode_repurposed(b, len, info, type, params, out.payload); + if (ec) return ec; + if (type != RequestTypeOpcode::Timed) return make_error_code(RequestErrc::unknown_request_type); + if (params[0] != 0) return make_error_code(RequestErrc::reserved_field_nonzero); + if (info.hs || info.cs) return make_error_code(RequestErrc::unsupported_cmd); + + uint64_t pt = 0; + for (size_t i = 1; i < 7; ++i) pt = (pt << 8) | params[i]; + out.presentation_time = pt; + out.byte_bus_id = info.byte_bus_id; + out.transaction_num = info.transaction_num; + return {}; +} + +enum class TimedAdmission : uint8_t { + Accept, + RejectGptpFail, + RejectPresentationTimeTooFar, +}; + +namespace detail { +inline uint64_t timed_forward_delta(uint64_t presentation_time, uint64_t now) noexcept { + return (presentation_time - now) & kTimedPresentationTimeMax; +} +inline bool timed_in_the_past(uint64_t delta) noexcept { + return delta > (kTimedPresentationTimeModulus / 2); +} +} // namespace detail + +// timed_too_far — true iff presentation_time sits strictly in the future of +// `now` (wraparound-safe, modulo the 48-bit rollover period) by more than +// max_horizon. A presentation_time at or before now is never "too far". +inline bool timed_too_far(uint64_t presentation_time, uint64_t now, uint64_t max_horizon) noexcept { + const uint64_t delta = detail::timed_forward_delta(presentation_time, now); + if (detail::timed_in_the_past(delta)) return false; + return delta > max_horizon; +} + +inline bool timed_due(uint64_t presentation_time, uint64_t now) noexcept { + const uint64_t delta = detail::timed_forward_delta(presentation_time, now); + return delta == 0 || detail::timed_in_the_past(delta); +} + +// timed_admit is the combined admission decision: RejectGptpFail if +// !gptp_locked (takes priority — presentation_time cannot be trusted at all +// without a locked time base), else RejectPresentationTimeTooFar if +// timed_too_far(), else Accept. +inline TimedAdmission timed_admit(bool gptp_locked, uint64_t presentation_time, uint64_t now, + uint64_t max_horizon) noexcept { + if (!gptp_locked) return TimedAdmission::RejectGptpFail; + if (timed_too_far(presentation_time, now, max_horizon)) return TimedAdmission::RejectPresentationTimeTooFar; + return TimedAdmission::Accept; +} + +// wire_error_for maps a TimedAdmission onto this project's own numbered wire +// error code (acf::WireErrorCode, extraction Table 27) for a caller +// populating an Error Response frame. std::nullopt for Accept — nothing to +// report. +inline std::optional wire_error_for(TimedAdmission a) noexcept { + switch (a) { + case TimedAdmission::RejectGptpFail: return acf::WireErrorCode::GptpFail; + case TimedAdmission::RejectPresentationTimeTooFar: return acf::WireErrorCode::PresentationTimeTooFar; + default: return std::nullopt; + } +} + +// ── Cancellation: clear-all (0x05) and clear-single (0x07) ─────────────────── +// clear-non-safestate (0x06) lives in the compound section above (it is part +// of the compound bundle, not independently flaggable). clear-all and +// clear-single both carry no payload of their own beyond their opcode byte +// (and, for clear-single, the one clear_transaction_num sub-field at octet +// 3) — the remaining sub-field bytes are always reserved-zero, and +// evt[2:0]/hs/cs must be zero for both, same rule as clear-non-safestate. + +struct ClearAllRequest { + avtp::ByteBusId byte_bus_id = 0; + uint8_t transaction_num = 0; +}; + +inline std::vector encode_clear_all(avtp::ByteBusId byte_bus_id, uint8_t transaction_num) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + const uint64_t ts = encode_request_type(RequestTypeOpcode::ClearAll, {}); + return acf::encode_acf_gbb(info, ts, {}); +} + +inline std::error_code decode_clear_all(const uint8_t* b, size_t len, ClearAllRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + std::vector payload; + auto ec = detail::decode_repurposed(b, len, info, type, params, payload); + if (ec) return ec; + if (type != RequestTypeOpcode::ClearAll) return make_error_code(RequestErrc::unknown_request_type); + if (!detail::all_zero(params, {0, 1, 2, 3, 4, 5, 6})) return make_error_code(RequestErrc::reserved_field_nonzero); + ec = detail::check_evt_hs_cs_zero(info); + if (ec) return ec; + + out.byte_bus_id = info.byte_bus_id; + out.transaction_num = info.transaction_num; + return {}; +} + +struct ClearSingleRequest { + avtp::ByteBusId byte_bus_id = 0; + uint8_t clear_transaction_num = 0; // the previously-queued request this cancels + uint8_t transaction_num = 0; +}; + +// Octet offsets: 0 request_type | 1..2 reserved | 3 clear_transaction_num | 4..7 reserved +inline std::vector encode_clear_single(avtp::ByteBusId byte_bus_id, uint8_t clear_transaction_num, + uint8_t transaction_num) { + acf::AcfMessageInfo info; + info.byte_bus_id = byte_bus_id; + info.transaction_num = transaction_num; + const std::array params = {0, 0, clear_transaction_num, 0, 0, 0, 0}; + const uint64_t ts = encode_request_type(RequestTypeOpcode::ClearSingle, params); + return acf::encode_acf_gbb(info, ts, {}); +} + +inline std::error_code decode_clear_single(const uint8_t* b, size_t len, ClearSingleRequest& out) noexcept { + acf::AcfMessageInfo info; + RequestTypeOpcode type{}; + std::array params{}; + std::vector payload; + auto ec = detail::decode_repurposed(b, len, info, type, params, payload); + if (ec) return ec; + if (type != RequestTypeOpcode::ClearSingle) return make_error_code(RequestErrc::unknown_request_type); + if (!detail::all_zero(params, {0, 1, 3, 4, 5, 6})) return make_error_code(RequestErrc::reserved_field_nonzero); + ec = detail::check_evt_hs_cs_zero(info); + if (ec) return ec; + + out.clear_transaction_num = params[2]; + out.byte_bus_id = info.byte_bus_id; + out.transaction_num = info.transaction_num; + return {}; +} + +// ── Fixed-capacity bounded storage ───────────────────────────────────────────── +// c-RCP's own request.c/request_sequencer.c have no ledger-shaped storage to +// port a bound from (request.c is a pure-function library; request_ +// sequencer.c's table is still heap-allocated via rcp_malloc(count), sized +// from svr_sequencers_max at runtime — deliberately NOT yet converted to +// fixed-capacity as of this pass, per c-RCP's own CHANGELOG.md, issue #521 +// "[c-RCP-17]"). RequestLedger below is a cpp-RCP-original data structure +// (c-RCP has no equivalent), so this bound is this port's own engineering +// decision, not a literal c-RCP value. +// +// transaction_num's own domain is a full uint8_t (256 distinct values), but +// kMaxTrackedRequests is deliberately chosen well below that: RequestLedger +// tracks every submitted transaction_num for the ledger's entire lifetime +// (finalized/canceled records are kept, never evicted — see +// RequestLedger::submit's own doc comment), and submit()'s collision check +// runs BEFORE its capacity check — so a bound of exactly 256 would make the +// full-ledger path unreachable through the public API: once every uint8_t +// value is tracked, every possible resubmission is by definition a +// collision, and the capacity check could never independently fire (dead +// code, undetectable by any test). 64 — the same order of magnitude as +// other c-RCP-17 fixed-capacity conversions elsewhere in this project's +// history (e.g. RCP_POWERSTATE_MAX_ENDPOINTS=64) — is comfortably below +// transaction_num's full range, so a genuinely fresh, never-submitted +// transaction_num can always still exist once the ledger is full, making +// the ledger_full outcome a real, reachable, testable scenario rather than +// unreachable defensive code. +constexpr size_t kMaxTrackedRequests = 64; + +namespace detail { + +// BoundedVector — a small, fixed-capacity, std::array-backed +// container with std::vector-shaped ergonomics (push_back, size, iteration) +// for the one thing RequestLedger needs: push_back that reports "full" +// instead of growing without bound. +template +class BoundedVector { +public: + bool push_back(T value) noexcept { + if (size_ >= Capacity) return false; + data_[size_] = std::move(value); + ++size_; + return true; + } + + size_t size() const noexcept { return size_; } + static constexpr size_t capacity() noexcept { return Capacity; } + bool full() const noexcept { return size_ >= Capacity; } + bool empty() const noexcept { return size_ == 0; } + + T& operator[](size_t i) noexcept { return data_[i]; } + const T& operator[](size_t i) const noexcept { return data_[i]; } + + T* begin() noexcept { return data_.data(); } + T* end() noexcept { return data_.data() + size_; } + const T* begin() const noexcept { return data_.data(); } + const T* end() const noexcept { return data_.data() + size_; } + +private: + std::array data_{}; + size_t size_ = 0; +}; + +} // namespace detail + // ── Request lifecycle state machine ──────────────────────────────────────────── // pending -> started -> under_execution -> finalized, forward-only and -// single-step (extraction §3.14), mirroring rcp/lifecycle.hpp's own -// forward-only ServerState progression. `canceled` is a separate terminal -// state reachable only from pending/started — extraction §2.7's rule that -// "already-executing requests finish" means under_execution and finalized -// requests are never canceled by this ledger, only left to complete. +// single-step, mirroring rcp/lifecycle.hpp's own forward-only ServerState +// progression. `canceled` is a separate terminal state reachable only from +// pending/started — already-executing requests finish, never cancel here. +// c-RCP has no equivalent state machine at all (request.c is a pure-function +// library with no request-store concept of its own); RequestState/ +// RequestRecord/RequestLedger below remain this codebase's own original +// design, unchanged in shape by this pass except for the two content fixes +// (deltas #3 and #4 in the file header above). enum class RequestState : uint8_t { Pending, @@ -497,11 +1293,9 @@ enum class RequestState : uint8_t { }; // RequestRecord is one tracked conditional/cancellation/standard request. -// `request_type` is std::nullopt for a standard request (see category_of -// above); chained-successor bookkeeping (`chained_predecessor`, -// `chained_successors`) is populated by the caller when it submits a chained -// request, since only the caller knows the wire-level identifier a -// predecessor/successor pair share. +// `request_type` is std::nullopt for a standard request; chained-successor +// bookkeeping (`chained_predecessor`, `chained_successors`) is populated by +// the caller when it submits a chained request. struct RequestRecord { uint8_t transaction_num = 0; std::optional request_type; @@ -511,8 +1305,11 @@ struct RequestRecord { size_t arrival_seq = 0; // assigned by RequestLedger::submit, FIFO tie-break key // Compound / compound-wait finalization target (unused by other kinds). - std::optional sequencer_index; - regmap::SequencerState expected_start_state = SequencerTable::kDefaultState; + // Carries an explicit next_state (see delta #4 in the file header) — + // replaces the pre-pass sequencer_index/expected_start_state pair, which + // modeled finalization as an unconditional +1 with no way to express + // "leave the sequencer where it is" or "jump to an arbitrary state". + std::optional compound_step; // Chained-request linkage (unused by other kinds). std::optional chained_predecessor; @@ -522,13 +1319,10 @@ struct RequestRecord { }; // request_record_for builds a RequestRecord with `is_safety` derived -// automatically from `type` via is_safety_variant() rather than left for -// the caller to set by hand — ROADMAP.md milestone 50 (v2.6.0)'s hook for -// the 0x8x decode path referenced in this header's own comments above. -// Compound/compound-wait/chained-specific fields (sequencer_index, -// expected_start_state, chained_predecessor, chained_successors) are left -// at their defaults; callers that need them still set them directly, same -// as before this helper existed. +// automatically from `type` via is_safety_variant(). Compound/compound-wait/ +// chained-specific fields (compound_step, chained_predecessor, +// chained_successors) are left at their defaults; callers that need them +// still set them directly. inline RequestRecord request_record_for(uint8_t transaction_num, std::optional type, bool cs) noexcept { RequestRecord rec; @@ -541,11 +1335,11 @@ inline RequestRecord request_record_for(uint8_t transaction_num, std::optionalsequencer_index.has_value() && - (rec->request_type == RequestTypeOpcode::Compound || - rec->request_type == RequestTypeOpcode::CompoundWait)) { - bool advanced = false; - sequencers->try_advance(*rec->sequencer_index, rec->expected_start_state, advanced); + if (rec != nullptr && sequencers != nullptr && rec->compound_step.has_value() && + rec->request_type.has_value() && + (is_compound(*rec->request_type) || is_compound_wait(*rec->request_type))) { + if (sequencers->advance_guard(*rec->compound_step)) { + sequencers->apply_next_state(*rec->compound_step); + } } propagate_chain_completion(txn, errored); return {}; @@ -593,15 +1393,18 @@ class RequestLedger { // cancel_single implements clear-single (0x07): cancels `txn` and every // transitive chained successor if `txn` is still Pending/Started; - // returns REQUEST_NOT_FOUND if `txn` is untracked, already Canceled, or - // has moved past cancellation (UnderExecution/Finalized — "already- - // executing requests finish", extraction §2.7). + // returns request_not_found if `txn` was never tracked by this ledger at + // all, or request_not_cancellable if it is tracked but has already moved + // past cancellation (UnderExecution/Finalized/Canceled — "already- + // executing requests finish"). These are two different c-RCP outcomes + // (RCP_CANCEL_RESULT_NOT_FOUND vs RCP_CANCEL_RESULT_NOT_CANCELLABLE, see + // delta #3 in the file header) collapsed into one before this pass. std::error_code cancel_single(uint8_t txn) noexcept { auto* rec = find_mut(txn); if (rec == nullptr) return make_error_code(RequestErrc::request_not_found); if (rec->state == RequestState::UnderExecution || rec->state == RequestState::Finalized || rec->state == RequestState::Canceled) - return make_error_code(RequestErrc::request_not_found); + return make_error_code(RequestErrc::request_not_cancellable); cascade_cancel(txn); return {}; } @@ -611,8 +1414,7 @@ class RequestLedger { // =true, part of the compound bundle): cancels every currently // Pending/Started record (and, for each, its chained successors), // optionally skipping records flagged is_safety. Returns the count of - // top-level records this call transitioned to Canceled (successors - // cascaded from them are not counted separately). + // top-level records this call transitioned to Canceled. size_t cancel_all(bool non_safestate_only) noexcept { size_t count = 0; for (auto& rec : records_) { @@ -638,6 +1440,7 @@ class RequestLedger { } size_t size() const noexcept { return records_.size(); } + static constexpr size_t capacity() noexcept { return kMaxTrackedRequests; } private: RequestRecord* find_mut(uint8_t txn) noexcept { @@ -656,8 +1459,7 @@ class RequestLedger { // cascade_cancel marks `txn` Canceled (idempotent, and a no-op for a // record that has moved past cancellation) and recurses into its - // chained_successors — extraction §2.7's "cancelling a chained request - // cancels its successors" rule. + // chained_successors. void cascade_cancel(uint8_t txn) noexcept { auto* rec = find_mut(txn); if (rec == nullptr) return; @@ -673,26 +1475,27 @@ class RequestLedger { // rule (should_execute_chained above): every record whose // chained_predecessor is `predecessor_txn` is aborted (cascade_cancel) // iff its own cs does not permit executing after a predecessor error. - // A predecessor that finished without error never triggers an abort - // here, regardless of any successor's cs. void propagate_chain_completion(uint8_t predecessor_txn, bool predecessor_errored) noexcept { // Collect matching successors first: cascade_cancel can mutate // records_ only in-place (no insertion/removal), but iterating and - // mutating the same vector via index is simpler to reason about - // than iterator invalidation rules, so gather ids first. - std::vector successors; - for (const auto& rec : records_) - if (rec.chained_predecessor.has_value() && *rec.chained_predecessor == predecessor_txn) - successors.push_back(rec.transaction_num); - for (uint8_t succ : successors) { - const auto* rec = find(succ); + // mutating by id first is simpler to reason about. + std::array successors{}; + size_t successor_count = 0; + for (const auto& rec : records_) { + if (rec.chained_predecessor.has_value() && *rec.chained_predecessor == predecessor_txn && + successor_count < successors.size()) { + successors[successor_count++] = rec.transaction_num; + } + } + for (size_t i = 0; i < successor_count; ++i) { + const auto* rec = find(successors[i]); if (rec != nullptr && !should_execute_chained(rec->cs, predecessor_errored)) - cascade_cancel(succ); + cascade_cancel(successors[i]); } } - std::vector records_; - size_t next_arrival_seq_ = 0; + detail::BoundedVector records_; + size_t next_arrival_seq_ = 0; }; } // namespace request diff --git a/tests/test_request.cpp b/tests/test_request.cpp index d64c192..5bd22a1 100644 --- a/tests/test_request.cpp +++ b/tests/test_request.cpp @@ -10,12 +10,90 @@ // fusa:test REQ-SEQ-010 // fusa:test REQ-SEQ-011 // fusa:test REQ-SEQ-012 - -// Tests for rcp/request.hpp — conditional-request taxonomy and -// sequencer-state primitives (ROADMAP.md milestone 49, -// "Conditional-Request Taxonomy & Sequencers", v2.5.0; file renamed from -// rcp/sequencer.hpp per RELAY spec §13.7.2's `request` standard-module-name -// entry). +// fusa:test REQ-CMP-001 +// fusa:test REQ-CMP-002 +// fusa:test REQ-CMP-003 +// fusa:test REQ-CMP-008 +// fusa:test REQ-CMP-009 +// fusa:test REQ-CMP-010 +// fusa:test REQ-CMP-011 +// fusa:test REQ-CMP-012 +// fusa:test REQ-CMP-013 +// fusa:test REQ-CMP-014 +// fusa:test REQ-CMP-015 +// fusa:test REQ-CMP-016 +// fusa:test REQ-CMP-017 +// fusa:test REQ-CMP-018 +// fusa:test REQ-CMP-019 +// fusa:test REQ-CMP-020 +// fusa:test REQ-CMP-021 +// fusa:test REQ-CMP-022 +// fusa:test REQ-CMP-023 +// fusa:test REQ-CMP-024 +// fusa:test REQ-CMP-025 +// fusa:test REQ-CMP-026 +// fusa:test REQ-CMP-027 +// fusa:test REQ-CMP-028 +// fusa:test REQ-CMP-029 +// fusa:test REQ-TRIG-001 +// fusa:test REQ-TRIG-003 +// fusa:test REQ-TRIG-004 +// fusa:test REQ-TRIG-005 +// fusa:test REQ-TRIG-006 +// fusa:test REQ-TRIG-007 +// fusa:test REQ-TRIG-008 +// fusa:test REQ-TRIG-009 +// fusa:test REQ-TRIG-010 +// fusa:test REQ-TRIG-011 +// fusa:test REQ-TRIG-012 +// fusa:test REQ-TRIG-013 +// fusa:test REQ-CHAIN-002 +// fusa:test REQ-CHAIN-003 +// fusa:test REQ-CHAIN-004 +// fusa:test REQ-CHAIN-005 +// fusa:test REQ-CHAIN-006 +// fusa:test REQ-CHAIN-007 +// fusa:test REQ-CHAIN-010 +// fusa:test REQ-CHAIN-011 +// fusa:test REQ-CHAIN-012 +// fusa:test REQ-TIMED-002 +// fusa:test REQ-TIMED-003 +// fusa:test REQ-TIMED-004 +// fusa:test REQ-TIMED-005 +// fusa:test REQ-TIMED-006 +// fusa:test REQ-TIMED-007 +// fusa:test REQ-TIMED-008 +// fusa:test REQ-TIMED-009 +// fusa:test REQ-TIMED-010 +// fusa:test REQ-TIMED-011 +// fusa:test REQ-CANCEL-002 +// fusa:test REQ-CANCEL-003 +// fusa:test REQ-CANCEL-004 +// fusa:test REQ-CANCEL-005 +// fusa:test REQ-CANCEL-006 +// fusa:test REQ-CANCEL-007 +// fusa:test REQ-CANCEL-008 +// fusa:test REQ-CANCEL-009 +// fusa:test REQ-CANCEL-010 +// fusa:test REQ-CANCEL-011 +// fusa:test REQ-CANCEL-013 +// fusa:test REQ-CANCEL-014 +// fusa:test REQ-CANCEL-015 +// fusa:test REQ-SCHED-002 +// fusa:test REQ-SCHED-003 +// fusa:test REQ-SCHED-007 +// fusa:test REQ-SCHED-008 + +// Tests for rcp/request.hpp — conditional-request taxonomy, sequencer-state +// primitives, and the request lifecycle ledger. Phase 1 rewrite (cpp-RCP +// issue #129, ROADMAP.md "Phase 17"): ported from c-RCP's request.c/ +// request_sequencer.c/scheduler.c and their own test files +// (test_request_compound.c, test_request_chained.c, test_request_triggered.c, +// test_request_timed.c, test_request_cancel.c, test_request_sequencer.c, +// test_scheduler.c) — see rcp/request.hpp's own file header for the full +// list of content deltas this pass found and fixed relative to the prior +// cpp-RCP implementation, including cpp-RCP issue #58 (cs-bit polarity +// inverted in should_execute_chained()). #include #include @@ -46,12 +124,21 @@ TEST_CASE("decode_request_type rejects mtv=1 as not a repurposed slot", "[reques TEST_CASE("decode_request_type rejects an unrecognized opcode byte", "[request][REQ-SEQ-001]") { RequestTypeOpcode type{}; std::array out_params{}; - // 0x02 is not one of the 8 defined opcodes. + // 0x02 is not one of the 11 defined opcodes. const uint64_t ts = uint64_t{0x02} << 56; auto ec = decode_request_type(/*mtv=*/false, ts, type, out_params); REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); } +TEST_CASE("decode_request_type rejects an MSB-set byte that is not one of the three defined " + "safety opcodes", + "[request][REQ-CMP-002]") { + RequestTypeOpcode type{}; + std::array out_params{}; + auto ec = decode_request_type(/*mtv=*/false, uint64_t{0x80} << 56, type, out_params); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + TEST_CASE("make_conditional_request always sets ACF_GBB with mtv clear", "[request][REQ-SEQ-001]") { auto info = make_conditional_request(/*bus_id=*/7, /*transaction_num=*/9, /*cs=*/true); REQUIRE(info.acf_msg_type == rcp::acf::kAcfMsgTypeGbb); @@ -61,30 +148,43 @@ TEST_CASE("make_conditional_request always sets ACF_GBB with mtv clear", "[reque REQUIRE(info.cs); } -TEST_CASE("make_conditional_request round-trips through acf::encode_acf_gbb/decode_acf_gbb", - "[request][REQ-SEQ-001]") { - std::array params{1, 2, 3, 4, 5, 6, 7}; - auto info = make_conditional_request(3, 42, false); - auto ts = encode_request_type(RequestTypeOpcode::Triggered, params); - auto buf = rcp::acf::encode_acf_gbb(info, ts, {}); - - rcp::acf::AcfMessageInfo decoded_info; - uint64_t decoded_ts = 0; - std::vector decoded_payload; - auto ec = rcp::acf::decode_acf_gbb(buf.data(), buf.size(), decoded_info, decoded_ts, decoded_payload); - REQUIRE_FALSE(ec); - REQUIRE_FALSE(decoded_info.mtv); +// ── request_type classification helpers ─────────────────────────────────────── - RequestTypeOpcode type{}; - std::array out_params{}; - REQUIRE_FALSE(decode_request_type(decoded_info.mtv, decoded_ts, type, out_params)); - REQUIRE(type == RequestTypeOpcode::Triggered); - REQUIRE(out_params == params); +TEST_CASE("is_safety_variant identifies exactly the three 0x8x opcodes", "[request][REQ-CMP-001]") { + REQUIRE(is_safety_variant(RequestTypeOpcode::CompoundSafety)); + REQUIRE(is_safety_variant(RequestTypeOpcode::CompoundWaitSafety)); + REQUIRE(is_safety_variant(RequestTypeOpcode::TriggeredSafety)); + + REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Compound)); + REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::CompoundWait)); + REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Triggered)); + REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Chained)); + REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::ClearAll)); +} + +TEST_CASE("is_compound recognizes Compound and CompoundSafety only", "[request][REQ-CMP-002]") { + REQUIRE(is_compound(RequestTypeOpcode::Compound)); + REQUIRE(is_compound(RequestTypeOpcode::CompoundSafety)); + REQUIRE_FALSE(is_compound(RequestTypeOpcode::CompoundWait)); + REQUIRE_FALSE(is_compound(RequestTypeOpcode::CompoundWaitSafety)); + REQUIRE_FALSE(is_compound(RequestTypeOpcode::Chained)); +} + +TEST_CASE("is_compound_wait recognizes CompoundWait and CompoundWaitSafety only", "[request][REQ-CMP-003]") { + REQUIRE(is_compound_wait(RequestTypeOpcode::CompoundWait)); + REQUIRE(is_compound_wait(RequestTypeOpcode::CompoundWaitSafety)); + REQUIRE_FALSE(is_compound_wait(RequestTypeOpcode::Compound)); + REQUIRE_FALSE(is_compound_wait(RequestTypeOpcode::CompoundSafety)); } -// ── Request categories & execution-priority ordering ───────────────────────── +TEST_CASE("is_triggered recognizes Triggered and TriggeredSafety only", "[request][REQ-TRIG-001]") { + REQUIRE(is_triggered(RequestTypeOpcode::Triggered)); + REQUIRE(is_triggered(RequestTypeOpcode::TriggeredSafety)); + REQUIRE_FALSE(is_triggered(RequestTypeOpcode::Compound)); + REQUIRE_FALSE(is_triggered(RequestTypeOpcode::Chained)); +} -TEST_CASE("category_of maps every opcode to its documented category", "[request][REQ-SEQ-002]") { +TEST_CASE("category_of maps every opcode to its documented category", "[request][REQ-SCHED-002]") { REQUIRE(category_of(std::nullopt) == RequestCategory::Standard); REQUIRE(category_of(RequestTypeOpcode::ClearAll) == RequestCategory::Cancellation); REQUIRE(category_of(RequestTypeOpcode::ClearNonSafestate) == RequestCategory::Cancellation); @@ -94,13 +194,12 @@ TEST_CASE("category_of maps every opcode to its documented category", "[request] REQUIRE(category_of(RequestTypeOpcode::Compound) == RequestCategory::Compound); REQUIRE(category_of(RequestTypeOpcode::CompoundWait) == RequestCategory::CompoundWait); REQUIRE(category_of(RequestTypeOpcode::Chained) == RequestCategory::Chained); - // Safety-tagged (0x8x) variants share their base opcode's category. REQUIRE(category_of(RequestTypeOpcode::CompoundSafety) == RequestCategory::Compound); REQUIRE(category_of(RequestTypeOpcode::CompoundWaitSafety) == RequestCategory::CompoundWait); REQUIRE(category_of(RequestTypeOpcode::TriggeredSafety) == RequestCategory::Triggered); } -TEST_CASE("priority_rank orders categories cancellation..standard", "[request][REQ-SEQ-002]") { +TEST_CASE("priority_rank orders categories cancellation..standard", "[request][REQ-SCHED-002]") { REQUIRE(priority_rank(RequestCategory::Cancellation) < priority_rank(RequestCategory::Triggered)); REQUIRE(priority_rank(RequestCategory::Triggered) < priority_rank(RequestCategory::Timed)); REQUIRE(priority_rank(RequestCategory::Timed) < priority_rank(RequestCategory::Compound)); @@ -110,18 +209,18 @@ TEST_CASE("priority_rank orders categories cancellation..standard", "[request][R } TEST_CASE("select_next_due picks the highest-priority category regardless of arrival order", - "[request][REQ-SEQ-002]") { + "[request][REQ-SCHED-003]") { std::vector due{ {RequestCategory::Standard, 0}, {RequestCategory::Chained, 1}, - {RequestCategory::Cancellation, 2}, // arrived last, but highest priority + {RequestCategory::Cancellation, 2}, }; auto winner = select_next_due(due); REQUIRE(winner.has_value()); REQUIRE(*winner == 2); } -TEST_CASE("select_next_due breaks ties within a category by FIFO arrival order", "[request][REQ-SEQ-002]") { +TEST_CASE("select_next_due breaks ties within a category by FIFO arrival order", "[request][REQ-SCHED-003]") { std::vector due{ {RequestCategory::Timed, 5}, {RequestCategory::Timed, 2}, @@ -129,13 +228,211 @@ TEST_CASE("select_next_due breaks ties within a category by FIFO arrival order", }; auto winner = select_next_due(due); REQUIRE(winner.has_value()); - REQUIRE(*winner == 1); // arrival_seq 2 is earliest + REQUIRE(*winner == 1); } -TEST_CASE("select_next_due returns nullopt for an empty candidate set", "[request][REQ-SEQ-002]") { +TEST_CASE("select_next_due returns nullopt for an empty candidate set", "[request][REQ-SCHED-003]") { REQUIRE_FALSE(select_next_due({}).has_value()); } +// ── frame_timing_consistent (REQ-SCHED-007/008) ─────────────────────────────── + +TEST_CASE("frame_timing_consistent is trivially true for NTSCF frames", "[request][REQ-SCHED-007]") { + REQUIRE(frame_timing_consistent(/*is_tscf=*/false, {true, false, true})); + REQUIRE(frame_timing_consistent(/*is_tscf=*/false, {})); +} + +TEST_CASE("frame_timing_consistent is trivially true for an empty member list", "[request][REQ-SCHED-007]") { + REQUIRE(frame_timing_consistent(/*is_tscf=*/true, {})); +} + +TEST_CASE("frame_timing_consistent accepts a uniform TSCF frame", "[request][REQ-SCHED-007]") { + REQUIRE(frame_timing_consistent(/*is_tscf=*/true, {true, true, true})); + REQUIRE(frame_timing_consistent(/*is_tscf=*/true, {false, false})); +} + +TEST_CASE("frame_timing_consistent rejects a TSCF frame mixing timed and untimed members", + "[request][REQ-SCHED-008]") { + REQUIRE_FALSE(frame_timing_consistent(/*is_tscf=*/true, {true, false})); + REQUIRE_FALSE(frame_timing_consistent(/*is_tscf=*/true, {false, true, false})); +} + +// ── The `cs` field's one remaining meaning: chained abort-on-error ─────────── +// cpp-RCP issue #58: should_execute_chained()'s cs polarity was inverted +// before this pass (old body: `return cs || !predecessor_errored;`, which +// never aborted a successor for cs=true no matter what). These tests pin +// the corrected, c-RCP-verified polarity: cs=false (CONTINUE_ON_ERROR) +// executes regardless; cs=true (ABORT_ON_ERROR) aborts iff the predecessor +// errored. Every REQUIRE below fails against the pre-pass body. + +TEST_CASE("should_execute_chained: cs=false (continue-on-error) always executes regardless of " + "predecessor outcome", + "[request][REQ-CHAIN-010]") { + REQUIRE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/true)); + REQUIRE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/false)); +} + +TEST_CASE("should_execute_chained: cs=true (abort-on-error) aborts only when the predecessor errored " + "(cpp-RCP issue #58 regression pin)", + "[request][REQ-CHAIN-010]") { + REQUIRE_FALSE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/true)); + REQUIRE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/false)); +} + +// ── Compound / compound-wait (0x0F/0x8F, 0x0B/0x8B) ─────────────────────────── + +TEST_CASE("encode_compound_step_params/decode_compound_step_params round-trip at the " + "specification's own sub-field offsets", + "[request][REQ-CMP-010]") { + CompoundStep step; + step.start_state = 3; + step.next_state = 5; + step.sequencer_index = 2; + step.exec_delay = 0x1234; + step.repeat_count = 0x5678; + + auto params = encode_compound_step_params(step); + REQUIRE(params[0] == 3); + REQUIRE(params[1] == 5); + REQUIRE(params[2] == 2); + REQUIRE(params[3] == 0x12); + REQUIRE(params[4] == 0x34); + REQUIRE(params[5] == 0x56); + REQUIRE(params[6] == 0x78); + + auto decoded = decode_compound_step_params(params); + REQUIRE(decoded.start_state == step.start_state); + REQUIRE(decoded.next_state == step.next_state); + REQUIRE(decoded.sequencer_index == step.sequencer_index); + REQUIRE(decoded.exec_delay == step.exec_delay); + REQUIRE(decoded.repeat_count == step.repeat_count); +} + +TEST_CASE("kCompoundRepeatInfinite is the two-octet all-ones sentinel", "[request][REQ-CMP-010]") { + REQUIRE(kCompoundRepeatInfinite == 0xFFFF); +} + +TEST_CASE("compound request round-trips through encode_compound_request/decode_compound_request", + "[request][REQ-CMP-011]") { + CompoundStep step; + step.start_state = 1; + step.next_state = 2; + step.sequencer_index = 0; + step.exec_delay = 10; + step.repeat_count = 3; + std::vector payload{0xAA, 0xBB}; + + auto encoded = encode_compound_request(RequestTypeOpcode::Compound, /*byte_bus_id=*/5, step, + /*evt_op=*/0, /*transaction_num=*/9, payload); + + CompoundRequest out; + auto ec = decode_compound_request(encoded.data(), encoded.size(), out); + REQUIRE_FALSE(ec); + REQUIRE(out.type == RequestTypeOpcode::Compound); + REQUIRE(out.byte_bus_id == 5); + REQUIRE(out.step.start_state == 1); + REQUIRE(out.step.next_state == 2); + REQUIRE(out.step.exec_delay == 10); + REQUIRE(out.step.repeat_count == 3); + REQUIRE(out.transaction_num == 9); + REQUIRE(out.payload == payload); +} + +TEST_CASE("compound-wait safety request round-trips, evt_op independent of step sub-fields", + "[request][REQ-CMP-026]") { + CompoundStep step; + step.start_state = 4; + step.next_state = 0; // "leave it where it is" sentinel + step.sequencer_index = 1; + + auto encoded = encode_compound_request(RequestTypeOpcode::CompoundWaitSafety, 2, step, + /*evt_op=*/0x5, /*transaction_num=*/1); + + CompoundRequest out; + REQUIRE_FALSE(decode_compound_request(encoded.data(), encoded.size(), out)); + REQUIRE(out.type == RequestTypeOpcode::CompoundWaitSafety); + REQUIRE(out.evt_op == 0x5); + REQUIRE(out.step.next_state == 0); +} + +TEST_CASE("decode_compound_request rejects a request_type it does not recognize", "[request][REQ-CMP-014]") { + // Encode a Chained request, then try to decode it as compound/compound-wait. + auto encoded = encode_chained_member(1, 0, false, 1); + CompoundRequest out; + auto ec = decode_compound_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("decode_compound_request rejects a non-repurposed (mtv=1) message", "[request][REQ-CMP-013]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + info.mtv = true; + auto encoded = rcp::acf::encode_acf_gbb(info, 0x1122334455667788ull, {}); + + CompoundRequest out; + auto ec = decode_compound_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::timestamp_not_repurposed)); +} + +TEST_CASE("decode_compound_request rejects a short frame", "[request][REQ-CMP-011]") { + // A genuine ACF_GBB-typed prefix (byte 0 correctly identifies GBB), but + // truncated below the 16-byte ACF_GBB Message Info block. + CompoundStep step; + auto full = encode_compound_request(RequestTypeOpcode::Compound, 1, step, 0, 1); + std::vector too_short(full.begin(), full.begin() + 4); + CompoundRequest out; + auto ec = decode_compound_request(too_short.data(), too_short.size(), out); + REQUIRE(ec == rcp::avtp::make_error_code(rcp::avtp::AvtpErrc::short_buffer)); +} + +TEST_CASE("decode_compound_request rejects a non-ACF_GBB message", "[request][REQ-CMP-012]") { + rcp::acf::AcfMessageInfo info; + auto encoded = rcp::acf::encode_acf_abb(info, {}); + CompoundRequest out; + auto ec = decode_compound_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == rcp::acf::make_error_code(rcp::acf::AcfErrc::bad_acf_msg_type)); +} + +// ── clear-non-safestate (0x06) ──────────────────────────────────────────────── + +TEST_CASE("clear-non-safestate round-trips through encode/decode", "[request][REQ-CMP-016]") { + auto encoded = encode_clear_non_safestate(/*byte_bus_id=*/3, /*transaction_num=*/7); + ClearNonSafestateRequest out; + REQUIRE_FALSE(decode_clear_non_safestate(encoded.data(), encoded.size(), out)); + REQUIRE(out.byte_bus_id == 3); + REQUIRE(out.transaction_num == 7); +} + +TEST_CASE("clear-non-safestate decode rejects a compound request's opcode", "[request][REQ-CMP-018]") { + CompoundStep step; + auto encoded = encode_compound_request(RequestTypeOpcode::Compound, 1, step, 0, 1); + ClearNonSafestateRequest out; + auto ec = decode_clear_non_safestate(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("clear-non-safestate decode rejects a nonzero reserved octet", "[request][REQ-CMP-028]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + // opcode 0x06, one reserved octet set. + const uint64_t ts = (uint64_t{0x06} << 56) | (uint64_t{1} << 40); + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ClearNonSafestateRequest out; + auto ec = decode_clear_non_safestate(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::reserved_field_nonzero)); +} + +TEST_CASE("clear-non-safestate decode rejects nonzero evt/hs/cs", "[request][REQ-CMP-029]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + info.hs = true; + const uint64_t ts = uint64_t{0x06} << 56; + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ClearNonSafestateRequest out; + auto ec = decode_clear_non_safestate(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::evt_hs_cs_nonzero)); +} + // ── Optional-feature bundling ───────────────────────────────────────────────── TEST_CASE("validate_feature_bundles accepts a feature set with nothing conditional enabled", @@ -155,7 +452,7 @@ TEST_CASE("validate_feature_bundles accepts triggered/chained/timed independentl TEST_CASE("validate_feature_bundles rejects compound claimed alone", "[request][REQ-SEQ-003]") { FeatureSet f; - f.compound = true; // missing compound_wait, clear_non_safestate, and sequencer_count + f.compound = true; REQUIRE(validate_feature_bundles(f) == make_error_code(RequestErrc::compound_bundle_incomplete)); } @@ -225,42 +522,30 @@ TEST_CASE("SequencerTable::ensure_size never shrinks existing storage", "[reques REQUIRE(table.size() == 3); } -TEST_CASE("SequencerTable::try_advance advances only when current state matches expected_start", - "[request][REQ-SEQ-004]") { - std::vector states{SequencerTable::kDefaultState}; +TEST_CASE("SequencerTable::set_state overwrites a valid index", "[request][REQ-SEQ-010]") { + std::vector states{1}; SequencerTable table(states); - - bool advanced = false; - auto ec = table.try_advance(0, SequencerTable::kDefaultState, advanced); - REQUIRE_FALSE(ec); - REQUIRE(advanced); - + REQUIRE_FALSE(table.set_state(0, 9)); rcp::regmap::SequencerState s = 0; REQUIRE_FALSE(table.state_of(0, s)); - REQUIRE(s == SequencerTable::kDefaultState + 1); + REQUIRE(s == 9); } -TEST_CASE("SequencerTable::try_advance leaves a mismatched sequencer untouched", "[request][REQ-SEQ-004]") { - std::vector states{9}; +TEST_CASE("SequencerTable::set_state rejects an invalid index without changing the table", + "[request][REQ-SEQ-011]") { + std::vector states{1}; SequencerTable table(states); - - bool advanced = true; - auto ec = table.try_advance(0, /*expected_start=*/1, advanced); - REQUIRE_FALSE(ec); // not an error — just didn't advance - REQUIRE_FALSE(advanced); - + REQUIRE(table.set_state(5, 9) == make_error_code(RequestErrc::index_out_of_range)); rcp::regmap::SequencerState s = 0; REQUIRE_FALSE(table.state_of(0, s)); - REQUIRE(s == 9); + REQUIRE(s == 1); } -TEST_CASE("SequencerTable reports index_out_of_range for an out-of-bounds index", "[request][REQ-SEQ-004]") { +TEST_CASE("SequencerTable reports index_out_of_range for an out-of-bounds index", "[request][REQ-SEQ-007]") { std::vector states{1}; SequencerTable table(states); rcp::regmap::SequencerState s = 0; REQUIRE(table.state_of(5, s) == make_error_code(RequestErrc::index_out_of_range)); - bool advanced = false; - REQUIRE(table.try_advance(5, 1, advanced) == make_error_code(RequestErrc::index_out_of_range)); } TEST_CASE("SequencerTable can be constructed directly over a RegisterMap's sequencer_states", @@ -272,266 +557,663 @@ TEST_CASE("SequencerTable can be constructed directly over a RegisterMap's seque REQUIRE(regs.sequencer_states[0] == SequencerTable::kDefaultState); } -// ── Cancellation kinds & shared cancellation semantics ──────────────────────── - -TEST_CASE("RequestLedger::cancel_single cancels a pending request", "[request][REQ-SEQ-005]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); +// ── advance_guard / start_condition_met (delta #4 — c-RCP's two DIFFERENT +// predicates, replacing the pre-pass unconditional-+1 try_advance) ────────── - REQUIRE_FALSE(ledger.cancel_single(1)); - REQUIRE(ledger.find(1)->state == RequestState::Canceled); - - std::error_code outcome; - REQUIRE_FALSE(ledger.outcome_of(1, outcome)); - REQUIRE(outcome == make_error_code(RequestErrc::request_canceled)); +TEST_CASE("advance_guard is true when the sequencer is in start_state", "[request][REQ-CMP-019]") { + std::vector states{3}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 3; + step.sequencer_index = 0; + REQUIRE(table.advance_guard(step)); } -TEST_CASE("RequestLedger::cancel_single reports REQUEST_NOT_FOUND for an unknown transaction", - "[request][REQ-SEQ-005]") { - RequestLedger ledger; - REQUIRE(ledger.cancel_single(77) == make_error_code(RequestErrc::request_not_found)); +TEST_CASE("advance_guard is false when the sequencer is not in start_state", "[request][REQ-CMP-019]") { + std::vector states{3}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 4; + step.sequencer_index = 0; + REQUIRE_FALSE(table.advance_guard(step)); } -TEST_CASE("RequestLedger::cancel_single leaves an already-executing request to finish", - "[request][REQ-SEQ-005]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); +TEST_CASE("advance_guard is false for an invalid sequencer index", "[request][REQ-CMP-019]") { + std::vector states{3}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 3; + step.sequencer_index = 5; + REQUIRE_FALSE(table.advance_guard(step)); +} - REQUIRE(ledger.cancel_single(1) == make_error_code(RequestErrc::request_not_found)); - REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +TEST_CASE("advance_guard is false when the sequencer is disabled (state 0), even if start_state is 0", + "[request][REQ-SEQ-012]") { + std::vector states{0}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 0; + step.sequencer_index = 0; + REQUIRE_FALSE(table.advance_guard(step)); } -TEST_CASE("Cancelling a chained request cancels its successors", "[request][REQ-SEQ-005]") { - RequestLedger ledger; - RequestRecord a; - a.transaction_num = 1; - a.chained_successors = {2}; - RequestRecord b; - b.transaction_num = 2; - b.chained_predecessor = uint8_t{1}; - b.chained_successors = {3}; - RequestRecord c; - c.transaction_num = 3; - c.chained_predecessor = uint8_t{2}; +TEST_CASE("start_condition_met: start_state==0 matches any enabled state", "[request][REQ-CMP-025]") { + std::vector states{42}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 0; + step.sequencer_index = 0; + REQUIRE(table.start_condition_met(step)); +} - REQUIRE_FALSE(ledger.submit(a)); - REQUIRE_FALSE(ledger.submit(b)); - REQUIRE_FALSE(ledger.submit(c)); +TEST_CASE("start_condition_met: nonzero start_state requires an exact match", "[request][REQ-CMP-025]") { + std::vector states{5}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 6; + step.sequencer_index = 0; + REQUIRE_FALSE(table.start_condition_met(step)); - REQUIRE_FALSE(ledger.cancel_single(1)); - REQUIRE(ledger.find(1)->state == RequestState::Canceled); - REQUIRE(ledger.find(2)->state == RequestState::Canceled); - REQUIRE(ledger.find(3)->state == RequestState::Canceled); + step.start_state = 5; + REQUIRE(table.start_condition_met(step)); } -TEST_CASE("cancel_all(clear-all) cancels every pending/started request", "[request][REQ-SEQ-005]") { - RequestLedger ledger; - for (uint8_t i = 1; i <= 3; ++i) { - RequestRecord rec; - rec.transaction_num = i; - REQUIRE_FALSE(ledger.submit(rec)); - } - REQUIRE(ledger.cancel_all(/*non_safestate_only=*/false) == 3); - for (uint8_t i = 1; i <= 3; ++i) REQUIRE(ledger.find(i)->state == RequestState::Canceled); +TEST_CASE("start_condition_met is false for an unknown sequencer", "[request][REQ-CMP-025]") { + std::vector states{5}; + SequencerTable table(states); + CompoundStep step; + step.sequencer_index = 9; + REQUIRE_FALSE(table.start_condition_met(step)); } -TEST_CASE("cancel_all(clear-non-safestate) skips is_safety records", "[request][REQ-SEQ-005]") { - RequestLedger ledger; - RequestRecord normal; - normal.transaction_num = 1; - RequestRecord safety; - safety.transaction_num = 2; - safety.is_safety = true; - REQUIRE_FALSE(ledger.submit(normal)); - REQUIRE_FALSE(ledger.submit(safety)); - - REQUIRE(ledger.cancel_all(/*non_safestate_only=*/true) == 1); - REQUIRE(ledger.find(1)->state == RequestState::Canceled); - REQUIRE(ledger.find(2)->state == RequestState::Pending); +TEST_CASE("start_condition_met is false when disabled, even with the start_state==0 wildcard", + "[request][REQ-SEQ-012]") { + std::vector states{0}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 0; + step.sequencer_index = 0; + REQUIRE_FALSE(table.start_condition_met(step)); } -TEST_CASE("cancel_all does not disturb an already-executing request", "[request][REQ-SEQ-005]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - - REQUIRE(ledger.cancel_all(false) == 0); - REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +TEST_CASE("SequencerTable::exec_delay_elapsed compares elapsed against exec_delay", "[request][REQ-CMP-020]") { + CompoundStep step; + step.exec_delay = 100; + REQUIRE_FALSE(SequencerTable::exec_delay_elapsed(step, 99)); + REQUIRE(SequencerTable::exec_delay_elapsed(step, 100)); + REQUIRE(SequencerTable::exec_delay_elapsed(step, 101)); } -// ── Request lifecycle state machine ─────────────────────────────────────────── +TEST_CASE("SequencerTable::tick advances only once both the delay has elapsed and the guard holds", + "[request][REQ-CMP-021]") { + std::vector states{1}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 1; + step.next_state = 2; + step.sequencer_index = 0; + step.exec_delay = 10; -TEST_CASE("RequestLedger enforces the forward-only pending->started->under_execution->finalized sequence", - "[request][REQ-SEQ-006]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE(ledger.find(1)->state == RequestState::Pending); + REQUIRE_FALSE(table.tick(step, 5)); // delay not elapsed yet + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 1); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE(ledger.find(1)->state == RequestState::Started); + REQUIRE(table.tick(step, 10)); + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 2); +} - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +TEST_CASE("SequencerTable::tick never advances a sequencer that has already left start_state, " + "even after its delay elapses", + "[request][REQ-CMP-022]") { + std::vector states{9}; // not start_state + SequencerTable table(states); + CompoundStep step; + step.start_state = 1; + step.next_state = 2; + step.sequencer_index = 0; + step.exec_delay = 0; - REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); - REQUIRE(ledger.find(1)->state == RequestState::Finalized); + REQUIRE_FALSE(table.tick(step, 100)); + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 9); } -TEST_CASE("RequestLedger rejects skipping a lifecycle step", "[request][REQ-SEQ-006]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); +TEST_CASE("SequencerTable::wait_tick advances only on condition_met and guard both holding", + "[request][REQ-CMP-023]") { + std::vector states{1}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 1; + step.next_state = 2; + step.sequencer_index = 0; - REQUIRE(ledger.begin_execution(1) == make_error_code(RequestErrc::invalid_lifecycle_transition)); - REQUIRE(ledger.finalize(1, false) == make_error_code(RequestErrc::invalid_lifecycle_transition)); -} + REQUIRE_FALSE(table.wait_tick(step, /*condition_met=*/false)); + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 1); -TEST_CASE("RequestLedger::submit rejects a duplicate transaction_num", "[request][REQ-SEQ-006]") { - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE(ledger.submit(rec) == make_error_code(RequestErrc::transaction_num_collision)); + REQUIRE(table.wait_tick(step, /*condition_met=*/true)); + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 2); } -TEST_CASE("RequestLedger reports unknown_transaction for an untracked id", "[request][REQ-SEQ-006]") { - RequestLedger ledger; - REQUIRE(ledger.start(99) == make_error_code(RequestErrc::unknown_transaction)); +TEST_CASE("SequencerTable::wait_tick never advances a sequencer that has already left start_state, " + "even on a condition match", + "[request][REQ-CMP-024]") { + std::vector states{9}; + SequencerTable table(states); + CompoundStep step; + step.start_state = 1; + step.next_state = 2; + step.sequencer_index = 0; + + REQUIRE_FALSE(table.wait_tick(step, /*condition_met=*/true)); + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 9); } -TEST_CASE("finalize advances a compound request's sequencer on successful finalization", - "[request][REQ-SEQ-006]") { - std::vector states{SequencerTable::kDefaultState}; +TEST_CASE("SequencerTable::tick with next_state==0 leaves the sequencer unchanged but still " + "reports success", + "[request][REQ-CMP-021]") { + std::vector states{1}; SequencerTable table(states); + CompoundStep step; + step.start_state = 1; + step.next_state = 0; // "remain in the current state" sentinel + step.sequencer_index = 0; - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - rec.request_type = RequestTypeOpcode::Compound; - rec.sequencer_index = size_t{0}; - rec.expected_start_state = SequencerTable::kDefaultState; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE_FALSE(ledger.finalize(1, false, &table)); - + REQUIRE(table.tick(step, 0)); rcp::regmap::SequencerState s = 0; REQUIRE_FALSE(table.state_of(0, s)); - REQUIRE(s == SequencerTable::kDefaultState + 1); + REQUIRE(s == 1); // unchanged, NOT driven to 0 } -TEST_CASE("finalize does not advance the sequencer when its state no longer matches expected_start", - "[request][REQ-SEQ-006]") { - std::vector states{99}; // not the expected start state +TEST_CASE("SequencerTable::wait_tick with next_state==0 leaves the sequencer unchanged but still " + "reports success", + "[request][REQ-CMP-023]") { + std::vector states{7}; SequencerTable table(states); + CompoundStep step; + step.start_state = 7; + step.next_state = 0; + step.sequencer_index = 0; - RequestLedger ledger; - RequestRecord rec; - rec.transaction_num = 1; - rec.request_type = RequestTypeOpcode::CompoundWait; - rec.sequencer_index = size_t{0}; - rec.expected_start_state = SequencerTable::kDefaultState; - REQUIRE_FALSE(ledger.submit(rec)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE_FALSE(ledger.finalize(1, false, &table)); - + REQUIRE(table.wait_tick(step, true)); rcp::regmap::SequencerState s = 0; REQUIRE_FALSE(table.state_of(0, s)); - REQUIRE(s == 99); // untouched + REQUIRE(s == 7); } -// ── Chained-successor propagation on predecessor finalization ──────────────── +// ── Triggered (0x0E/0x8E) ────────────────────────────────────────────────────── + +TEST_CASE("encode_triggered_step_params/decode_triggered_step_params round-trip", "[request][REQ-TRIG-004]") { + TriggeredStep step; + step.trigger_source_ep = 4; + step.trigger_signal_nr = 2; + step.trigger_threshold = 1; + step.exec_delay = 0x0102; + step.repeat_count = kTriggeredRepeatInfinite; + + auto params = encode_triggered_step_params(step); + REQUIRE(params[0] == 4); + REQUIRE(params[1] == 2); + REQUIRE(params[2] == 1); + REQUIRE(params[3] == 0x01); + REQUIRE(params[4] == 0x02); + REQUIRE(params[5] == 0xFF); + REQUIRE(params[6] == 0xFF); + + auto decoded = decode_triggered_step_params(params); + REQUIRE(decoded.trigger_source_ep == step.trigger_source_ep); + REQUIRE(decoded.trigger_signal_nr == step.trigger_signal_nr); + REQUIRE(decoded.trigger_threshold == step.trigger_threshold); + REQUIRE(decoded.exec_delay == step.exec_delay); + REQUIRE(decoded.repeat_count == step.repeat_count); +} -TEST_CASE("finalize with errored=true aborts a chained successor whose cs forbids executing anyway", - "[request][REQ-SEQ-007]") { - RequestLedger ledger; - RequestRecord predecessor; - predecessor.transaction_num = 1; - predecessor.chained_successors = {2}; - RequestRecord successor; - successor.transaction_num = 2; - successor.chained_predecessor = uint8_t{1}; - successor.cs = false; // abort-on-predecessor-error +TEST_CASE("triggered request round-trips through encode_triggered_request/decode_triggered_request", + "[request][REQ-TRIG-005]") { + TriggeredStep step; + step.trigger_source_ep = 1; + step.trigger_signal_nr = 3; + step.trigger_threshold = 0; + step.exec_delay = 5; + std::vector payload{0x01}; + + auto encoded = encode_triggered_request(RequestTypeOpcode::Triggered, 2, step, 11, payload); + TriggeredRequest out; + REQUIRE_FALSE(decode_triggered_request(encoded.data(), encoded.size(), out)); + REQUIRE(out.type == RequestTypeOpcode::Triggered); + REQUIRE(out.byte_bus_id == 2); + REQUIRE(out.step.trigger_source_ep == 1); + REQUIRE(out.step.trigger_signal_nr == 3); + REQUIRE(out.transaction_num == 11); + REQUIRE(out.payload == payload); +} - REQUIRE_FALSE(ledger.submit(predecessor)); - REQUIRE_FALSE(ledger.submit(successor)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); +TEST_CASE("triggered safety opcode (0x8E) round-trips", "[request][REQ-TRIG-005]") { + TriggeredStep step; + auto encoded = encode_triggered_request(RequestTypeOpcode::TriggeredSafety, 1, step, 1); + REQUIRE(static_cast(RequestTypeOpcode::TriggeredSafety) == 0x8E); - REQUIRE(ledger.find(2)->state == RequestState::Canceled); + TriggeredRequest out; + REQUIRE_FALSE(decode_triggered_request(encoded.data(), encoded.size(), out)); + REQUIRE(out.type == RequestTypeOpcode::TriggeredSafety); } -TEST_CASE("finalize with errored=true does not abort a chained successor with cs=execute-regardless", - "[request][REQ-SEQ-007]") { - RequestLedger ledger; - RequestRecord predecessor; - predecessor.transaction_num = 1; - predecessor.chained_successors = {2}; - RequestRecord successor; - successor.transaction_num = 2; - successor.chained_predecessor = uint8_t{1}; - successor.cs = true; // execute regardless +TEST_CASE("decode_triggered_request rejects a request_type it does not recognize", "[request][REQ-TRIG-007]") { + auto encoded = encode_chained_member(1, 0, false, 1); + TriggeredRequest out; + auto ec = decode_triggered_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} - REQUIRE_FALSE(ledger.submit(predecessor)); - REQUIRE_FALSE(ledger.submit(successor)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); +TEST_CASE("decode_triggered_request rejects a non-repurposed message_timestamp", "[request][REQ-TRIG-006]") { + rcp::acf::AcfMessageInfo info; + info.mtv = true; + auto encoded = rcp::acf::encode_acf_gbb(info, 0x0Eull << 56, {}); + TriggeredRequest out; + auto ec = decode_triggered_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::timestamp_not_repurposed)); +} - REQUIRE(ledger.find(2)->state == RequestState::Pending); +TEST_CASE("triggered_enter_started resets the occurrence counter", "[request][REQ-TRIG-008]") { + TriggeredRuntime rt; + rt.occurrence_count = 9; + rt.started = false; + triggered_enter_started(rt); + REQUIRE(rt.occurrence_count == 0); + REQUIRE(rt.started); } -TEST_CASE("finalize with errored=false never aborts a chained successor regardless of its cs", - "[request][REQ-SEQ-007]") { - RequestLedger ledger; - RequestRecord predecessor; - predecessor.transaction_num = 1; - predecessor.chained_successors = {2}; - RequestRecord successor; - successor.transaction_num = 2; - successor.chained_predecessor = uint8_t{1}; - successor.cs = false; +TEST_CASE("triggered_record_occurrence requires started", "[request][REQ-TRIG-009]") { + TriggeredRuntime rt; + TriggeredStep step; + step.trigger_source_ep = 1; + step.trigger_signal_nr = 2; + REQUIRE_FALSE(triggered_record_occurrence(rt, step, 1, 2)); + REQUIRE(rt.occurrence_count == 0); +} - REQUIRE_FALSE(ledger.submit(predecessor)); - REQUIRE_FALSE(ledger.submit(successor)); - REQUIRE_FALSE(ledger.start(1)); - REQUIRE_FALSE(ledger.begin_execution(1)); - REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); +TEST_CASE("triggered_record_occurrence only counts the selected trigger", "[request][REQ-TRIG-009]") { + TriggeredRuntime rt; + triggered_enter_started(rt); + TriggeredStep step; + step.trigger_source_ep = 1; + step.trigger_signal_nr = 2; - REQUIRE(ledger.find(2)->state == RequestState::Pending); + REQUIRE_FALSE(triggered_record_occurrence(rt, step, /*source_ep=*/9, /*signal_nr=*/2)); + REQUIRE_FALSE(triggered_record_occurrence(rt, step, /*source_ep=*/1, /*signal_nr=*/9)); + REQUIRE(rt.occurrence_count == 0); + + REQUIRE(triggered_record_occurrence(rt, step, 1, 2)); + REQUIRE(rt.occurrence_count == 1); } -// ── The `cs` field's dual meaning ───────────────────────────────────────────── +TEST_CASE("triggered_threshold_reached counts occurrences that must precede execution", + "[request][REQ-TRIG-010]") { + TriggeredStep step; + step.trigger_threshold = 1; // fires on the 2nd occurrence + TriggeredRuntime rt; + rt.occurrence_count = 1; + REQUIRE_FALSE(triggered_threshold_reached(step, rt)); + rt.occurrence_count = 2; + REQUIRE(triggered_threshold_reached(step, rt)); + + step.trigger_threshold = 0; // fires on the 1st occurrence + rt.occurrence_count = 1; + REQUIRE(triggered_threshold_reached(step, rt)); +} -TEST_CASE("compound_wait_check_of maps cs to Immediate vs AfterChangeOnly", "[request][REQ-SEQ-008]") { - REQUIRE(compound_wait_check_of(true) == CompoundWaitCheck::Immediate); - REQUIRE(compound_wait_check_of(false) == CompoundWaitCheck::AfterChangeOnly); +TEST_CASE("triggered_exec_delay_elapsed compares elapsed against exec_delay", "[request][REQ-TRIG-011]") { + TriggeredStep step; + step.exec_delay = 50; + REQUIRE_FALSE(triggered_exec_delay_elapsed(step, 49)); + REQUIRE(triggered_exec_delay_elapsed(step, 50)); } -TEST_CASE("should_execute_chained: cs=true always executes regardless of predecessor outcome", - "[request][REQ-SEQ-008]") { - REQUIRE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/true)); - REQUIRE(should_execute_chained(/*cs=*/true, /*predecessor_errored=*/false)); +TEST_CASE("triggered_tick requires started and the threshold reached", "[request][REQ-TRIG-012]") { + TriggeredStep step; + step.trigger_threshold = 0; + TriggeredRuntime rt; + REQUIRE_FALSE(triggered_tick(step, rt, 0, true)); // not started + + triggered_enter_started(rt); + REQUIRE_FALSE(triggered_tick(step, rt, 0, true)); // threshold not reached (0 occurrences) } -TEST_CASE("should_execute_chained: cs=false aborts only when the predecessor errored", - "[request][REQ-SEQ-008]") { - REQUIRE_FALSE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/true)); - REQUIRE(should_execute_chained(/*cs=*/false, /*predecessor_errored=*/false)); +TEST_CASE("triggered_tick fires only when the endpoint is idle", "[request][REQ-TRIG-012]") { + TriggeredStep step; + step.trigger_threshold = 0; + TriggeredRuntime rt; + triggered_enter_started(rt); + triggered_record_occurrence(rt, step, 0, 0); + + REQUIRE_FALSE(triggered_tick(step, rt, 0, /*endpoint_idle=*/false)); + REQUIRE(rt.started); // unchanged + + REQUIRE(triggered_tick(step, rt, 0, /*endpoint_idle=*/true)); +} + +TEST_CASE("triggered_tick is blocked until exec_delay elapses", "[request][REQ-TRIG-012]") { + TriggeredStep step; + step.trigger_threshold = 0; + step.exec_delay = 100; + TriggeredRuntime rt; + triggered_enter_started(rt); + triggered_record_occurrence(rt, step, 0, 0); + + REQUIRE_FALSE(triggered_tick(step, rt, 50, true)); + REQUIRE(triggered_tick(step, rt, 100, true)); +} + +TEST_CASE("triggered_tick resets the runtime on a successful fire", "[request][REQ-TRIG-013]") { + TriggeredStep step; + step.trigger_threshold = 0; + TriggeredRuntime rt; + triggered_enter_started(rt); + triggered_record_occurrence(rt, step, 0, 0); + + REQUIRE(triggered_tick(step, rt, 0, true)); + REQUIRE(rt.occurrence_count == 0); + REQUIRE_FALSE(rt.started); +} + +TEST_CASE("the occurrence counter free-runs independent of endpoint_idle", "[request][REQ-TRIG-009]") { + TriggeredStep step; + TriggeredRuntime rt; + triggered_enter_started(rt); + // record_occurrence has no endpoint_idle parameter at all — occurrences + // accumulate regardless of what a fire attempt's own idle flag would be. + REQUIRE(triggered_record_occurrence(rt, step, 0, 0)); + REQUIRE(triggered_record_occurrence(rt, step, 0, 0)); + REQUIRE(rt.occurrence_count == 2); +} + +// ── Chained (0x01) ───────────────────────────────────────────────────────────── + +TEST_CASE("chained member round-trips through encode_chained_member/decode_chained_member", + "[request][REQ-CHAIN-002]") { + std::vector payload{0x01, 0x02}; + auto encoded = encode_chained_member(/*byte_bus_id=*/4, /*chain_exec_delay=*/0x0102, + /*cs=*/true, /*transaction_num=*/6, payload); + + ChainedMember out; + REQUIRE_FALSE(decode_chained_member(encoded.data(), encoded.size(), out)); + REQUIRE(out.byte_bus_id == 4); + REQUIRE(out.chain_exec_delay == 0x0102); + REQUIRE(out.cs); + REQUIRE(out.transaction_num == 6); + REQUIRE(out.payload == payload); +} + +TEST_CASE("chained member wire sub-field offsets: chain_exec_delay at octets 4..5, rest reserved", + "[request][REQ-CHAIN-004]") { + auto encoded = encode_chained_member(1, 0xBEEF, false, 1); + // encoded[8] is the opcode byte (start of the repurposed region). + REQUIRE(encoded[8] == 0x01); + REQUIRE(encoded[9] == 0); + REQUIRE(encoded[10] == 0); + REQUIRE(encoded[11] == 0); + REQUIRE(encoded[12] == 0xBE); + REQUIRE(encoded[13] == 0xEF); + REQUIRE(encoded[14] == 0); + REQUIRE(encoded[15] == 0); +} + +TEST_CASE("decode_chained_member rejects a non-chained opcode", "[request][REQ-CHAIN-007]") { + auto encoded = encode_clear_all(1, 1); + ChainedMember out; + auto ec = decode_chained_member(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("decode_chained_member rejects a non-repurposed message_timestamp", "[request][REQ-CHAIN-006]") { + rcp::acf::AcfMessageInfo info; + info.mtv = true; + auto encoded = rcp::acf::encode_acf_gbb(info, 0x01ull << 56, {}); + ChainedMember out; + auto ec = decode_chained_member(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::timestamp_not_repurposed)); +} + +TEST_CASE("decode_chained_member rejects a nonzero reserved sub-field octet", "[request][REQ-CHAIN-012]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + const uint64_t ts = (uint64_t{0x01} << 56) | (uint64_t{1} << 8); // octet 6 set (reserved) + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ChainedMember out; + auto ec = decode_chained_member(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::reserved_field_nonzero)); +} + +TEST_CASE("chained_exec_delay_elapsed compares elapsed against chain_exec_delay", "[request][REQ-CHAIN-011]") { + REQUIRE_FALSE(chained_exec_delay_elapsed(100, 99)); + REQUIRE(chained_exec_delay_elapsed(100, 100)); + REQUIRE(chained_exec_delay_elapsed(0, 0)); +} + +// ── Timed (0x0A) ─────────────────────────────────────────────────────────────── + +TEST_CASE("timed_feature_enabled requires kOptConditionalRequests", "[request][REQ-TIMED-002]") { + REQUIRE_FALSE(timed_feature_enabled(0)); + REQUIRE(timed_feature_enabled(rcp::regmap::kOptConditionalRequests)); +} + +TEST_CASE("timed request round-trips through encode_timed_request/decode_timed_request", + "[request][REQ-TIMED-003]") { + std::vector payload{0x01, 0x02, 0x03}; + auto encoded = encode_timed_request(/*byte_bus_id=*/2, /*presentation_time=*/0x0001020304050ull, + /*transaction_num=*/8, payload); + REQUIRE(encoded.has_value()); + + TimedRequest out; + REQUIRE_FALSE(decode_timed_request(encoded->data(), encoded->size(), out)); + REQUIRE(out.byte_bus_id == 2); + REQUIRE(out.presentation_time == 0x0001020304050ull); + REQUIRE(out.transaction_num == 8); + REQUIRE(out.payload == payload); +} + +TEST_CASE("timed request wire sub-field offsets: octet 1 reserved, octets 2..7 the 48-bit " + "presentation_time", + "[request][REQ-TIMED-003]") { + auto encoded = encode_timed_request(1, kTimedPresentationTimeMax, 1); + REQUIRE(encoded.has_value()); + REQUIRE((*encoded)[8] == 0x0A); + REQUIRE((*encoded)[9] == 0); // reserved + for (size_t i = 10; i < 16; ++i) REQUIRE((*encoded)[i] == 0xFF); +} + +TEST_CASE("encode_timed_request rejects a presentation_time beyond the 48-bit max", "[request][REQ-TIMED-003]") { + auto encoded = encode_timed_request(1, kTimedPresentationTimeMax + 1, 1); + REQUIRE_FALSE(encoded.has_value()); +} + +TEST_CASE("decode_timed_request rejects a nonzero reserved octet", "[request][REQ-TIMED-009]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + const uint64_t ts = (uint64_t{0x0A} << 56) | (uint64_t{1} << 48); // octet 1 (reserved) set + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + TimedRequest out; + auto ec = decode_timed_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::reserved_field_nonzero)); +} + +TEST_CASE("decode_timed_request rejects hs or cs set", "[request][REQ-TIMED-010]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + info.hs = true; + auto encoded1 = rcp::acf::encode_acf_gbb(info, uint64_t{0x0A} << 56, {}); + TimedRequest out; + REQUIRE(decode_timed_request(encoded1.data(), encoded1.size(), out) == + make_error_code(RequestErrc::unsupported_cmd)); + + rcp::acf::AcfMessageInfo info2; + info2.byte_bus_id = 1; + info2.cs = true; + auto encoded2 = rcp::acf::encode_acf_gbb(info2, uint64_t{0x0A} << 56, {}); + REQUIRE(decode_timed_request(encoded2.data(), encoded2.size(), out) == + make_error_code(RequestErrc::unsupported_cmd)); +} + +TEST_CASE("decode_timed_request rejects a non-timed opcode", "[request][REQ-TIMED-005]") { + auto encoded = encode_clear_all(1, 1); + TimedRequest out; + auto ec = decode_timed_request(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("timed presentation_time round-trips beyond the 32-bit range", "[request][REQ-TIMED-003]") { + const uint64_t big = 0x0000A0B0C0D0E0ull; + auto encoded = encode_timed_request(1, big, 1); + REQUIRE(encoded.has_value()); + TimedRequest out; + REQUIRE_FALSE(decode_timed_request(encoded->data(), encoded->size(), out)); + REQUIRE(out.presentation_time == big); +} + +TEST_CASE("timed_too_far rejects a presentation_time beyond the horizon", "[request][REQ-TIMED-006]") { + REQUIRE(timed_too_far(/*presentation_time=*/1000, /*now=*/0, /*max_horizon=*/500)); + REQUIRE_FALSE(timed_too_far(500, 0, 500)); +} + +TEST_CASE("timed_too_far: a presentation_time in the past is never too far", "[request][REQ-TIMED-006]") { + REQUIRE_FALSE(timed_too_far(/*presentation_time=*/0, /*now=*/1000, /*max_horizon=*/1)); +} + +TEST_CASE("timed_too_far is wraparound-safe in the 48-bit presentation-time domain", + "[request][REQ-TIMED-006]") { + // now near the top of the 48-bit domain, presentation_time just after + // wraparound — forward delta should be small, not huge. + const uint64_t now = kTimedPresentationTimeMax - 5; + const uint64_t pt = 10; // wraps to now+16 + REQUIRE_FALSE(timed_too_far(pt, now, 100)); + REQUIRE(timed_too_far(pt, now, 5)); +} + +TEST_CASE("timed_admit reports RejectGptpFail whenever gptp is not locked", "[request][REQ-TIMED-007]") { + REQUIRE(timed_admit(/*gptp_locked=*/false, 1'000'000, 0, 1) == TimedAdmission::RejectGptpFail); + // Even when the horizon would otherwise pass. + REQUIRE(timed_admit(false, 0, 0, 1'000'000) == TimedAdmission::RejectGptpFail); +} + +TEST_CASE("timed_admit reports RejectPresentationTimeTooFar or Accept once gPTP is locked", + "[request][REQ-TIMED-008]") { + REQUIRE(timed_admit(true, 1000, 0, 500) == TimedAdmission::RejectPresentationTimeTooFar); + REQUIRE(timed_admit(true, 500, 0, 500) == TimedAdmission::Accept); +} + +TEST_CASE("wire_error_for maps admission outcomes onto acf::WireErrorCode", "[request][REQ-TIMED-007]") { + REQUIRE(wire_error_for(TimedAdmission::RejectGptpFail) == rcp::acf::WireErrorCode::GptpFail); + REQUIRE(wire_error_for(TimedAdmission::RejectPresentationTimeTooFar) == + rcp::acf::WireErrorCode::PresentationTimeTooFar); + REQUIRE_FALSE(wire_error_for(TimedAdmission::Accept).has_value()); +} + +TEST_CASE("timed_due reports whether a presentation_time has arrived", "[request][REQ-TIMED-011]") { + REQUIRE_FALSE(timed_due(/*presentation_time=*/100, /*now=*/0)); + REQUIRE(timed_due(100, 100)); + REQUIRE(timed_due(100, 200)); // already past +} + +// ── Cancellation: clear-all (0x05) and clear-single (0x07) ─────────────────── + +TEST_CASE("clear-all round-trips through encode_clear_all/decode_clear_all", "[request][REQ-CANCEL-002]") { + auto encoded = encode_clear_all(/*byte_bus_id=*/3, /*transaction_num=*/4); + ClearAllRequest out; + REQUIRE_FALSE(decode_clear_all(encoded.data(), encoded.size(), out)); + REQUIRE(out.byte_bus_id == 3); + REQUIRE(out.transaction_num == 4); +} + +TEST_CASE("decode_clear_all rejects a clear-single opcode", "[request][REQ-CANCEL-004]") { + auto encoded = encode_clear_single(1, 2, 3); + ClearAllRequest out; + auto ec = decode_clear_all(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("decode_clear_all rejects a short frame", "[request][REQ-CANCEL-003]") { + auto full = encode_clear_all(1, 1); + std::vector too_short(full.begin(), full.begin() + 4); + ClearAllRequest out; + auto ec = decode_clear_all(too_short.data(), too_short.size(), out); + REQUIRE(ec == rcp::avtp::make_error_code(rcp::avtp::AvtpErrc::short_buffer)); +} + +TEST_CASE("decode_clear_all rejects a nonzero reserved octet", "[request][REQ-CANCEL-013]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + const uint64_t ts = (uint64_t{0x05} << 56) | 1; + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ClearAllRequest out; + auto ec = decode_clear_all(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::reserved_field_nonzero)); +} + +TEST_CASE("decode_clear_all rejects nonzero evt/hs/cs", "[request][REQ-CANCEL-014]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + info.evt_op = 1; + auto encoded = rcp::acf::encode_acf_gbb(info, uint64_t{0x05} << 56, {}); + ClearAllRequest out; + auto ec = decode_clear_all(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::evt_hs_cs_nonzero)); +} + +TEST_CASE("clear-single round-trips through encode_clear_single/decode_clear_single", "[request][REQ-CANCEL-005]") { + auto encoded = encode_clear_single(/*byte_bus_id=*/2, /*clear_transaction_num=*/42, /*transaction_num=*/9); + ClearSingleRequest out; + REQUIRE_FALSE(decode_clear_single(encoded.data(), encoded.size(), out)); + REQUIRE(out.byte_bus_id == 2); + REQUIRE(out.clear_transaction_num == 42); + REQUIRE(out.transaction_num == 9); +} + +TEST_CASE("clear-single wire sub-field offsets: clear_transaction_num at octet 3, rest reserved", + "[request][REQ-CANCEL-005]") { + auto encoded = encode_clear_single(1, 0x77, 1); + REQUIRE(encoded[8] == 0x07); + REQUIRE(encoded[9] == 0); + REQUIRE(encoded[10] == 0); + REQUIRE(encoded[11] == 0x77); + REQUIRE(encoded[12] == 0); + REQUIRE(encoded[13] == 0); + REQUIRE(encoded[14] == 0); + REQUIRE(encoded[15] == 0); +} + +TEST_CASE("decode_clear_single rejects a clear-all opcode", "[request][REQ-CANCEL-006]") { + auto encoded = encode_clear_all(1, 1); + ClearSingleRequest out; + auto ec = decode_clear_single(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); +} + +TEST_CASE("decode_clear_single rejects a nonzero reserved octet", "[request][REQ-CANCEL-007]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + const uint64_t ts = (uint64_t{0x07} << 56) | (uint64_t{1} << 40); // octet 2 (reserved) set + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ClearSingleRequest out; + auto ec = decode_clear_single(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::reserved_field_nonzero)); +} + +TEST_CASE("decode_clear_single rejects nonzero evt/hs/cs", "[request][REQ-CANCEL-015]") { + rcp::acf::AcfMessageInfo info; + info.byte_bus_id = 1; + info.cs = true; + const uint64_t ts = uint64_t{0x07} << 56; + auto encoded = rcp::acf::encode_acf_gbb(info, ts, {}); + ClearSingleRequest out; + auto ec = decode_clear_single(encoded.data(), encoded.size(), out); + REQUIRE(ec == make_error_code(RequestErrc::evt_hs_cs_nonzero)); } // ── RequestErrc category sanity ───────────────────────────────────────────── @@ -548,7 +1230,9 @@ TEST_CASE("every RequestErrc value has a distinct, non-empty message", "[request RequestErrc::index_out_of_range, RequestErrc::unknown_transaction, RequestErrc::invalid_lifecycle_transition, RequestErrc::transaction_num_collision, RequestErrc::request_not_found, RequestErrc::request_canceled, - RequestErrc::compound_bundle_incomplete, + RequestErrc::compound_bundle_incomplete, RequestErrc::request_not_cancellable, + RequestErrc::reserved_field_nonzero, RequestErrc::evt_hs_cs_nonzero, + RequestErrc::unsupported_cmd, RequestErrc::ledger_full, }; std::vector messages; for (auto e : all) { @@ -561,9 +1245,330 @@ TEST_CASE("every RequestErrc value has a distinct, non-empty message", "[request REQUIRE(messages[i] != messages[j]); } -// ── Safety-tagged (0x8x) request variants (ROADMAP.md milestone 50, v2.6.0) ── +// ── Request lifecycle state machine ─────────────────────────────────────────── + +TEST_CASE("RequestLedger enforces the forward-only pending->started->under_execution->finalized sequence", + "[request][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE(ledger.find(1)->state == RequestState::Pending); + + REQUIRE_FALSE(ledger.start(1)); + REQUIRE(ledger.find(1)->state == RequestState::Started); + + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); + + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); + REQUIRE(ledger.find(1)->state == RequestState::Finalized); +} + +TEST_CASE("RequestLedger rejects skipping a lifecycle step", "[request][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + + REQUIRE(ledger.begin_execution(1) == make_error_code(RequestErrc::invalid_lifecycle_transition)); + REQUIRE(ledger.finalize(1, false) == make_error_code(RequestErrc::invalid_lifecycle_transition)); +} -TEST_CASE("decode_request_type accepts the three 0x8x safety-tagged opcodes", "[request][REQ-SEQ-010]") { +TEST_CASE("RequestLedger::submit rejects a duplicate transaction_num", "[request][REQ-SEQ-006]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE(ledger.submit(rec) == make_error_code(RequestErrc::transaction_num_collision)); +} + +TEST_CASE("RequestLedger reports unknown_transaction for an untracked id", "[request][REQ-SEQ-006]") { + RequestLedger ledger; + REQUIRE(ledger.start(99) == make_error_code(RequestErrc::unknown_transaction)); +} + +TEST_CASE("finalize advances a compound request's sequencer via its own next_state on successful " + "finalization", + "[request][REQ-SEQ-006]") { + std::vector states{SequencerTable::kDefaultState}; + SequencerTable table(states); + + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + rec.request_type = RequestTypeOpcode::Compound; + CompoundStep step; + step.start_state = SequencerTable::kDefaultState; + step.next_state = SequencerTable::kDefaultState + 5; // NOT just +1 + step.sequencer_index = 0; + rec.compound_step = step; + + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, false, &table)); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == SequencerTable::kDefaultState + 5); +} + +TEST_CASE("finalize with next_state==0 leaves the sequencer exactly where it is", "[request][REQ-SEQ-006]") { + std::vector states{SequencerTable::kDefaultState}; + SequencerTable table(states); + + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + rec.request_type = RequestTypeOpcode::CompoundWait; + CompoundStep step; + step.start_state = SequencerTable::kDefaultState; + step.next_state = 0; // sentinel + step.sequencer_index = 0; + rec.compound_step = step; + + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, false, &table)); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == SequencerTable::kDefaultState); +} + +TEST_CASE("finalize does not advance the sequencer when its state no longer matches start_state", + "[request][REQ-SEQ-006]") { + std::vector states{99}; // not the expected start state + SequencerTable table(states); + + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + rec.request_type = RequestTypeOpcode::CompoundWait; + CompoundStep step; + step.start_state = SequencerTable::kDefaultState; + step.next_state = SequencerTable::kDefaultState + 1; + step.sequencer_index = 0; + rec.compound_step = step; + + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, false, &table)); + + rcp::regmap::SequencerState s = 0; + REQUIRE_FALSE(table.state_of(0, s)); + REQUIRE(s == 99); // untouched +} + +// ── Cancellation kinds & shared cancellation semantics ──────────────────────── + +TEST_CASE("RequestLedger::cancel_single cancels a pending request", "[request][REQ-CANCEL-011]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + + REQUIRE_FALSE(ledger.cancel_single(1)); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + + std::error_code outcome; + REQUIRE_FALSE(ledger.outcome_of(1, outcome)); + REQUIRE(outcome == make_error_code(RequestErrc::request_canceled)); +} + +TEST_CASE("RequestLedger::cancel_single reports request_not_found for a transaction_num never tracked " + "at all", + "[request][REQ-CANCEL-009]") { + RequestLedger ledger; + REQUIRE(ledger.cancel_single(77) == make_error_code(RequestErrc::request_not_found)); +} + +TEST_CASE("RequestLedger::cancel_single reports request_not_cancellable — not request_not_found — " + "for a request that is tracked but already past cancellation (c-RCP delta #3)", + "[request][REQ-CANCEL-010]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + + auto ec = ledger.cancel_single(1); + REQUIRE(ec == make_error_code(RequestErrc::request_not_cancellable)); + REQUIRE_FALSE(ec == make_error_code(RequestErrc::request_not_found)); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +} + +TEST_CASE("Cancelling a chained request cancels its successors", "[request][REQ-CANCEL-012]") { + RequestLedger ledger; + RequestRecord a; + a.transaction_num = 1; + a.chained_successors = {2}; + RequestRecord b; + b.transaction_num = 2; + b.chained_predecessor = uint8_t{1}; + b.chained_successors = {3}; + RequestRecord c; + c.transaction_num = 3; + c.chained_predecessor = uint8_t{2}; + + REQUIRE_FALSE(ledger.submit(a)); + REQUIRE_FALSE(ledger.submit(b)); + REQUIRE_FALSE(ledger.submit(c)); + + REQUIRE_FALSE(ledger.cancel_single(1)); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + REQUIRE(ledger.find(2)->state == RequestState::Canceled); + REQUIRE(ledger.find(3)->state == RequestState::Canceled); +} + +TEST_CASE("cancel_all(clear-all) cancels every pending/started request", "[request][REQ-CANCEL-002]") { + RequestLedger ledger; + for (uint8_t i = 1; i <= 3; ++i) { + RequestRecord rec; + rec.transaction_num = i; + REQUIRE_FALSE(ledger.submit(rec)); + } + REQUIRE(ledger.cancel_all(/*non_safestate_only=*/false) == 3); + for (uint8_t i = 1; i <= 3; ++i) REQUIRE(ledger.find(i)->state == RequestState::Canceled); +} + +TEST_CASE("cancel_all(clear-non-safestate) skips is_safety records", "[request][REQ-CMP-016]") { + RequestLedger ledger; + RequestRecord normal; + normal.transaction_num = 1; + RequestRecord safety; + safety.transaction_num = 2; + safety.is_safety = true; + REQUIRE_FALSE(ledger.submit(normal)); + REQUIRE_FALSE(ledger.submit(safety)); + + REQUIRE(ledger.cancel_all(/*non_safestate_only=*/true) == 1); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("cancel_all does not disturb an already-executing request", "[request][REQ-CANCEL-002]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 1; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + + REQUIRE(ledger.cancel_all(false) == 0); + REQUIRE(ledger.find(1)->state == RequestState::UnderExecution); +} + +// ── Chained-successor propagation on predecessor finalization ──────────────── +// These tests exercise should_execute_chained() (issue #58) THROUGH the +// ledger's own finalize()/propagate_chain_completion() integration, so a +// regression of the polarity fix above is caught at both layers. + +TEST_CASE("finalize with errored=true aborts a chained successor whose cs is abort-on-error", + "[request][REQ-CHAIN-010]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = true; // abort-on-error + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); + + REQUIRE(ledger.find(2)->state == RequestState::Canceled); +} + +TEST_CASE("finalize with errored=true does not abort a chained successor with cs=continue-on-error", + "[request][REQ-CHAIN-010]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = false; // continue regardless + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); + + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("finalize with errored=false never aborts a chained successor regardless of its cs", + "[request][REQ-CHAIN-010]") { + RequestLedger ledger; + RequestRecord predecessor; + predecessor.transaction_num = 1; + predecessor.chained_successors = {2}; + RequestRecord successor; + successor.transaction_num = 2; + successor.chained_predecessor = uint8_t{1}; + successor.cs = true; + + REQUIRE_FALSE(ledger.submit(predecessor)); + REQUIRE_FALSE(ledger.submit(successor)); + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/false)); + + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("a full 4-member chain sequence matches c-RCP's own worked example", "[request][REQ-CHAIN-010]") { + // member 0 runs fine, then errors; member 1 (cs=abort-on-error) does + // not run and is aborted; members 2 and 3 cascade-cancel regardless of + // their own cs. + RequestLedger ledger; + RequestRecord m0; + m0.transaction_num = 1; + m0.chained_successors = {2}; + RequestRecord m1; + m1.transaction_num = 2; + m1.chained_predecessor = uint8_t{1}; + m1.chained_successors = {3}; + m1.cs = true; // abort-on-error + RequestRecord m2; + m2.transaction_num = 3; + m2.chained_predecessor = uint8_t{2}; + m2.chained_successors = {4}; + m2.cs = false; + RequestRecord m3; + m3.transaction_num = 4; + m3.chained_predecessor = uint8_t{3}; + m3.cs = false; + + REQUIRE_FALSE(ledger.submit(m0)); + REQUIRE_FALSE(ledger.submit(m1)); + REQUIRE_FALSE(ledger.submit(m2)); + REQUIRE_FALSE(ledger.submit(m3)); + + REQUIRE_FALSE(ledger.start(1)); + REQUIRE_FALSE(ledger.begin_execution(1)); + REQUIRE_FALSE(ledger.finalize(1, /*errored=*/true)); + + REQUIRE(ledger.find(1)->state == RequestState::Finalized); + REQUIRE(ledger.find(2)->state == RequestState::Canceled); + REQUIRE(ledger.find(3)->state == RequestState::Canceled); + REQUIRE(ledger.find(4)->state == RequestState::Canceled); +} + +// ── Safety-tagged (0x8x) request variants ───────────────────────────────────── + +TEST_CASE("decode_request_type accepts the three 0x8x safety-tagged opcodes", "[request][REQ-CMP-001]") { std::array params{1, 2, 3, 4, 5, 6, 7}; for (auto opcode : {RequestTypeOpcode::CompoundSafety, RequestTypeOpcode::CompoundWaitSafety, RequestTypeOpcode::TriggeredSafety}) { @@ -577,37 +1582,15 @@ TEST_CASE("decode_request_type accepts the three 0x8x safety-tagged opcodes", "[ } } -TEST_CASE("decode_request_type rejects an MSB-set byte that is not one of the three defined " - "safety opcodes", - "[request][REQ-SEQ-010]") { - RequestTypeOpcode type{}; - std::array out_params{}; - // 0x80 has the MSB set but is not CompoundWaitSafety/TriggeredSafety/CompoundSafety. - auto ec = decode_request_type(/*mtv=*/false, uint64_t{0x80} << 56, type, out_params); - REQUIRE(ec == make_error_code(RequestErrc::unknown_request_type)); -} - -TEST_CASE("is_safety_variant identifies exactly the three 0x8x opcodes", "[request][REQ-SEQ-011]") { - REQUIRE(is_safety_variant(RequestTypeOpcode::CompoundSafety)); - REQUIRE(is_safety_variant(RequestTypeOpcode::CompoundWaitSafety)); - REQUIRE(is_safety_variant(RequestTypeOpcode::TriggeredSafety)); - - REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Compound)); - REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::CompoundWait)); - REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Triggered)); - REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::Chained)); - REQUIRE_FALSE(is_safety_variant(RequestTypeOpcode::ClearAll)); -} - TEST_CASE("category_of maps each safety-tagged opcode to its base opcode's priority category", - "[request][REQ-SEQ-011]") { + "[request][REQ-SCHED-002]") { REQUIRE(category_of(RequestTypeOpcode::CompoundSafety) == category_of(RequestTypeOpcode::Compound)); REQUIRE(category_of(RequestTypeOpcode::CompoundWaitSafety) == category_of(RequestTypeOpcode::CompoundWait)); REQUIRE(category_of(RequestTypeOpcode::TriggeredSafety) == category_of(RequestTypeOpcode::Triggered)); } -TEST_CASE("request_record_for derives is_safety from the opcode automatically", "[request][REQ-SEQ-012]") { +TEST_CASE("request_record_for derives is_safety from the opcode automatically", "[request][REQ-CMP-001]") { auto normal = request_record_for(/*transaction_num=*/1, RequestTypeOpcode::Compound, /*cs=*/true); REQUIRE_FALSE(normal.is_safety); REQUIRE(normal.transaction_num == 1); @@ -623,7 +1606,7 @@ TEST_CASE("request_record_for derives is_safety from the opcode automatically", } TEST_CASE("request_record_for-built safety records survive cancel_all(non_safestate_only=true)", - "[request][REQ-SEQ-012]") { + "[request][REQ-CMP-016]") { RequestLedger ledger; REQUIRE_FALSE(ledger.submit(request_record_for(1, RequestTypeOpcode::Triggered, false))); REQUIRE_FALSE(ledger.submit(request_record_for(2, RequestTypeOpcode::TriggeredSafety, false))); @@ -633,3 +1616,70 @@ TEST_CASE("request_record_for-built safety records survive cancel_all(non_safest REQUIRE(ledger.find(1)->state == RequestState::Canceled); REQUIRE(ledger.find(2)->state == RequestState::Pending); } + +// ── Fixed-capacity RequestLedger (kMaxTrackedRequests) ─────────────────────── +// RequestLedger::records_ is a detail::BoundedVector (256 — the exact size of transaction_num's own +// uint8_t domain) rather than an unbounded std::vector. This proves the +// bound is real, not merely documented: filling the ledger to capacity and +// submitting one more must fail with ledger_full rather than silently +// growing. (Mutation-tested during development: temporarily removing +// RequestLedger::submit's `if (records_.full()) return ...;` guard made +// this test fail — REQUIRE(ledger.submit(...) == ...ledger_full) instead +// observed a success outcome — confirming the test is load-bearing; the +// guard was restored immediately after.) + +TEST_CASE("RequestLedger::capacity reports kMaxTrackedRequests", "[request][fixed-capacity]") { + RequestLedger ledger; + REQUIRE(RequestLedger::capacity() == kMaxTrackedRequests); + REQUIRE(ledger.size() == 0); +} + +TEST_CASE("RequestLedger::submit fills to capacity and then reports ledger_full — not " + "transaction_num_collision — for a genuinely fresh, never-submitted id", + "[request][fixed-capacity]") { + RequestLedger ledger; + for (size_t i = 0; i < RequestLedger::capacity(); ++i) { + RequestRecord rec; + rec.transaction_num = static_cast(i); + REQUIRE_FALSE(ledger.submit(rec)); + } + REQUIRE(ledger.size() == RequestLedger::capacity()); + + // kMaxTrackedRequests (64) is well below transaction_num's full uint8_t + // range (256), so id 64 is guaranteed fresh — this exercises the + // capacity check for real, not the collision check. + RequestRecord fresh; + fresh.transaction_num = static_cast(RequestLedger::capacity()); + auto ec = ledger.submit(fresh); + REQUIRE(ec == make_error_code(RequestErrc::ledger_full)); + REQUIRE_FALSE(ec == make_error_code(RequestErrc::transaction_num_collision)); + REQUIRE(ledger.size() == RequestLedger::capacity()); // unchanged — not silently grown + REQUIRE(ledger.find(fresh.transaction_num) == nullptr); // truly never admitted +} + +TEST_CASE("RequestLedger::submit still reports transaction_num_collision (not ledger_full) for a " + "duplicate submitted before the ledger is full", + "[request][fixed-capacity]") { + RequestLedger ledger; + RequestRecord rec; + rec.transaction_num = 5; + REQUIRE_FALSE(ledger.submit(rec)); + REQUIRE(ledger.submit(rec) == make_error_code(RequestErrc::transaction_num_collision)); +} + +TEST_CASE("detail::BoundedVector reports full() at capacity and rejects a push_back beyond it", + "[request][fixed-capacity]") { + rcp::request::detail::BoundedVector v; + REQUIRE(v.empty()); + REQUIRE(v.push_back(1)); + REQUIRE(v.push_back(2)); + REQUIRE(v.push_back(3)); + REQUIRE(v.full()); + REQUIRE(v.size() == 3); + + REQUIRE_FALSE(v.push_back(4)); // rejected, not silently grown + REQUIRE(v.size() == 3); // unchanged + REQUIRE(v[0] == 1); + REQUIRE(v[2] == 3); +}