diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b50236..c68ffff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ add_executable(secsgem_tests tests/test_cem_objects.cpp tests/test_modules.cpp tests/test_sml.cpp + tests/test_s9_fallback.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/include/secsgem/gem/router.hpp b/include/secsgem/gem/router.hpp index a938a13..f9a4403 100644 --- a/include/secsgem/gem/router.hpp +++ b/include/secsgem/gem/router.hpp @@ -59,6 +59,26 @@ class Router { std::size_t size() const { return handlers_.size(); } + // Wrap dispatch so unhandled primaries trigger an S9 error report + // before the message is consumed. `emit_s9` takes (function_byte, + // mhead) — the connection's emit_s9 has exactly that signature, so a + // caller can write `router.dispatch_with_s9([&](auto f, auto h) { + // conn->emit_s9(f, h); }, current_header, msg)`. Per E5/E30: + // S9F3 = stream not recognized + // S9F5 = function within known stream not recognized + template + std::optional dispatch_with_s9( + EmitFn emit_s9, HeaderProvider header_provider, + const s2::Message& msg) const { + if (!has_handler(msg.stream, msg.function)) { + if (auto mhead = header_provider()) { + const uint8_t f = has_handler_for_stream(msg.stream) ? 5 : 3; + emit_s9(f, *mhead); + } + } + return dispatch(msg); + } + private: std::map, Handler> handlers_; Handler fallback_; diff --git a/tests/test_s9_fallback.cpp b/tests/test_s9_fallback.cpp new file mode 100644 index 0000000..ce8c028 --- /dev/null +++ b/tests/test_s9_fallback.cpp @@ -0,0 +1,180 @@ +// E5 / E30 §9 — auto S9F3/S9F5 emission for primaries that hit no +// registered handler. Uses a back-to-back Connection pair: A runs the +// system-under-test with a router that knows only S1F1; B is a raw +// peer that sends a few unrecognized primaries and asserts the right +// S9F* response comes back on the wire. + +#include + +#include +#include +#include +#include +#include + +#include "secsgem/gem/router.hpp" +#include "secsgem/hsms/connection.hpp" +#include "secsgem/hsms/header.hpp" + +using namespace secsgem; +namespace s2 = secsgem::secs2; + +namespace { + +struct Pair { + asio::io_context io; + asio::ip::tcp::socket a{io}, b{io}; + Pair() { + asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint( + asio::ip::address_v4::loopback(), 0)); + const auto port = acc.local_endpoint().port(); + bool done_a = false, done_b = false; + std::error_code ec_a, ec_b; + acc.async_accept(a, [&](std::error_code ec) { ec_a = ec; done_a = true; }); + b.async_connect({asio::ip::address_v4::loopback(), port}, + [&](std::error_code ec) { ec_b = ec; done_b = true; }); + while (!(done_a && done_b)) { + if (io.stopped()) io.restart(); + if (io.poll() == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE_FALSE(ec_a); + REQUIRE_FALSE(ec_b); + } +}; + +template +void pump_until(asio::io_context& io, Pred pred, + std::chrono::milliseconds budget = std::chrono::seconds(3)) { + 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(std::chrono::milliseconds(1)); + } +} + +} // namespace + +TEST_CASE("Router::dispatch_with_s9 emits S9F5 for unhandled function in known stream") { + Pair sp; + hsms::Timers timers; + timers.linktest = std::chrono::milliseconds(0); + timers.t3 = std::chrono::milliseconds(500); + + auto sut = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, 0, timers); + auto peer = std::make_shared( + std::move(sp.b), hsms::Connection::Mode::Active, 0, timers); + + gem::Router router; + router.on(1, 1, [](const s2::Message&) -> std::optional { + return s2::Message(1, 2, false); + }); + + std::weak_ptr wsut = sut; + sut->set_message_handler([&router, wsut](const s2::Message& msg) + -> std::optional { + auto c = wsut.lock(); + return router.dispatch_with_s9( + [&c](uint8_t f, const std::array& mhead) { + if (c) c->emit_s9(f, mhead); + }, + [&c]() -> std::optional> { + if (!c) return std::nullopt; + auto* h = c->current_header(); + if (!h) return std::nullopt; + return h->encode(); + }, + msg); + }); + + std::vector> seen_at_peer; + peer->set_message_handler([&](const s2::Message& m) -> std::optional { + seen_at_peer.emplace_back(m.stream, m.function); + return std::nullopt; + }); + + bool sut_sel = false, peer_sel = false; + sut->set_selected_handler([&] { sut_sel = true; }); + peer->set_selected_handler([&] { peer_sel = true; }); + sut->start(); + peer->start(); + pump_until(sp.io, [&] { return sut_sel && peer_sel; }); + + // Send S1F2 (a "reply" with no transaction — sut should ignore the + // body but treat it as an unhandled primary and emit S9F5). + // Actually a better stimulus: send S1F5 which is W-bit primary; sut + // has no handler for F5 within stream 1. + peer->send_request(s2::Message(1, 5, true), + [](std::error_code, const s2::Message&) {}); + + pump_until(sp.io, [&] { + for (auto& p : seen_at_peer) if (p.first == 9 && p.second == 5) return true; + return false; + }); + + bool saw_s9f5 = false; + for (auto& p : seen_at_peer) if (p.first == 9 && p.second == 5) saw_s9f5 = true; + CHECK(saw_s9f5); +} + +TEST_CASE("Router::dispatch_with_s9 emits S9F3 for unhandled stream") { + Pair sp; + hsms::Timers timers; + timers.linktest = std::chrono::milliseconds(0); + timers.t3 = std::chrono::milliseconds(500); + + auto sut = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, 0, timers); + auto peer = std::make_shared( + std::move(sp.b), hsms::Connection::Mode::Active, 0, timers); + + // Router knows only S1F1 — stream 7 is completely unhandled. + gem::Router router; + router.on(1, 1, [](const s2::Message&) -> std::optional { + return s2::Message(1, 2, false); + }); + + std::weak_ptr wsut = sut; + sut->set_message_handler([&router, wsut](const s2::Message& msg) + -> std::optional { + auto c = wsut.lock(); + return router.dispatch_with_s9( + [&c](uint8_t f, const std::array& mhead) { + if (c) c->emit_s9(f, mhead); + }, + [&c]() -> std::optional> { + if (!c) return std::nullopt; + auto* h = c->current_header(); + if (!h) return std::nullopt; + return h->encode(); + }, + msg); + }); + + std::vector> seen_at_peer; + peer->set_message_handler([&](const s2::Message& m) -> std::optional { + seen_at_peer.emplace_back(m.stream, m.function); + return std::nullopt; + }); + + bool sut_sel = false, peer_sel = false; + sut->set_selected_handler([&] { sut_sel = true; }); + peer->set_selected_handler([&] { peer_sel = true; }); + sut->start(); + peer->start(); + pump_until(sp.io, [&] { return sut_sel && peer_sel; }); + + // S7F19 — stream 7 is unknown to our router. + peer->send_request(s2::Message(7, 19, true), + [](std::error_code, const s2::Message&) {}); + + pump_until(sp.io, [&] { + for (auto& p : seen_at_peer) if (p.first == 9 && p.second == 3) return true; + return false; + }); + + bool saw_s9f3 = false; + for (auto& p : seen_at_peer) if (p.first == 9 && p.second == 3) saw_s9f3 = true; + CHECK(saw_s9f3); +}