Seven chapters walking the implementation top-to-bottom. 30 — Repository tour. Top-level layout, directory by directory. The eight built binaries. The dependency graph from TCP socket up through EquipmentDataModel. CMake's role. Test layout. 31 — Spec-as-data and codegen. Why the design choice fits SECS/ GEM specifically. The five YAML files: messages catalog, control/PJ/CJ transition tables, equipment dictionary. How tools/gen_messages.py turns messages.yaml into typed C++ at build time. The --validate-config multi-error validator. How to add a new SVID / CEID / host command / state / message without C++. 32 — Stores and the data model. What a store IS (records + API + change handler + optional persistence). Every store in the codebase mapped to the SEMI standard it serves (table of 21). EquipmentDataModel as plain composition + cross-store convenience methods (vid_value, compose_reports_for). The no-locks single- threaded contract. How to add a new store. 33 — Transport. hsms::Connection read path (length+payload async chain), write path (queue + one outstanding write), timer model (5 steady_timers + per-request T3). The asio executor / strand model and why it's the right shape. secsi::Protocol as the IO- free FSM with Action / Event variants; secsi::TcpTransport as the asio adapter. Pattern repeats for E84 + GEM comm-state. 34 — Codec and SML. The four files (170 + 30 + 52 + 32 lines of header, 229 + 220 lines of impl). Item variant storage layout (11 alternatives, 16 formats, shared storage where E5 permits). encode_into recursion; decode_at with bounds checks throwing CodecError. Message wrapper. SML printer + try_parse_sml + why SML round-trips Items but not necessarily bytes. 35 — State machines and dispatch. gem::Router as a typed (stream, function) dispatch table. How an S2F41 round-trip walks through parser → store dispatch → side-effect → CEID emission → S6F11 build → spool-aware deliver. The 11 FSMs all sharing the same three-property shape (pure data table + pure FSM + observer pattern). CEID cascading from FSM transitions to wire bytes. 36 — Persistence, validation, metrics. Which 7 stores have file journals + why the others don't. Per-record file pattern (atomic rename, partial-write safe). Schema versioning + multi-version read. Multi-error YAML validator (--validate-config) + cross-file reference checks. Prometheus registry + HTTP exporter + worked metric patterns from the PVD example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9.2 KiB
33 — Transport
← 32 Stores and the data model | Back to index | Next: 34 Codec and SML →
We covered the standards-level view of HSMS and SECS-I in
chapters 11 and 12. This chapter drops down into the
implementation: how hsms::Connection actually moves bytes,
the asio executor model, the single-threaded strand contract,
and why the transport layer doesn't need locks.
The two transport modules
include/secsgem/hsms/
├── header.hpp — Frame format primitives (length prefix, header, SType).
└── connection.hpp — One-socket session manager + T-timers + S9 emission.
include/secsgem/secsi/
├── header.hpp — 10-byte SECS-I block header.
├── block.hpp — Block split / assemble (multi-block messages).
├── protocol.hpp — IO-free line-turnaround FSM.
└── tcp_transport.hpp — asio TCP wrapper around the FSM (tunnel for testing).
Each module owns one TCP endpoint (or in the SECS-I case, a tunnel endpoint). Both are single-threaded by design.
hsms::Connection — top to bottom
Lifecycle
// apps/secs_server.cpp — passive-equipment startup
asio::io_context io;
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint{asio::ip::tcp::v4(), port});
acc.async_accept([&](std::error_code ec, asio::ip::tcp::socket sock) {
auto conn = std::make_shared<hsms::Connection>(
std::move(sock), Mode::Passive, /*device_id=*/0, timers);
conn->set_message_handler(...);
conn->set_closed_handler(...);
conn->start();
});
io.run(); // blocks until all work is done
Connection::start() either:
- Passive — arms T7 (waiting for Select.req) and starts the read loop.
- Active — initiates the Select.req exchange, then starts the read loop.
Read path
Three async steps repeated forever:
async_read(socket, 4 bytes) → on_length()
async_read(socket, length bytes) → on_payload()
handle_frame(decoded Frame) → dispatch
In code, src/hsms/connection.cpp:
void Connection::read_length() {
asio::async_read(socket_, asio::buffer(len_buf_, 4),
[self = shared_from_this()](std::error_code ec, std::size_t n) {
self->on_length(ec, n);
});
}
void Connection::on_length(std::error_code ec, std::size_t n) {
if (ec) return close("read_length");
uint32_t len = decode_be32(len_buf_);
payload_.resize(len);
asio::async_read(socket_, asio::buffer(payload_),
[self = shared_from_this()](std::error_code ec, std::size_t n) {
self->on_payload(ec, n);
});
}
Each callback is on the socket's executor. No locks because nothing else can be touching the read state — by construction.
Write path
A send queue + one outstanding async_write:
void Connection::send_frame(Frame frame) {
send_queue_.push_back(std::move(frame));
if (send_queue_.size() == 1) write_next();
}
void Connection::write_next() {
auto& frame = send_queue_.front();
send_buf_ = frame.encode();
asio::async_write(socket_, asio::buffer(send_buf_),
[self = shared_from_this()](std::error_code ec, std::size_t) {
self->send_queue_.pop_front();
if (ec) return self->close("write");
if (!self->send_queue_.empty()) self->write_next();
});
}
Same single-threaded discipline — send_queue_ is only touched
on the executor. Callers from other threads must asio::post.
Timers
Five asio::steady_timers, one per HSMS T-timer:
// src/hsms/connection.cpp:30
Connection::Connection(...)
: socket_(std::move(sock)),
t3_timer_(socket_.get_executor()),
t6_timer_(socket_.get_executor()),
t7_timer_(socket_.get_executor()),
t8_timer_(socket_.get_executor()),
linktest_timer_(socket_.get_executor()),
timers_(timers) { }
All five share the socket's executor. When a timer fires, its handler runs on the same executor as the read/write loop — so again no locks for timer-vs-IO interaction.
T3 is special: there's one T3 timer per in-flight W=1 message
(correlated by system_bytes). These are short-lived
steady_timers allocated when the request is sent and destroyed
when the reply arrives. See src/hsms/connection.cpp:447:
auto t3 = std::make_shared<asio::steady_timer>(socket_.get_executor());
t3->expires_after(timers_.t3);
in_flight_.insert({system_bytes, RequestState{std::move(cb), t3, ...}});
t3->async_wait([self, system_bytes](std::error_code ec) {
if (ec) return; // cancelled (reply arrived)
self->on_t3_expire(system_bytes);
});
The asio executor / strand model
All Connection state lives on one executor. In simple cases
that's just io_context.get_executor() — a single-threaded loop.
In production, an EAP may run multiple io_context::run() threads
per connection by wrapping work in an asio::strand.
What a strand is
A strand is an executor that guarantees mutual exclusion
between handlers it dispatches. Multiple threads can call
io.run(); the strand picks one of them at a time to run the
next handler in its queue.
For HSMS purposes, socket_.get_executor() already gives strand
semantics if the underlying io_context is single-threaded. For
multi-threaded io_context, the application wraps with
asio::make_strand:
auto strand = asio::make_strand(io);
asio::ip::tcp::socket sock(strand);
auto conn = std::make_shared<hsms::Connection>(std::move(sock), ...);
Now conn has all its IO running on strand, while the
io_context can use 8 threads to handle 100 different connections.
What this means for the caller
From any thread that isn't the strand's currently-running handler, the caller MUST marshal onto the strand:
// From a sensor-thread callback:
asio::post(conn->executor(), [conn, msg = std::move(msg)] {
conn->send_data(std::move(msg));
});
Calling conn->send_data directly from another thread is a
race. Same for any store mutation. TSan catches this and the
test suite enforces it.
The contract is documented in detail in
docs/INTEGRATION.md §3.
secsi::Protocol — the IO-free FSM
SECS-I's protocol layer is structured differently: the FSM has
no IO at all. It takes events (bytes received, application
asked to send, timer fired) and produces a list of Actions
(transmit these bytes, arm a timer, deliver a block to the
application).
// include/secsgem/secsi/protocol.hpp
struct ActionTransmit { std::vector<uint8_t> bytes; };
struct ActionStartTimer { Timer which; };
struct ActionCancelTimer { Timer which; };
struct ActionDeliverBlock { Block block; };
struct ActionRaiseError { std::string reason; };
The wrapper (secsi::TcpTransport) drives the FSM:
void TcpTransport::on_byte(uint8_t b) {
auto actions = protocol_.handle(EventByte{b});
for (const auto& a : actions) execute(a);
}
void TcpTransport::execute(const Action& a) {
std::visit([this](auto&& v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, ActionTransmit>) write_bytes(v.bytes);
else if constexpr (std::is_same_v<T, ActionStartTimer>) arm_timer(v.which);
else if constexpr (std::is_same_v<T, ActionCancelTimer>) cancel_timer(v.which);
else if constexpr (std::is_same_v<T, ActionDeliverBlock>) deliver(v.block);
else if constexpr (std::is_same_v<T, ActionRaiseError>) raise(v.reason);
}, a);
}
This design makes the whole FSM unit-testable. No sockets,
no timers, just Event in → Action out. Tests:
tests/test_secsi.cpp— basic FSM state walks.tests/test_secsi_timers.cpp— every timer scenario via syntheticEventTimeoutinjection.tests/test_secsi_tcp.cpp— end-to-end viaTcpTransport.
Same pattern repeats for E84 (chapter 18) and the GEM communication-state FSM (chapter 13): IO-free FSM + asio adapter
- separate test suites for each layer.
Why this is the right shape
Pros of single-threaded + IO-free
- No mutexes. Anywhere.
- Trivial reasoning. When you read
Connection::send_frame, you can be sure nothing else is mutating the queue. - Fast. No lock contention, no atomic round-trips.
- Testable. IO-free FSM lets you exercise every transition without IO.
Cons
- Callers must know about the strand. Multi-threaded
applications need
asio::postboilerplate. - One slow handler blocks the rest. A 100 ms handler delays every other message until it returns. Fix: don't write slow handlers; if you must, dispatch the slow work to another executor and return immediately.
For a SECS/GEM equipment runtime — where the natural shape is "one TCP socket, one event loop" — the pros far outweigh the cons.
Where to go next
You've now seen the bottom layer in detail: how bytes move, how state machines drive transitions without IO, how the single- threaded contract makes everything safe. Next chapter goes back up one level: the codec — the encoder/decoder that turns the Item type from chapter 10 into wire bytes, plus the SML human-readable form.
Next: → 34 Codec and SML