I2: Router::dispatch_with_s9 helper + end-to-end S9F3/F5 tests

The S9F3/F5 fallback was previously inlined in apps/secs_server.cpp;
this commit lifts it onto Router as a template helper and adds two
focused tests asserting the wire behaviour against a real back-to-
back HSMS Connection pair.

  template <typename EmitFn, typename HeaderProvider>
  std::optional<Message> dispatch_with_s9(emit, header, msg);

The helper does the has_handler / has_handler_for_stream check and
calls the supplied emit function with S9F3 (unknown stream) or S9F5
(unknown function in known stream).  The header_provider returns the
optional MHEAD bytes — keeping the helper free of any direct
Connection coupling.

Tests:
  - SUT registered only for S1F1; peer sends S1F5 -> SUT replies
    S9F5 to the peer.
  - SUT registered only for S1F1; peer sends S7F19 -> SUT replies
    S9F3 to the peer.

Closes Tranche I — SML parser and the auto-S9F* fallback closeout
both verified end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:58:03 +02:00
parent e2348db082
commit 28dac8e9c8
3 changed files with 201 additions and 0 deletions
+1
View File
@@ -112,6 +112,7 @@ add_executable(secsgem_tests
tests/test_cem_objects.cpp tests/test_cem_objects.cpp
tests/test_modules.cpp tests/test_modules.cpp
tests/test_sml.cpp tests/test_sml.cpp
tests/test_s9_fallback.cpp
) )
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
target_compile_definitions(secsgem_tests PRIVATE target_compile_definitions(secsgem_tests PRIVATE
+20
View File
@@ -59,6 +59,26 @@ class Router {
std::size_t size() const { return handlers_.size(); } 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 <typename EmitFn, typename HeaderProvider>
std::optional<s2::Message> 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: private:
std::map<std::pair<uint8_t, uint8_t>, Handler> handlers_; std::map<std::pair<uint8_t, uint8_t>, Handler> handlers_;
Handler fallback_; Handler fallback_;
+180
View File
@@ -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 <doctest/doctest.h>
#include <asio.hpp>
#include <chrono>
#include <memory>
#include <thread>
#include <vector>
#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 <typename Pred>
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<hsms::Connection>(
std::move(sp.a), hsms::Connection::Mode::Passive, 0, timers);
auto peer = std::make_shared<hsms::Connection>(
std::move(sp.b), hsms::Connection::Mode::Active, 0, timers);
gem::Router router;
router.on(1, 1, [](const s2::Message&) -> std::optional<s2::Message> {
return s2::Message(1, 2, false);
});
std::weak_ptr<hsms::Connection> wsut = sut;
sut->set_message_handler([&router, wsut](const s2::Message& msg)
-> std::optional<s2::Message> {
auto c = wsut.lock();
return router.dispatch_with_s9(
[&c](uint8_t f, const std::array<uint8_t, 10>& mhead) {
if (c) c->emit_s9(f, mhead);
},
[&c]() -> std::optional<std::array<uint8_t, 10>> {
if (!c) return std::nullopt;
auto* h = c->current_header();
if (!h) return std::nullopt;
return h->encode();
},
msg);
});
std::vector<std::pair<uint8_t, uint8_t>> seen_at_peer;
peer->set_message_handler([&](const s2::Message& m) -> std::optional<s2::Message> {
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<hsms::Connection>(
std::move(sp.a), hsms::Connection::Mode::Passive, 0, timers);
auto peer = std::make_shared<hsms::Connection>(
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<s2::Message> {
return s2::Message(1, 2, false);
});
std::weak_ptr<hsms::Connection> wsut = sut;
sut->set_message_handler([&router, wsut](const s2::Message& msg)
-> std::optional<s2::Message> {
auto c = wsut.lock();
return router.dispatch_with_s9(
[&c](uint8_t f, const std::array<uint8_t, 10>& mhead) {
if (c) c->emit_s9(f, mhead);
},
[&c]() -> std::optional<std::array<uint8_t, 10>> {
if (!c) return std::nullopt;
auto* h = c->current_header();
if (!h) return std::nullopt;
return h->encode();
},
msg);
});
std::vector<std::pair<uint8_t, uint8_t>> seen_at_peer;
peer->set_message_handler([&](const s2::Message& m) -> std::optional<s2::Message> {
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);
}