28dac8e9c8
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>
181 lines
6.2 KiB
C++
181 lines
6.2 KiB
C++
// 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);
|
|
}
|