Implements the largest functional gap from the compliance audit. The
equipment now queues events the host can't immediately receive (either
because there's no SELECTED session or because the demo's force-spool
flag is on) and transmits the queue on host request.
What's new
include/secsgem/gem/store/spool.hpp
SpoolStore: a deque queue with a configurable per-stream whitelist
(so only streams 5+6 spool by default), a max_size cap with FIFO
eviction on overflow, and a `force_spool` test flag. Enqueue
returns one of Queued / Dropped_NotSpoolable / Dropped_Full so the
caller can fall back to live delivery when appropriate. Drain
pops the entire queue in FIFO order. Two new ack enums:
ResetSpoolAck (S2F44 RSPACK) and SpoolRequestAck (S6F24 RSDA), plus
SpoolRequestCode (S6F23 RSDC, Transmit/Purge).
data/messages.yaml + auto-regenerated messages.hpp
S2F43 W <L,n <B stream>> Reset Spooling
S2F44 <L,2 <B RSPACK> <L,a ...>> Reset Spooling Ack
S6F23 W <B RSDC> Request Spooled Data
S6F24 <B RSDA> Request Spooled Data Ack
data/equipment.yaml
`spool:` section: max_size + spoolable_streams list. Two new host
commands SPOOL_ON / SPOOL_OFF that flip the force-spool flag (these
stand in for "host link down" in the demo without dropping TCP).
include/secsgem/gem/store/host_commands.hpp
Spec/Result gain an optional<bool> force_spool field. S2F41
dispatch returns the result, the server applies it after S2F42 is
queued.
src/config/loader.cpp
Reads `spool:` from equipment.yaml; reads `force_spool` from each
host_commands entry; populates SpoolStore + CommandSpec.
apps/secs_server.cpp
New `deliver_or_spool(msg, what)` helper. emit_event and
emit_alarm_set funnel through it: if force_spool is on (or there's
no active session), msg.stream is checked against the spoolable
list and the message is enqueued; otherwise it's sent live.
Two new handlers:
S2F43 parses the stream list, updates SpoolStore, replies S2F44
S6F23 RSDC=Transmit drains and re-sends each as a fresh primary
(posted on the executor so the S6F24 ack flushes first);
RSDC=Purge clears the queue and acks.
The S2F41 handler now also propagates result.force_spool into the
SpoolStore.
apps/secs_client.cpp
Demo extended with 4 new steps after the FAULT branch:
SPOOL_ON -> S2F42 Accept
START -> S2F42 Accept; CEID 300 emission spooled (no live S6F11)
SPOOL_OFF -> S2F42 Accept; queue still has the message
S6F23(Transmit) -> S6F24 Accept; spooled S6F11 arrives next
Then the existing S7F19/S7F5/S10F1/S1F15/Separate flow continues.
tests/test_data_model.cpp
Four new TEST_CASEs for SpoolStore (whitelist, FIFO eviction at
max_size, drain ordering, force flag).
tests/test_loader.cpp
Confirms equipment.yaml's `spool:` section populates the store and
`force_spool: true/false` flows through to dispatch results.
COMPLIANCE.md
Spooling moves from ⬜ to 🟡. Adds S2F43/F44 + S6F23/F24 as ✅ in
the message coverage matrix; calls out what's still missing
(S6F25/F26 notification, automatic activation on HSMS NOT-SELECTED,
persistent on-disk spool).
Verified
- Tests: 73 cases / 383 assertions pass (+4 spool cases).
- Demo (docker compose up server client) walks the full happy path
and the spool path, observed in the server log as:
spool: force_spool=true (depth=0)
spool: S6F11 CEID=300 queued (depth=1)
spool: force_spool=false (depth=1)
S6F23 transmit: draining 1 messages
and on the host side as the queued S6F11 arriving in the correct
order after S6F24.
Known limitations (logged in COMPLIANCE.md)
- Spool activation is manual via SPOOL_ON/OFF rather than
automatically triggered by HSMS NOT-SELECTED.
- No S6F25/F26 spooled-data-ready notification on re-SELECT.
- In-memory only; an equipment restart loses queued events.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+73
-12
@@ -73,10 +73,30 @@ int main(int argc, char** argv) {
|
||||
|
||||
auto active_conn = std::make_shared<std::weak_ptr<Connection>>();
|
||||
|
||||
auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) {
|
||||
asio::post(io, [active_conn, model, logfn, ceid]() {
|
||||
auto conn = active_conn->lock();
|
||||
if (!conn) return;
|
||||
// Deliver a primary message to the host, or spool it if we can't (either
|
||||
// there is no SELECTED session or the spool's force-flag is on). Returns
|
||||
// true if the message was delivered or queued; false if dropped because
|
||||
// the stream isn't spoolable and there is no live session.
|
||||
auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg,
|
||||
const std::string& what) -> bool {
|
||||
auto conn = active_conn->lock();
|
||||
const bool spooling = model->spool.force_spool() || !conn;
|
||||
if (spooling) {
|
||||
auto r = model->spool.enqueue(msg);
|
||||
if (r == gem::SpoolStore::EnqueueResult::Queued) {
|
||||
logfn("spool: " + what + " queued (depth=" +
|
||||
std::to_string(model->spool.size()) + ")");
|
||||
return true;
|
||||
}
|
||||
logfn("spool: " + what + " dropped (stream not spoolable, no host)");
|
||||
return false;
|
||||
}
|
||||
conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {});
|
||||
return true;
|
||||
};
|
||||
|
||||
auto emit_event = [&io, model, logfn, deliver_or_spool](uint32_t ceid) {
|
||||
asio::post(io, [model, logfn, deliver_or_spool, ceid]() {
|
||||
if (!model->is_event_enabled(ceid)) {
|
||||
logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed");
|
||||
return;
|
||||
@@ -84,23 +104,21 @@ int main(int argc, char** argv) {
|
||||
auto reports = model->compose_reports_for(ceid);
|
||||
logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" +
|
||||
std::to_string(reports.size()) + " reports)");
|
||||
conn->send_request(gem::s6f11_event_report(0, ceid, reports),
|
||||
[](std::error_code, const s2::Message&) {});
|
||||
deliver_or_spool(gem::s6f11_event_report(0, ceid, reports),
|
||||
"S6F11 CEID=" + std::to_string(ceid));
|
||||
});
|
||||
};
|
||||
|
||||
auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) {
|
||||
asio::post(io, [active_conn, model, logfn, emit_event, alid]() {
|
||||
auto conn = active_conn->lock();
|
||||
if (!conn) return;
|
||||
auto emit_alarm_set = [&io, model, logfn, emit_event, deliver_or_spool](uint32_t alid) {
|
||||
asio::post(io, [model, logfn, emit_event, deliver_or_spool, alid]() {
|
||||
auto alarm = model->alarms.get(alid);
|
||||
if (!alarm) return;
|
||||
auto alcd = model->alarms.set_active(alid);
|
||||
if (!alcd) return;
|
||||
if (model->alarms.enabled(alid)) {
|
||||
logfn("emit S5F1 alarm set ALID=" + std::to_string(alid));
|
||||
conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
|
||||
[](std::error_code, const s2::Message&) {});
|
||||
deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
|
||||
"S5F1 ALID=" + std::to_string(alid));
|
||||
} else {
|
||||
logfn("alarm " + std::to_string(alid) + " not enabled; suppressed");
|
||||
}
|
||||
@@ -250,10 +268,53 @@ int main(int argc, char** argv) {
|
||||
if (result.ack == gem::HostCmdAck::Accept) {
|
||||
if (result.emit_ceid) emit_event(*result.emit_ceid);
|
||||
if (result.set_alarm) emit_alarm_set(*result.set_alarm);
|
||||
if (result.force_spool) {
|
||||
model->spool.set_force_spool(*result.force_spool);
|
||||
logfn(std::string("spool: force_spool=") + (*result.force_spool ? "true" : "false") +
|
||||
" (depth=" + std::to_string(model->spool.size()) + ")");
|
||||
}
|
||||
}
|
||||
return gem::s2f42_host_command_ack(result.ack, {});
|
||||
});
|
||||
|
||||
router.on(2, 43, [model, logfn](const s2::Message& msg) {
|
||||
auto streams = gem::parse_s2f43(msg);
|
||||
auto ack = gem::ResetSpoolAck::Accept;
|
||||
std::vector<gem::ResetSpoolStreamAck> per;
|
||||
if (!streams) {
|
||||
ack = gem::ResetSpoolAck::Denied_NotAllowed;
|
||||
} else {
|
||||
model->spool.set_spoolable_streams(*streams);
|
||||
logfn("S2F43 spoolable=" + std::to_string(streams->size()) + " streams");
|
||||
}
|
||||
return gem::s2f44_reset_spooling_ack(ack, per);
|
||||
});
|
||||
|
||||
router.on(6, 23, [&io, active_conn, model, logfn](const s2::Message& msg) {
|
||||
auto rsdc = gem::parse_s6f23(msg);
|
||||
if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied);
|
||||
if (*rsdc == gem::SpoolRequestCode::Purge) {
|
||||
const auto n = model->spool.size();
|
||||
model->spool.clear();
|
||||
logfn("S6F23 purge: dropped " + std::to_string(n) + " messages");
|
||||
return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept);
|
||||
}
|
||||
// Transmit: drain the queue, fire each as a fresh primary. Defer to
|
||||
// the executor so the S6F24 ack flushes before the drained primaries
|
||||
// go out — the host should see ACK first, then the spooled traffic.
|
||||
auto drained = model->spool.drain();
|
||||
logfn("S6F23 transmit: draining " + std::to_string(drained.size()) +
|
||||
" messages");
|
||||
asio::post(io, [active_conn, drained = std::move(drained), logfn]() mutable {
|
||||
auto conn = active_conn->lock();
|
||||
if (!conn) return;
|
||||
for (auto& m : drained) {
|
||||
conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {});
|
||||
}
|
||||
});
|
||||
return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept);
|
||||
});
|
||||
|
||||
router.on(5, 3, [model, logfn](const s2::Message& msg) {
|
||||
auto req = gem::parse_s5f3(msg);
|
||||
auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0)
|
||||
|
||||
Reference in New Issue
Block a user