Files
secs-gem/docs/33_transport.md
raphael cae98d9a7d docs: chapters 30–36 — the codebase (Part 3 complete)
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>
2026-06-09 20:23:05 +02:00

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:

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::post boilerplate.
  • 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