// Real-socket tests for the HSMS T-timer family (E37 §10). // // Drives a real `hsms::Connection` over a loopback socket pair with // deliberately tiny timer budgets and asserts: // // T3 — reply timeout fires S9F9 with the original MHEAD and surfaces // Timeout to the caller. // T6 — control-transaction timeout (Select.rsp dropped) closes the // connection. // T7 — not-selected timeout (passive waiting for Select.req) closes // the connection. // T8 — intercharacter timeout (peer sends length prefix then stalls) // closes the connection. // // Plus S9F11 emission for an over-length frame. #include #include #include #include #include #include #include #include #include #include #include #include #include "secsgem/hsms/connection.hpp" #include "secsgem/hsms/header.hpp" #include "secsgem/secs2/message.hpp" using namespace secsgem::hsms; using namespace std::chrono_literals; namespace { // Identical pattern to test_hsms_connection.cpp's SocketPair, modulo the // shared `io` pump. Kept local so the two test files stay independent. struct SocketPair { asio::io_context io; asio::ip::tcp::socket a{io}; asio::ip::tcp::socket b{io}; SocketPair() { asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint( asio::ip::address_v4::loopback(), 0)); const auto port = acc.local_endpoint().port(); std::error_code ec_accept, ec_connect; bool accepted = false, connected = false; acc.async_accept(a, [&](std::error_code ec) { ec_accept = ec; accepted = true; }); b.async_connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port), [&](std::error_code ec) { ec_connect = ec; connected = true; }); while (!(accepted && connected)) { if (io.stopped()) io.restart(); if (io.poll() == 0) std::this_thread::sleep_for(1ms); } REQUIRE_FALSE(ec_accept); REQUIRE_FALSE(ec_connect); } }; template void pump_until(asio::io_context& io, Pred pred, std::chrono::milliseconds budget = 3s) { const auto deadline = std::chrono::steady_clock::now() + budget; while (!pred()) { if (std::chrono::steady_clock::now() > deadline) FAIL("pump_until budget exceeded"); if (io.stopped()) io.restart(); if (io.poll() == 0) std::this_thread::sleep_for(1ms); } } void send_bytes(SocketPair& sp, std::vector bytes) { auto buf = std::make_shared>(std::move(bytes)); bool done = false; asio::async_write(sp.b, asio::buffer(*buf), [buf, &done](std::error_code ec, std::size_t) { REQUIRE_FALSE(ec); done = true; }); pump_until(sp.io, [&] { return done; }); } std::optional try_recv_frame(SocketPair& sp, std::chrono::milliseconds budget = 2s) { auto lenbuf = std::make_shared>(); std::error_code rec_ec; bool len_done = false; asio::async_read(sp.b, asio::buffer(*lenbuf), [lenbuf, &len_done, &rec_ec](std::error_code ec, std::size_t) { rec_ec = ec; len_done = true; }); const auto deadline = std::chrono::steady_clock::now() + budget; while (!len_done) { if (std::chrono::steady_clock::now() > deadline) return std::nullopt; if (sp.io.stopped()) sp.io.restart(); if (sp.io.poll() == 0) std::this_thread::sleep_for(1ms); } if (rec_ec) return std::nullopt; const uint32_t len = (uint32_t((*lenbuf)[0]) << 24) | (uint32_t((*lenbuf)[1]) << 16) | (uint32_t((*lenbuf)[2]) << 8) | uint32_t((*lenbuf)[3]); auto payload = std::make_shared>(len); bool payload_done = false; asio::async_read(sp.b, asio::buffer(*payload), [payload, &payload_done](std::error_code, std::size_t) { payload_done = true; }); pump_until(sp.io, [&] { return payload_done; }); return Frame::decode(payload->data(), payload->size()); } // Drive the passive Connection into SELECTED so we can exercise data-path // timers (T3) cleanly. Tiny T3, T6, T7, T8 so tests stay fast. struct Selected { SocketPair sp; std::shared_ptr conn; explicit Selected(Timers t) { conn = std::make_shared(std::move(sp.a), Connection::Mode::Passive, /*device_id=*/0, t); bool selected = false; conn->set_selected_handler([&] { selected = true; }); conn->start(); send_bytes(sp, Frame(Header::control(SType::SelectReq, 1)).encode()); auto rsp = try_recv_frame(sp); REQUIRE(rsp.has_value()); REQUIRE(rsp->header.stype == SType::SelectRsp); pump_until(sp.io, [&] { return selected; }); } }; // Timer profiles tuned per test so the timer of interest is the FIRST to // fire. Without this, e.g. T7 (armed on passive start) wins over T8 // because they're both 150ms and T7 was armed first. Timers profile_for_t3() { Timers t; t.t3 = 150ms; t.t6 = 5s; t.t7 = 5s; t.t8 = 5s; t.linktest = 0ms; return t; } Timers profile_for_t6() { Timers t; t.t3 = 5s; t.t6 = 150ms; t.t7 = 5s; t.t8 = 5s; t.linktest = 0ms; return t; } Timers profile_for_t7() { Timers t; t.t3 = 5s; t.t6 = 5s; t.t7 = 150ms; t.t8 = 5s; t.linktest = 0ms; return t; } Timers profile_for_t8() { Timers t; // T7 must outlast T8 here, otherwise T7 wins and closes first. t.t3 = 5s; t.t6 = 5s; t.t7 = 5s; t.t8 = 150ms; t.linktest = 0ms; return t; } } // namespace TEST_CASE("T3: reply timeout emits S9F9 and surfaces Timeout to caller") { Selected s(profile_for_t3()); // Driver sends a primary message expecting a reply. The peer doesn't // reply at all — only consumes the frame. std::optional got_ec; s.conn->send_request(secsgem::secs2::Message(1, 1, true), [&](std::error_code ec, const secsgem::secs2::Message&) { got_ec = ec; }); // First frame on the peer is the primary S1F1 itself. auto primary = try_recv_frame(s.sp); REQUIRE(primary.has_value()); CHECK(primary->header.stream() == 1); CHECK(primary->header.function() == 1); const uint32_t primary_sys = primary->header.system_bytes; // Pump past T3. The reply callback should fire with Timeout AND an // S9F9 frame should land on the peer carrying the original MHEAD. pump_until(s.sp.io, [&] { return got_ec.has_value(); }, 2s); REQUIRE(got_ec.has_value()); CHECK(got_ec->value() == static_cast(Error::Timeout)); auto s9 = try_recv_frame(s.sp); REQUIRE(s9.has_value()); CHECK(s9->header.stream() == 9); CHECK(s9->header.function() == 9); // S9F9 body is the failed primary's MHEAD as ; the formatcode // byte for binary is 0x21 followed by length-byte then 10 payload bytes. REQUIRE(s9->body.size() >= 12); CHECK(s9->body[0] == 0x21); CHECK(s9->body[1] == 10); // Bytes 6..9 of the MHEAD are the system_bytes (big-endian). const uint32_t echoed_sys = (uint32_t(s9->body[2 + 6]) << 24) | (uint32_t(s9->body[2 + 7]) << 16) | (uint32_t(s9->body[2 + 8]) << 8) | uint32_t(s9->body[2 + 9]); CHECK(echoed_sys == primary_sys); s.conn->close("test done"); } TEST_CASE("T6: dropped Select.rsp closes the active connection") { SocketPair sp; bool closed = false; std::string close_reason; auto conn = std::make_shared(std::move(sp.a), Connection::Mode::Active, /*device_id=*/0, profile_for_t6()); conn->set_closed_handler([&](const std::string& r) { closed = true; close_reason = r; }); conn->start(); // Active mode emits Select.req on start. We deliberately do NOT reply. auto sel = try_recv_frame(sp); REQUIRE(sel.has_value()); CHECK(sel->header.stype == SType::SelectReq); // Drain the io_context past T6; the connection should self-close. pump_until(sp.io, [&] { return closed; }, 2s); CHECK(closed); CHECK(close_reason.find("T6") != std::string::npos); } TEST_CASE("T7: passive connection with no Select.req self-closes") { SocketPair sp; bool closed = false; std::string close_reason; auto conn = std::make_shared(std::move(sp.a), Connection::Mode::Passive, /*device_id=*/0, profile_for_t7()); conn->set_closed_handler([&](const std::string& r) { closed = true; close_reason = r; }); conn->start(); // Peer sends nothing — T7 should fire. pump_until(sp.io, [&] { return closed; }, 2s); CHECK(closed); CHECK(close_reason.find("T7") != std::string::npos); } TEST_CASE("T8: length-prefix-then-stall closes the connection") { SocketPair sp; bool closed = false; std::string close_reason; auto conn = std::make_shared(std::move(sp.a), Connection::Mode::Passive, /*device_id=*/0, profile_for_t8()); conn->set_closed_handler([&](const std::string& r) { closed = true; close_reason = r; }); conn->start(); // Send only the 4-byte length prefix. Connection will allocate the // body buffer and arm T8 — when no bytes follow, T8 fires. send_bytes(sp, {0x00, 0x00, 0x00, 0x0A}); pump_until(sp.io, [&] { return closed; }, 2s); CHECK(closed); CHECK(close_reason.find("T8") != std::string::npos); } TEST_CASE("S9F11: over-length frame triggers emission + close") { SocketPair sp; bool closed = false; // T7 long so the close reason isn't ambiguously T7. auto conn = std::make_shared(std::move(sp.a), Connection::Mode::Passive, /*device_id=*/0, profile_for_t8()); conn->set_closed_handler([&](const std::string&) { closed = true; }); conn->start(); // Length prefix of 0x40000001 = 1 GiB + 1, well above kMaxFrameLength. send_bytes(sp, {0x40, 0x00, 0x00, 0x01}); auto s9 = try_recv_frame(sp); REQUIRE(s9.has_value()); CHECK(s9->header.stream() == 9); CHECK(s9->header.function() == 11); // Body's starts with the offending length prefix bytes. REQUIRE(s9->body.size() >= 12); CHECK(s9->body[0] == 0x21); CHECK(s9->body[1] == 10); CHECK(s9->body[2] == 0x40); CHECK(s9->body[3] == 0x00); CHECK(s9->body[4] == 0x00); CHECK(s9->body[5] == 0x01); pump_until(sp.io, [&] { return closed; }, 2s); CHECK(closed); }