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:
@@ -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
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user