cae98d9a7d
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>
293 lines
9.2 KiB
Markdown
293 lines
9.2 KiB
Markdown
# 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<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`](../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<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`:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```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<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:
|
|
|
|
```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<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`](../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)
|