// 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 #include #include #include #include "secsgem/secsi/block.hpp" #include "secsgem/secsi/protocol.hpp" using namespace secsgem::secsi; namespace { bool has_abort(const std::vector& out) { return std::any_of(out.begin(), out.end(), [](const Action& a) { return std::holds_alternative(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& 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 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(&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 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 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 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 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(&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(&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 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(&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 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(&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(&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 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(&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 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)); }