Files
secs-gem/tests/test_secsi_timers.cpp
raphael d4d1a411d7 secsi: T3 / T4 enforcement moved into the FSM
The SECS-I Protocol FSM now enforces T3 (reply timeout) and T4
(inter-block timeout) directly, instead of leaving them as
upper-layer hooks.

T3: on complete_send, if the block we just acked had W=1, record its
system_bytes in awaiting_reply_sys_ and emit ActionStartTimer{T3}.
deliver_recv cancels T3 when a block arrives whose system_bytes
match the outstanding request.  EventTimeout{T3} aborts the FSM with
"T3 reply timeout".

T4: deliver_recv emits ActionStartTimer{T4} whenever the delivered
block has end_block=false.  The next block's deliver_recv cancels
the timer; EventTimeout{T4} aborts with "T4 inter-block timeout".

abort() now also cancels T3/T4 and clears the tracking state.

Test changes:
  - Old "T3/T4 are FSM-level no-ops" test → REPLACED by four new
    tests: T3 arm+expire, T3 arm+matching-reply cancels, T4
    arm+expire, T4 arm+next-block cancels.
  - Two new observer accessors on Protocol (awaiting_reply,
    awaiting_next_block) so the tests can assert tracking state
    without poking internals.

COMPLIANCE.md §1a: T3 + T4 rows go .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 11:52:43 +02:00

289 lines
9.2 KiB
C++

// SECS-I FSM timer firing tests.
//
// The existing test_secsi.cpp covered T2 in the send direction. This
// file fills in:
//
// T1 inter-character — fires while RecvBlock is mid-buffer, aborts.
// T2 protocol — fires while in receive states (RecvEotSent or
// RecvBlock), aborts.
// T3 / T4 — documented as upper-layer-driven; the FSM is
// a no-op on those, asserted here so a future
// commit that adds in-FSM enforcement breaks
// this test loudly.
#include <doctest/doctest.h>
#include <algorithm>
#include <variant>
#include <vector>
#include "secsgem/secsi/block.hpp"
#include "secsgem/secsi/protocol.hpp"
using namespace secsgem::secsi;
namespace {
bool has_abort(const std::vector<Action>& out) {
return std::any_of(out.begin(), out.end(), [](const Action& a) {
return std::holds_alternative<ActionRaiseError>(a);
});
}
// Drive the FSM into RecvBlock by simulating "peer sends ENQ, then a
// length byte (say 3), then nothing further".
void drive_into_recv_block(Protocol& p, std::vector<Action>& out) {
p.on_event(EventByte{kENQ}, out);
REQUIRE(p.state() == Protocol::State::RecvEotSent);
out.clear();
// Now feed the length byte — FSM moves into RecvBlock.
p.on_event(EventByte{3}, out);
REQUIRE(p.state() == Protocol::State::RecvBlock);
out.clear();
// One more byte: still in RecvBlock since recv_expected_ = 1 + 3 + 2 = 6.
p.on_event(EventByte{0xAA}, out);
REQUIRE(p.state() == Protocol::State::RecvBlock);
out.clear();
}
} // namespace
TEST_CASE("SECS-I T1: inter-character timeout in RecvBlock aborts") {
Protocol p(Role::Master);
std::vector<Action> out;
drive_into_recv_block(p, out);
p.on_event(EventTimeout{Timer::T1}, out);
CHECK(p.state() == Protocol::State::Idle);
CHECK(has_abort(out));
// The abort reason should mention T1 so operators can grep logs for it.
bool t1_mentioned = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* err = std::get_if<ActionRaiseError>(&a);
return err && err->reason.find("T1") != std::string::npos;
});
CHECK(t1_mentioned);
}
TEST_CASE("SECS-I T1: ignored outside RecvBlock") {
Protocol p(Role::Master);
std::vector<Action> out;
CHECK(p.state() == Protocol::State::Idle);
p.on_event(EventTimeout{Timer::T1}, out);
CHECK(p.state() == Protocol::State::Idle);
CHECK_FALSE(has_abort(out));
}
TEST_CASE("SECS-I T2: timeout in RecvEotSent aborts") {
Protocol p(Role::Master);
std::vector<Action> out;
p.on_event(EventByte{kENQ}, out);
REQUIRE(p.state() == Protocol::State::RecvEotSent);
out.clear();
p.on_event(EventTimeout{Timer::T2}, out);
CHECK(p.state() == Protocol::State::Idle);
CHECK(has_abort(out));
}
TEST_CASE("SECS-I T2: timeout in RecvBlock aborts (mid-block stall)") {
Protocol p(Role::Master);
std::vector<Action> out;
drive_into_recv_block(p, out);
p.on_event(EventTimeout{Timer::T2}, out);
CHECK(p.state() == Protocol::State::Idle);
CHECK(has_abort(out));
}
TEST_CASE("SECS-I T3: fires after a W=1 send is acked, expiry aborts") {
// Send a primary (w_bit=true), peer EOTs, peer ACKs the block. The
// FSM should now be awaiting a reply on those system_bytes. Firing
// T3 aborts the transaction.
Block primary;
primary.header.stream = 1;
primary.header.function = 1;
primary.header.w_bit = true;
primary.header.block_number = 1;
primary.header.end_block = true;
primary.header.system_bytes = 0xCAFEBABE;
Protocol p(Role::Master);
std::vector<Action> out;
p.on_event(EventSend{primary}, out);
out.clear();
p.on_event(EventByte{kEOT}, out); // peer clears us
out.clear();
p.on_event(EventByte{kACK}, out); // peer ACKs the block
// FSM should be back to Idle, awaiting a reply with T3 armed.
CHECK(p.state() == Protocol::State::Idle);
CHECK(p.awaiting_reply());
// The T3 ActionStartTimer should be present in `out`.
bool t3_armed = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* t = std::get_if<ActionStartTimer>(&a);
return t && t->which == Timer::T3;
});
CHECK(t3_armed);
// Now fire T3.
out.clear();
p.on_event(EventTimeout{Timer::T3}, out);
CHECK(p.state() == Protocol::State::Idle);
CHECK_FALSE(p.awaiting_reply());
CHECK(has_abort(out));
bool t3_mentioned = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* err = std::get_if<ActionRaiseError>(&a);
return err && err->reason.find("T3") != std::string::npos;
});
CHECK(t3_mentioned);
}
TEST_CASE("SECS-I T3: cancels when matching reply arrives") {
// Same setup as above, but instead of T3 expiring we receive a reply
// block whose system_bytes match.
Block primary;
primary.header.stream = 1;
primary.header.function = 1;
primary.header.w_bit = true;
primary.header.end_block = true;
primary.header.block_number = 1;
primary.header.system_bytes = 0xCAFEBABE;
Protocol p(Role::Master);
std::vector<Action> out;
p.on_event(EventSend{primary}, out);
p.on_event(EventByte{kEOT}, out);
out.clear();
p.on_event(EventByte{kACK}, out);
REQUIRE(p.awaiting_reply());
out.clear();
// Peer sends ENQ for the reply.
p.on_event(EventByte{kENQ}, out);
out.clear();
// Then the reply block (header + body + checksum).
Block reply;
reply.header.stream = 1;
reply.header.function = 2;
reply.header.end_block = true;
reply.header.block_number = 1;
reply.header.system_bytes = 0xCAFEBABE;
auto reply_bytes = reply.encode();
for (auto byte : reply_bytes) {
p.on_event(EventByte{byte}, out);
}
// T3 should have been cancelled in deliver_recv.
CHECK_FALSE(p.awaiting_reply());
bool t3_cancelled = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* t = std::get_if<ActionCancelTimer>(&a);
return t && t->which == Timer::T3;
});
CHECK(t3_cancelled);
}
TEST_CASE("SECS-I T4: fires after a non-final block, expiry aborts") {
// Receive a block whose end_block bit is false (i.e. multi-block
// continuation expected); T4 should be armed, and firing it aborts.
Block mid;
mid.header.stream = 7;
mid.header.function = 3;
mid.header.block_number = 1;
mid.header.end_block = false;
mid.header.system_bytes = 0xDEAD;
auto bytes = mid.encode();
Protocol p(Role::Master);
std::vector<Action> out;
p.on_event(EventByte{kENQ}, out);
out.clear();
for (auto byte : bytes) {
p.on_event(EventByte{byte}, out);
}
CHECK(p.awaiting_next_block());
bool t4_armed = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* t = std::get_if<ActionStartTimer>(&a);
return t && t->which == Timer::T4;
});
CHECK(t4_armed);
out.clear();
p.on_event(EventTimeout{Timer::T4}, out);
CHECK_FALSE(p.awaiting_next_block());
CHECK(has_abort(out));
bool t4_mentioned = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* err = std::get_if<ActionRaiseError>(&a);
return err && err->reason.find("T4") != std::string::npos;
});
CHECK(t4_mentioned);
}
TEST_CASE("SECS-I T4: cancels when the next block arrives") {
Block mid;
mid.header.stream = 7;
mid.header.function = 3;
mid.header.block_number = 1;
mid.header.end_block = false;
mid.header.system_bytes = 0xDEAD;
auto bytes1 = mid.encode();
Block final_block;
final_block.header.stream = 7;
final_block.header.function = 3;
final_block.header.block_number = 2;
final_block.header.end_block = true;
final_block.header.system_bytes = 0xDEAD;
auto bytes2 = final_block.encode();
Protocol p(Role::Master);
std::vector<Action> out;
p.on_event(EventByte{kENQ}, out);
for (auto b : bytes1) p.on_event(EventByte{b}, out);
REQUIRE(p.awaiting_next_block());
// Second block. Peer ENQs again (each block is its own send cycle).
out.clear();
p.on_event(EventByte{kENQ}, out);
out.clear();
for (auto b : bytes2) p.on_event(EventByte{b}, out);
CHECK_FALSE(p.awaiting_next_block());
bool t4_cancelled = std::any_of(out.begin(), out.end(), [](const Action& a) {
auto* t = std::get_if<ActionCancelTimer>(&a);
return t && t->which == Timer::T4;
});
CHECK(t4_cancelled);
}
TEST_CASE("SECS-I T2: in send state retries (existing) AND in recv state aborts (new)") {
// Round-trip the existing T2-send-retry to keep the contrast obvious:
// T2 in send → retry, T2 in recv → abort. Same timer, different
// recovery strategy depending on which half of the protocol is
// active.
Block blk;
blk.header.stream = 1;
blk.header.function = 1;
blk.header.block_number = 1;
blk.header.end_block = true;
blk.header.w_bit = true;
Protocol sender(Role::Master);
std::vector<Action> out;
sender.on_event(EventSend{blk}, out);
REQUIRE(sender.state() == Protocol::State::SendEnqSent);
const auto rty_before = sender.rty_remaining();
out.clear();
sender.on_event(EventTimeout{Timer::T2}, out);
CHECK(sender.state() == Protocol::State::SendEnqSent);
CHECK(sender.rty_remaining() == rty_before - 1);
// Now the receive side: T2 there *aborts* instead of retrying.
Protocol receiver(Role::Master);
out.clear();
receiver.on_event(EventByte{kENQ}, out);
REQUIRE(receiver.state() == Protocol::State::RecvEotSent);
out.clear();
receiver.on_event(EventTimeout{Timer::T2}, out);
CHECK(receiver.state() == Protocol::State::Idle);
CHECK(has_abort(out));
}