# 33 — Transport ← [32 Stores and the data model](32_stores_and_the_data_model.md) | [Back to index](00_index.md) | Next: [34 Codec and SML](34_codec_and_sml.md) → 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 ```cpp // 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( 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`](../src/hsms/connection.cpp): ```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`: ```cpp 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_timer`s, one per HSMS T-timer: ```cpp // 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_timer`s allocated when the request is sent and destroyed when the reply arrives. See `src/hsms/connection.cpp:447`: ```cpp auto t3 = std::make_shared(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`: ```cpp auto strand = asio::make_strand(io); asio::ip::tcp::socket sock(strand); auto conn = std::make_shared(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: ```cpp // 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`](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 `Action`s (transmit these bytes, arm a timer, deliver a block to the application). ```cpp // include/secsgem/secsi/protocol.hpp struct ActionTransmit { std::vector 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: ```cpp 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; if constexpr (std::is_same_v) write_bytes(v.bytes); else if constexpr (std::is_same_v) arm_timer(v.which); else if constexpr (std::is_same_v) cancel_timer(v.which); else if constexpr (std::is_same_v) deliver(v.block); else if constexpr (std::is_same_v) 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`](../tests/test_secsi.cpp) — basic FSM state walks. - [`tests/test_secsi_timers.cpp`](../tests/test_secsi_timers.cpp) — every timer scenario via synthetic `EventTimeout` injection. - [`tests/test_secsi_tcp.cpp`](../tests/test_secsi_tcp.cpp) — end-to-end via `TcpTransport`. 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](34_codec_and_sml.md)