From d0c7fb71b65610d62574c44eb500fc515a0f4010 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Tue, 9 Jun 2026 11:40:38 +0200 Subject: [PATCH] =?UTF-8?q?hsms:=20HSMS-GS=20multi-session=20support=20(E3?= =?UTF-8?q?7=20=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection now supports both HSMS-SS (single session — the constructor's behaviour, unchanged) and HSMS-GS (multi-session). add_session(device_id) registers additional sessions; each one has its own NotSelected/Selected state and its own message/selected handlers. In GS mode the Select.req carries session_id=device_id; in SS mode it stays at 0xFFFF (legacy). Linktest/Separate remain connection-scope per spec. Public API additions: add_session(device_id) set_session_message_handler(device_id, h) set_session_selected_handler(device_id, h) session_state(device_id) -> State is_session_selected(device_id) -> bool send_request(device_id, msg, cb) send_data(device_id, msg) Internal refactor: state_/on_message_/on_selected_ folded into a SessionSlot map keyed by device_id; SS-style getters/setters route through the primary session. T7 + linktest are connection-scope — T7 fires only when no session is selected; linktest runs while at least one is. Five wire-level tests: - passive: two sessions selected independently via Select.req with their own session_id - GS Select.req for an unregistered session id is Rejected (EntityNotSelected) - data routed by session_id; data on a not-selected session is Rejected - active: two registered sessions both end up selected via serialized Select.req per session - SS legacy: existing single-session API still works (session_id 0xFFFF in Select.req) COMPLIANCE.md §1 updated: HSMS-GS row goes ⬜ → ✅. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 1 + COMPLIANCE.md | 4 +- include/secsgem/hsms/connection.hpp | 84 ++++++-- src/hsms/connection.cpp | 184 +++++++++++++---- tests/test_hsms_gs.cpp | 297 ++++++++++++++++++++++++++++ 5 files changed, 516 insertions(+), 54 deletions(-) create mode 100644 tests/test_hsms_gs.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 036f506..8130cc5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,7 @@ add_executable(secsgem_tests tests/test_hsms_connection.cpp tests/test_hsms_timers.cpp tests/test_hsms_s9.cpp + tests/test_hsms_gs.cpp tests/test_secsi.cpp tests/test_secsi_timers.cpp tests/test_secsi_tcp.cpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index e3c95ae..48acf9e 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -41,8 +41,8 @@ Legend: | T6 control transaction timeout | ✅ | E37 §10 | One concurrent control transaction. | | T7 not-selected timeout (passive) | ✅ | E37 §10 | Armed on connect / on Deselect.req. | | T8 intercharacter timeout | ✅ | E37 §10 | Bounds the payload read after length prefix. | -| HSMS-SS (single-session) | ✅ | E37 §11 | The codebase is HSMS-SS only by design. | -| HSMS-GS (general-session) | ⬜ | E37 §11 | Multi-session; out of scope for this revision. | +| HSMS-SS (single-session) | ✅ | E37 §11 | Default mode: the constructor registers a single session. | +| HSMS-GS (general-session) | ✅ | E37 §11 | `Connection::add_session(device_id)` registers extra sessions; per-session SELECTED state + message handlers; Select.req carries session_id=device_id in GS mode. | --- diff --git a/include/secsgem/hsms/connection.hpp b/include/secsgem/hsms/connection.hpp index 495d51d..1e95493 100644 --- a/include/secsgem/hsms/connection.hpp +++ b/include/secsgem/hsms/connection.hpp @@ -16,10 +16,17 @@ namespace secsgem::hsms { -// One HSMS session over a connected TCP socket. Drives the connection state -// machine (NOT_SELECTED -> SELECTED), runs the SELECT/LINKTEST/SEPARATE control -// handshakes and the T3/T6/T7/T8 timers, and correlates data replies by system -// bytes. Single-threaded: all work runs on the socket's executor. +// One HSMS session-set over a connected TCP socket. Drives the connection +// state machine (NOT_SELECTED -> SELECTED, per session), runs the +// SELECT/LINKTEST/SEPARATE control handshakes and the T3/T6/T7/T8 timers, +// and correlates data replies by system bytes. Single-threaded: all work +// runs on the socket's executor. +// +// Supports HSMS-SS (single-session, default — created by the constructor) +// and HSMS-GS (multi-session, E37 §11) via add_session(). In GS mode the +// Select.req carries the session's device_id in the header session_id +// field; in SS mode it carries the kControlSessionId = 0xFFFF sentinel +// per the legacy convention. class Connection : public std::enable_shared_from_this { public: enum class Mode { Active, Passive }; @@ -35,11 +42,27 @@ class Connection : public std::enable_shared_from_this { Connection(asio::ip::tcp::socket socket, Mode mode, uint16_t device_id, Timers timers); - void set_message_handler(MessageHandler h) { on_message_ = std::move(h); } - void set_selected_handler(SelectedHandler h) { on_selected_ = std::move(h); } + // SS-style setters: operate on the primary (constructor-supplied) session. + void set_message_handler(MessageHandler h); + void set_selected_handler(SelectedHandler h); void set_closed_handler(ClosedHandler h) { on_closed_ = std::move(h); } void set_log_handler(LogHandler h) { on_log_ = std::move(h); } + // ---- HSMS-GS (multi-session) ------------------------------------------ + // Register an additional session. In Active mode each registered + // session triggers a Select.req on its device_id once the prior + // session reaches SELECTED. In Passive mode the session can be + // selected by a peer's Select.req(session_id=device_id). + void add_session(uint16_t device_id); + void set_session_message_handler(uint16_t device_id, MessageHandler); + void set_session_selected_handler(uint16_t device_id, SelectedHandler); + State session_state(uint16_t device_id) const; + bool is_session_selected(uint16_t device_id) const; + + // Multi-session send overloads — frames carry session_id = device_id. + void send_request(uint16_t device_id, secs2::Message msg, ReplyHandler cb); + void send_data(uint16_t device_id, secs2::Message msg); + // Begin the read loop. Active mode also initiates the SELECT handshake; // Passive mode arms the T7 not-selected timer and waits for Select.req. void start(); @@ -57,8 +80,9 @@ class Connection : public std::enable_shared_from_this { // Hard close. void close(const std::string& reason); - State state() const { return state_; } - bool selected() const { return state_ == State::Selected; } + // SS-style state getters: report the primary session's state. + State state() const; + bool selected() const; // The HSMS header of the primary currently being dispatched by the // message_handler. Only non-null inside the handler. Used so the @@ -86,9 +110,11 @@ class Connection : public std::enable_shared_from_this { void write_next(); // --- handshakes & timers --- - void send_select_req(); + void send_select_req(); // primary session + void send_select_req(uint16_t device_id); // explicit session (GS) void send_linktest_req(); - void enter_selected(); + void enter_selected(); // primary session + void enter_selected(uint16_t device_id); // explicit session (GS) void arm_t7(); void arm_linktest(); void start_control_transaction(SType expected_response, uint32_t system_bytes, @@ -105,11 +131,41 @@ class Connection : public std::enable_shared_from_this { asio::steady_timer linktest_timer_; // periodic linktest interval Mode mode_; - uint16_t device_id_; + uint16_t device_id_; // primary session's device_id Timers timers_; - State state_ = State::NotSelected; bool closed_ = false; + // Per-session state. In SS mode this map has a single entry keyed + // by device_id_. In GS mode add_session() inserts more entries. + struct SessionSlot { + uint16_t device_id; + State state = State::NotSelected; + MessageHandler on_message; + SelectedHandler on_selected; + }; + std::map sessions_; + + // Active mode walk-list: device_ids still awaiting their initial + // Select.req when running multi-session. Empty for SS. + std::deque pending_active_selects_; + + bool gs_mode() const { return sessions_.size() > 1; } + SessionSlot* find_session(uint16_t device_id) { + auto it = sessions_.find(device_id); + return it == sessions_.end() ? nullptr : &it->second; + } + const SessionSlot* find_session(uint16_t device_id) const { + auto it = sessions_.find(device_id); + return it == sessions_.end() ? nullptr : &it->second; + } + // Pick which session an inbound Select.req with session_id=`sid` is + // targeting. SS legacy: any session_id (often 0xFFFF) maps to the + // single primary session. GS: must match a registered device_id. + SessionSlot* resolve_select_target(uint16_t sid); + // Pick which session an inbound data frame with session_id=`sid` + // is for. SS: primary. GS: must match a registered device_id. + SessionSlot* resolve_data_target(uint16_t sid); + std::array len_buf_{}; std::vector payload_; @@ -138,10 +194,8 @@ class Connection : public std::enable_shared_from_this { }; std::optional pending_control_; - const Header* current_header_ = nullptr; // set only inside on_message_ dispatch + const Header* current_header_ = nullptr; // set only inside dispatch - MessageHandler on_message_; - SelectedHandler on_selected_; ClosedHandler on_closed_; LogHandler on_log_; }; diff --git a/src/hsms/connection.cpp b/src/hsms/connection.cpp index d9e6412..5857f27 100644 --- a/src/hsms/connection.cpp +++ b/src/hsms/connection.cpp @@ -39,12 +39,66 @@ Connection::Connection(asio::ip::tcp::socket socket, Mode mode, uint16_t device_ linktest_timer_(socket_.get_executor()), mode_(mode), device_id_(device_id), - timers_(timers) {} + timers_(timers) { + SessionSlot primary; + primary.device_id = device_id; + sessions_.emplace(device_id, std::move(primary)); +} + +void Connection::set_message_handler(MessageHandler h) { + if (auto* s = find_session(device_id_)) s->on_message = std::move(h); +} +void Connection::set_selected_handler(SelectedHandler h) { + if (auto* s = find_session(device_id_)) s->on_selected = std::move(h); +} + +Connection::State Connection::state() const { + auto* s = find_session(device_id_); + return s ? s->state : State::NotSelected; +} +bool Connection::selected() const { return state() == State::Selected; } + +void Connection::add_session(uint16_t device_id) { + if (sessions_.count(device_id)) return; + SessionSlot s; + s.device_id = device_id; + sessions_.emplace(device_id, std::move(s)); + if (mode_ == Mode::Active) { + pending_active_selects_.push_back(device_id); + } +} + +void Connection::set_session_message_handler(uint16_t device_id, MessageHandler h) { + if (auto* s = find_session(device_id)) s->on_message = std::move(h); +} +void Connection::set_session_selected_handler(uint16_t device_id, SelectedHandler h) { + if (auto* s = find_session(device_id)) s->on_selected = std::move(h); +} +Connection::State Connection::session_state(uint16_t device_id) const { + auto* s = find_session(device_id); + return s ? s->state : State::NotSelected; +} +bool Connection::is_session_selected(uint16_t device_id) const { + return session_state(device_id) == State::Selected; +} + +Connection::SessionSlot* Connection::resolve_select_target(uint16_t sid) { + if (gs_mode()) return find_session(sid); + // SS legacy: session_id is typically 0xFFFF in Select.req but some + // peers also send the device_id. Either way, route to the primary. + return find_session(device_id_); +} + +Connection::SessionSlot* Connection::resolve_data_target(uint16_t sid) { + if (gs_mode()) return find_session(sid); + return find_session(device_id_); +} void Connection::start() { read_length(); if (mode_ == Mode::Active) { - send_select_req(); + if (gs_mode()) send_select_req(device_id_); + else send_select_req(); } else { arm_t7(); } @@ -167,9 +221,8 @@ void Connection::handle_data(const Frame& frame) { const Header& h = frame.header; // Reply correlation: match on (system_bytes, stream, function) exactly. - // SECS-II replies use the same system_bytes as their request and have - // stream==request.stream, function==request.function+1; we recorded both - // when send_request stored the pending transaction. + // System bytes are connection-scope (E37 §8.3); replies route by system + // bytes regardless of which session they came in on. auto it = pending_requests_.find(h.system_bytes); if (it != pending_requests_.end() && it->second.expected_stream == h.stream() && @@ -208,8 +261,10 @@ void Connection::handle_data(const Frame& frame) { // handler can deal with it; the pending request stays open until T3. } - // Primary message; only valid while SELECTED. - if (state_ != State::Selected) { + // Primary message; route by header.session_id (GS) or to primary (SS). + // Reject if no matching session OR if that session isn't SELECTED. + SessionSlot* target = resolve_data_target(h.session_id); + if (!target || target->state != State::Selected) { send_frame(Frame(Header::control(SType::RejectReq, h.system_bytes, kControlSessionId, static_cast(SType::Data), static_cast(RejectReason::EntityNotSelected)))); @@ -228,16 +283,16 @@ void Connection::handle_data(const Frame& frame) { } log("<- " + h.describe()); - if (!on_message_) return; + if (!target->on_message) return; // Expose the originating header to the handler in case it needs to emit // an S9F3 / S9F5 in response. Cleared on the way out. current_header_ = &h; - auto reply = on_message_(msg); + auto reply = target->on_message(msg); current_header_ = nullptr; if (reply) { - Frame out(Header::data_message(device_id_, reply->stream, reply->function, + Frame out(Header::data_message(target->device_id, reply->stream, reply->function, reply->reply_expected, h.system_bytes), reply->encode_body()); log("-> " + out.header.describe()); @@ -249,10 +304,18 @@ void Connection::handle_control(const Frame& frame) { const Header& h = frame.header; switch (h.stype) { case SType::SelectReq: { - // E37 §7.2: a Select.req while already SELECTED is answered with - // SelectStatus::AlreadyActive; we do NOT transition (we're already - // there) and we do NOT call the selected handler twice. - const auto status = (state_ == State::Selected) + // Route by header.session_id (GS) or to primary (SS legacy). + SessionSlot* tgt = resolve_select_target(h.session_id); + if (!tgt) { + // GS: no such session on this connection. + log("<- Select.req for unknown session " + std::to_string(h.session_id)); + send_frame(Frame(Header::control( + SType::RejectReq, h.system_bytes, h.session_id, + static_cast(SType::SelectReq), + static_cast(RejectReason::EntityNotSelected)))); + break; + } + const auto status = (tgt->state == State::Selected) ? SelectStatus::AlreadyActive : SelectStatus::Ok; log(std::string("<- Select.req") + @@ -261,7 +324,7 @@ void Connection::handle_control(const Frame& frame) { static_cast(status)))); log(std::string("-> Select.rsp (") + (status == SelectStatus::Ok ? "Ok" : "AlreadyActive") + ")"); - if (status == SelectStatus::Ok) enter_selected(); + if (status == SelectStatus::Ok) enter_selected(tgt->device_id); break; } case SType::SelectRsp: { @@ -270,7 +333,16 @@ void Connection::handle_control(const Frame& frame) { clear_control_transaction(); if (h.byte3 == static_cast(SelectStatus::Ok)) { log("<- Select.rsp (Ok)"); - enter_selected(); + // Identify which session this rsp completes by session_id; SS + // legacy mode uses 0xFFFF on the wire, default to primary. + uint16_t selected = gs_mode() ? h.session_id : device_id_; + enter_selected(selected); + // Active mode walk-list: kick off next pending select. + if (mode_ == Mode::Active && !pending_active_selects_.empty()) { + uint16_t next = pending_active_selects_.front(); + pending_active_selects_.pop_front(); + send_select_req(next); + } } else { close("Select rejected, status=" + std::to_string(h.byte3)); } @@ -278,18 +350,20 @@ void Connection::handle_control(const Frame& frame) { break; } case SType::DeselectReq: { - // E37 §7.4: Deselect.req while NOT_SELECTED returns NotEstablished - // and leaves state untouched. - const auto status = (state_ == State::Selected) + SessionSlot* tgt = resolve_select_target(h.session_id); + const auto status = (tgt && tgt->state == State::Selected) ? DeselectStatus::Ok : DeselectStatus::NotEstablished; log(std::string("<- Deselect.req") + (status == DeselectStatus::NotEstablished ? " (not SELECTED)" : "")); send_frame(Frame(Header::control(SType::DeselectRsp, h.system_bytes, h.session_id, 0, static_cast(status)))); - if (status == DeselectStatus::Ok) { - state_ = State::NotSelected; - arm_t7(); + if (status == DeselectStatus::Ok && tgt) { + tgt->state = State::NotSelected; + // Re-arm T7 only if NO sessions are selected. + bool any_selected = false; + for (auto& kv : sessions_) if (kv.second.state == State::Selected) { any_selected = true; break; } + if (!any_selected) arm_t7(); } break; } @@ -297,7 +371,8 @@ void Connection::handle_control(const Frame& frame) { if (pending_control_ && pending_control_->expected_response == SType::DeselectRsp && pending_control_->system_bytes == h.system_bytes) { clear_control_transaction(); - state_ = State::NotSelected; + uint16_t target = gs_mode() ? h.session_id : device_id_; + if (auto* s = find_session(target)) s->state = State::NotSelected; } break; } @@ -357,32 +432,33 @@ void Connection::write_next() { // --- public send API ------------------------------------------------------ void Connection::send_request(secs2::Message msg, ReplyHandler cb) { + send_request(device_id_, std::move(msg), std::move(cb)); +} + +void Connection::send_request(uint16_t device_id, secs2::Message msg, ReplyHandler cb) { if (closed_) { cb(make_error(Error::Closed), {}); return; } const uint32_t sys = next_system_bytes(); - Frame f(Header::data_message(device_id_, msg.stream, msg.function, true, sys), + Frame f(Header::data_message(device_id, msg.stream, msg.function, true, sys), msg.encode_body()); auto t3 = std::make_shared(socket_.get_executor()); t3->expires_after(timers_.t3); - // SECS-II reply convention: same stream, function+1. pending_requests_.emplace(sys, PendingRequest{ /*expected_stream=*/msg.stream, /*expected_function=*/static_cast(msg.function + 1), std::move(cb), t3}); auto self = shared_from_this(); - t3->async_wait([this, self, sys](std::error_code ec) { + t3->async_wait([this, self, sys, device_id](std::error_code ec) { if (ec) return; auto it = pending_requests_.find(sys); if (it == pending_requests_.end()) return; - // Reconstruct the MHEAD of the request that timed out and notify the - // peer with S9F9 before surfacing Timeout to the caller. const uint8_t req_stream = it->second.expected_stream; const uint8_t req_function = static_cast(it->second.expected_function - 1); - Header h = Header::data_message(device_id_, req_stream, req_function, true, sys); + Header h = Header::data_message(device_id, req_stream, req_function, true, sys); emit_s9(9, h.encode()); auto cb2 = std::move(it->second.cb); pending_requests_.erase(it); @@ -394,9 +470,13 @@ void Connection::send_request(secs2::Message msg, ReplyHandler cb) { } void Connection::send_data(secs2::Message msg) { + send_data(device_id_, std::move(msg)); +} + +void Connection::send_data(uint16_t device_id, secs2::Message msg) { if (closed_) return; const uint32_t sys = next_system_bytes(); - Frame f(Header::data_message(device_id_, msg.stream, msg.function, msg.reply_expected, sys), + Frame f(Header::data_message(device_id, msg.stream, msg.function, msg.reply_expected, sys), msg.encode_body()); log("-> " + f.header.describe()); send_frame(std::move(f)); @@ -436,12 +516,23 @@ void Connection::close(const std::string& reason) { // --- handshakes & timers -------------------------------------------------- void Connection::send_select_req() { + // SS legacy: Select.req uses 0xFFFF session id. Used unchanged when + // running with a single registered session. const uint32_t sys = next_system_bytes(); log("-> Select.req"); send_frame(Frame(Header::control(SType::SelectReq, sys))); start_control_transaction(SType::SelectRsp, sys, "Select"); } +void Connection::send_select_req(uint16_t device_id) { + // GS: Select.req carries the session's device_id in the session_id + // field (E37 §11.4.2). + const uint32_t sys = next_system_bytes(); + log("-> Select.req session=" + std::to_string(device_id)); + send_frame(Frame(Header::control(SType::SelectReq, sys, device_id))); + start_control_transaction(SType::SelectRsp, sys, "Select"); +} + void Connection::send_linktest_req() { const uint32_t sys = next_system_bytes(); send_frame(Frame(Header::control(SType::LinktestReq, sys))); @@ -449,12 +540,18 @@ void Connection::send_linktest_req() { } void Connection::enter_selected() { - if (state_ == State::Selected) return; - state_ = State::Selected; + enter_selected(device_id_); +} + +void Connection::enter_selected(uint16_t device_id) { + auto* s = find_session(device_id); + if (!s) return; + if (s->state == State::Selected) return; + s->state = State::Selected; t7_timer_.cancel(); - log("== SELECTED =="); + log("== SELECTED session=" + std::to_string(device_id) + " =="); arm_linktest(); - if (on_selected_) on_selected_(); + if (s->on_selected) s->on_selected(); } void Connection::arm_t7() { @@ -463,17 +560,30 @@ void Connection::arm_t7() { t7_timer_.expires_after(timers_.t7); auto self = shared_from_this(); t7_timer_.async_wait([this, self](std::error_code ec) { - if (!ec && !closed_ && state_ == State::NotSelected) close("T7 not-selected timeout"); + if (!ec && !closed_) { + // Fire only if NO session has been selected — T7 is a single + // connection-scope timer per E37 §10, not per-session. + bool any = false; + for (auto& kv : sessions_) if (kv.second.state == State::Selected) { any = true; break; } + if (!any) close("T7 not-selected timeout"); + } }); } void Connection::arm_linktest() { if (timers_.linktest.count() <= 0) return; - if (state_ != State::Selected) return; + // Run linktest while any session is selected — that proves the + // underlying TCP path is alive regardless of which session uses it. + bool any = false; + for (auto& kv : sessions_) if (kv.second.state == State::Selected) { any = true; break; } + if (!any) return; linktest_timer_.expires_after(timers_.linktest); auto self = shared_from_this(); linktest_timer_.async_wait([this, self](std::error_code ec) { - if (!ec && !closed_ && state_ == State::Selected) send_linktest_req(); + if (ec || closed_) return; + bool any2 = false; + for (auto& kv : sessions_) if (kv.second.state == State::Selected) { any2 = true; break; } + if (any2) send_linktest_req(); }); } diff --git a/tests/test_hsms_gs.cpp b/tests/test_hsms_gs.cpp new file mode 100644 index 0000000..b60e23f --- /dev/null +++ b/tests/test_hsms_gs.cpp @@ -0,0 +1,297 @@ +// HSMS-GS (multi-session, E37 §11) tests. +// +// The same hsms::Connection class supports HSMS-SS by default (one +// session registered by the constructor) and HSMS-GS once +// `add_session(device_id)` registers more. These tests cover the +// distinct behaviours: per-session selected state, session-routed +// data dispatch, rejection of data on the wrong session, and +// Reject(EntityNotSelected) for a Select.req targeting an unknown +// session id. + +#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; +using namespace std::chrono_literals; + +namespace { + +struct SocketPair { + asio::io_context io; + asio::ip::tcp::socket a{io}, 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(); + bool da = false, db = false; + std::error_code ea, eb; + acc.async_accept(a, [&](std::error_code ec) { ea = ec; da = true; }); + b.async_connect({asio::ip::address_v4::loopback(), port}, + [&](std::error_code ec) { eb = ec; db = true; }); + while (!(da && db)) { + if (io.stopped()) io.restart(); + if (io.poll() == 0) std::this_thread::sleep_for(1ms); + } + REQUIRE_FALSE(ea); + REQUIRE_FALSE(eb); + } +}; + +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>(); + bool len_done = false; + std::error_code rec_ec; + 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 hsms::Frame::decode(payload->data(), payload->size()); +} + +hsms::Timers permissive_timers() { + hsms::Timers t; + t.t3 = 5s; t.t6 = 5s; t.t7 = 5s; t.t8 = 5s; t.linktest = 0ms; + return t; +} + +} // namespace + +TEST_CASE("HSMS-GS passive: two sessions selected independently") { + SocketPair sp; + auto conn = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, + /*primary_device_id=*/1, permissive_timers()); + conn->add_session(2); + + std::atomic sel_count_1{0}; + std::atomic sel_count_2{0}; + conn->set_session_selected_handler(1, [&] { ++sel_count_1; }); + conn->set_session_selected_handler(2, [&] { ++sel_count_2; }); + conn->start(); + + // Select session 1 via Select.req(session_id=1). + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectReq, 100, /*session_id=*/1)) + .encode()); + auto rsp1 = try_recv_frame(sp); + REQUIRE(rsp1.has_value()); + CHECK(rsp1->header.stype == hsms::SType::SelectRsp); + CHECK(rsp1->header.byte3 == static_cast(hsms::SelectStatus::Ok)); + pump_until(sp.io, [&] { return sel_count_1.load() == 1; }); + CHECK(conn->is_session_selected(1)); + CHECK_FALSE(conn->is_session_selected(2)); + + // Select session 2. + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectReq, 101, /*session_id=*/2)) + .encode()); + auto rsp2 = try_recv_frame(sp); + REQUIRE(rsp2.has_value()); + CHECK(rsp2->header.byte3 == static_cast(hsms::SelectStatus::Ok)); + pump_until(sp.io, [&] { return sel_count_2.load() == 1; }); + CHECK(conn->is_session_selected(2)); + + conn->close("test done"); +} + +TEST_CASE("HSMS-GS: Select.req for unknown session is Rejected") { + SocketPair sp; + auto conn = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, + /*primary_device_id=*/1, permissive_timers()); + conn->add_session(2); + conn->start(); + + // Request select for session 7 — never registered. + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectReq, 5, /*session_id=*/7)) + .encode()); + auto rej = try_recv_frame(sp); + REQUIRE(rej.has_value()); + CHECK(rej->header.stype == hsms::SType::RejectReq); + CHECK(rej->header.byte3 == + static_cast(hsms::RejectReason::EntityNotSelected)); + + conn->close("test done"); +} + +TEST_CASE("HSMS-GS: data routed by session_id; wrong session is rejected") { + SocketPair sp; + auto conn = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, + /*primary_device_id=*/1, permissive_timers()); + conn->add_session(2); + + std::vector>> seen; + conn->set_session_message_handler(1, [&](const secs2::Message& m) + -> std::optional { + seen.emplace_back(1, std::make_pair(m.stream, m.function)); + return std::nullopt; + }); + conn->set_session_message_handler(2, [&](const secs2::Message& m) + -> std::optional { + seen.emplace_back(2, std::make_pair(m.stream, m.function)); + return std::nullopt; + }); + conn->start(); + + // Select session 1 only. + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectReq, 10, /*session_id=*/1)) + .encode()); + auto rsp = try_recv_frame(sp); + REQUIRE(rsp.has_value()); + pump_until(sp.io, [&] { return conn->is_session_selected(1); }); + + // Data for session 1 — should route to session 1's handler. + send_bytes(sp, hsms::Frame(hsms::Header::data_message( + /*session_id=*/1, /*stream=*/1, /*function=*/3, + /*reply_expected=*/false, /*sys=*/200)) + .encode()); + pump_until(sp.io, [&] { + for (auto& s : seen) if (s.first == 1) return true; + return false; + }); + + // Data for session 2 (not selected) — should be Reject(EntityNotSelected). + send_bytes(sp, hsms::Frame(hsms::Header::data_message( + /*session_id=*/2, /*stream=*/1, /*function=*/3, + /*reply_expected=*/false, /*sys=*/201)) + .encode()); + auto rej = try_recv_frame(sp); + REQUIRE(rej.has_value()); + CHECK(rej->header.stype == hsms::SType::RejectReq); + CHECK(rej->header.byte3 == + static_cast(hsms::RejectReason::EntityNotSelected)); + + // Session 2's handler must NOT have fired. + for (auto& s : seen) CHECK(s.first != 2); + + conn->close("test done"); +} + +TEST_CASE("HSMS-GS active: two registered sessions both end up selected") { + // Active mode: the connection sends Select.req for each session + // serially, waiting for each Ok before moving to the next. + SocketPair sp; + // Build active "host" with two sessions. + auto host = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Active, + /*primary_device_id=*/1, permissive_timers()); + host->add_session(2); + std::atomic host_sel_1{0}; + std::atomic host_sel_2{0}; + host->set_session_selected_handler(1, [&] { ++host_sel_1; }); + host->set_session_selected_handler(2, [&] { ++host_sel_2; }); + + // Use the *other* socket as a raw peer. The host will issue + // Select.req in GS mode (session_id=device_id of each session); + // we ack each one Ok. + host->start(); + + // First Select.req should arrive for session 1. + auto sel1 = try_recv_frame(sp); + REQUIRE(sel1.has_value()); + CHECK(sel1->header.stype == hsms::SType::SelectReq); + CHECK(sel1->header.session_id == 1); + // Ack Ok. + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectRsp, sel1->header.system_bytes, + /*session_id=*/1, /*byte2=*/0, + /*byte3=*/static_cast(hsms::SelectStatus::Ok))) + .encode()); + pump_until(sp.io, [&] { return host_sel_1.load() == 1; }); + + // Second Select.req should arrive for session 2. + auto sel2 = try_recv_frame(sp); + REQUIRE(sel2.has_value()); + CHECK(sel2->header.stype == hsms::SType::SelectReq); + CHECK(sel2->header.session_id == 2); + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectRsp, sel2->header.system_bytes, + /*session_id=*/2, /*byte2=*/0, + /*byte3=*/static_cast(hsms::SelectStatus::Ok))) + .encode()); + pump_until(sp.io, [&] { return host_sel_2.load() == 1; }); + + CHECK(host->is_session_selected(1)); + CHECK(host->is_session_selected(2)); + + host->close("test done"); +} + +TEST_CASE("HSMS-SS: existing single-session API still works (backwards compat)") { + // Sanity that the SS-only path hasn't regressed. + SocketPair sp; + auto conn = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, 0, permissive_timers()); + bool selected = false; + conn->set_selected_handler([&] { selected = true; }); + conn->start(); + + // SS-style Select.req (session_id=0xFFFF). + send_bytes(sp, hsms::Frame(hsms::Header::control( + hsms::SType::SelectReq, /*sys=*/1)) + .encode()); + auto rsp = try_recv_frame(sp); + REQUIRE(rsp.has_value()); + CHECK(rsp->header.stype == hsms::SType::SelectRsp); + CHECK(rsp->header.byte3 == static_cast(hsms::SelectStatus::Ok)); + pump_until(sp.io, [&] { return selected; }); + CHECK(conn->selected()); + + conn->close("test done"); +}