feat(daemon): D10 carriers + E16 ops RPCs + stress test + virtual fab
tests / build-and-test (push) Successful in 2m59s
tests / thread-sanitizer (push) Successful in 3m36s
tests / tshark-dissector (push) Successful in 2m25s
tests / secs4j-interop (push) Successful in 59s
tests / python-interop (push) Successful in 3m20s
tests / libfuzzer (push) Successful in 3m40s

Completes the daemon's GEM300 surface and adds two new test tiers.

D10 — E87 carriers: CarrierStore gains the HandlerSlot observer pattern
(add_id/slot_map/access_handler). The daemon's id-observer forwards host
S3F17 decisions onto the Subscribe stream as CarrierAction (PROCEED on a
Confirmed transition, CANCEL on CancelCarrier); ReportCarrier drives the
flow tool-side: WAITING creates the carrier + records the slot map,
IN_ACCESS/COMPLETE advance the access FSM (INVALID_OBJECT on unknown,
CANNOT_DO_NOW on an illegal transition).

E16 — operations RPCs: Describe (full name inventory: variables/events/
alarms/commands/constants + device header), FlushSpool (purge or drain),
SendTerminalMessage (S10F1 tool->host, honest CANNOT_DO_NOW when no host
and stream 10 isn't spoolable).

Stream responsiveness: Subscribe/WatchHealth poll at 100ms (was 500ms) so a
cancelled stream frees its sync-server worker thread promptly — this was
found by the new stress test, which hung under Subscribe churn at 500ms.

Tests:
- A randomized concurrent RPC stress case: 4 threads x 250 seeded ops
  (set/get/fire/alarm/control-state/describe + Subscribe churn), asserts no
  failed RPC and a still-responsive engine afterward; prints its seed; a
  strong TSan target.
- A virtual fab (interop/virtual_fab.py + the `fab` compose service /
  tools/spawn_fab.sh): N daemons, each with a secsgem-py host AND a
  secsgem_client tool, driven by seeded random traffic with end-to-end
  invariant checks (set/get round-trips, event->S6F11 and alarm->S5F1
  delivery, command->tool->completion). Verified green at N=3 (~150 ops/eq,
  all commands round-tripped, 0 violations). Wired into run_interop.sh
  (now 13 steps).

Also fixes the CI break from the previous commit: the Python-client lane's
test_values.py step lacked PYTHONPATH=clients/python (now step-level env).

Two bugs found and fixed while building this, both mine from this batch:
1. carrier test hung on a CancelCarrier of a still-NotConfirmed carrier — a
   self-transition the FSM doesn't signal, so the observer never fired and
   the stream Read blocked forever. Fixed to cancel a Confirmed carrier;
   the NotConfirmed edge is documented as a known E87 limitation.
2. the 500ms stream poll above.

Daemon suite 7 cases / 214 assertions; core 475 / 3097; virtual fab green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:05:13 +02:00
parent 54626ceb6a
commit 9876dd9b5a
10 changed files with 703 additions and 13 deletions
+123 -1
View File
@@ -26,6 +26,7 @@
#include <utility>
#include <vector>
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/runtime.hpp"
#include "secsgem/secs2/item.hpp"
#include "secsgem/v1/equipment.grpc.pb.h"
@@ -227,6 +228,33 @@ class EquipmentService final : public pb::Equipment::Service {
hr.mutable_process_program()->set_body(body);
push_request(std::move(hr));
});
for (const auto& rcmd : rt.model().commands.names()) commands_.push_back(rcmd);
descr_model_ = rt.descriptor().model_name;
descr_rev_ = rt.descriptor().software_rev;
// E87 (D10): the host's carrier-ID decisions go to the tool. PROCEED on
// a Confirmed transition (ProceedWithCarrier or Bind), CANCEL on
// CancelCarrier. The tool announces carriers + drives access via
// ReportCarrier.
rt.model().carriers.add_id_handler(
[this](const std::string& cid, gem::CarrierIDStatus,
gem::CarrierIDStatus to, gem::CarrierIDEvent ev) {
pb::CarrierAction::Action action;
if (ev == gem::CarrierIDEvent::CancelCarrier)
action = pb::CarrierAction::CANCEL;
else if (to == gem::CarrierIDStatus::Confirmed)
action = pb::CarrierAction::PROCEED;
else
return;
pb::HostRequest hr;
auto* ca = hr.mutable_carrier();
ca->set_carrier_id(cid);
if (const auto* c = rt_.model().carriers.get(cid))
ca->set_port(c->port_id);
ca->set_action(action);
push_request(std::move(hr));
});
rt.model().ecids.add_changed_handler(
[this](uint32_t ecid, const s2::Item& value) {
pb::HostRequest hr;
@@ -374,11 +402,14 @@ class EquipmentService final : public pb::Equipment::Service {
rt_.log("tool client subscribed" +
(req->client().empty() ? "" : " (" + req->client() + ")"));
bool alive = true;
// Poll at 100ms: with the sync server one thread is parked here per open
// stream, so a cancelled client must free its worker promptly (gRPC's
// sync API only exposes cancellation via polled IsCancelled()).
while (alive && !ctx->IsCancelled()) {
std::optional<pb::HostRequest> next;
{
std::unique_lock<std::mutex> lk(subs_mu_);
subs_cv_.wait_for(lk, std::chrono::milliseconds(500),
subs_cv_.wait_for(lk, std::chrono::milliseconds(100),
[&] { return !sub->queue.empty(); });
if (!sub->queue.empty()) {
next = std::move(sub->queue.front());
@@ -452,6 +483,95 @@ class EquipmentService final : public pb::Equipment::Service {
return grpc::Status::OK;
}
// E87 carrier reporting (observe-and-report): WAITING announces an
// arrived carrier (idempotent; updates the slot map), IN_ACCESS/COMPLETE
// drive the access FSM. See the proto for the contract.
grpc::Status ReportCarrier(grpc::ServerContext*, const pb::CarrierState* req,
pb::Ack* resp) override {
const std::string cid = req->carrier_id();
const auto port = static_cast<uint8_t>(req->port());
const auto state = req->state();
std::vector<bool> slots(req->slots().begin(), req->slots().end());
auto outcome = rt_.read_sync(
[this, cid, port, state, slots]() -> std::optional<bool> {
auto& carriers = rt_.model().carriers;
if (state == pb::CarrierState::WAITING) {
carriers.create(cid, port,
slots.empty() ? 25 : slots.size()); // idempotent
// E87 slot-map bytes: 0 = Empty, 1 = NotEmpty.
for (std::size_t i = 0; i < slots.size(); ++i)
carriers.set_slot_state(cid, i, slots[i] ? 1 : 0);
if (!slots.empty())
carriers.fire_slot_map_event(cid, gem::SlotMapEvent::Read);
return true;
}
if (!carriers.has(cid)) return std::nullopt;
if (state == pb::CarrierState::IN_ACCESS)
return carriers.fire_access_event(cid, gem::CarrierAccessEvent::BeginAccess);
if (state == pb::CarrierState::COMPLETE)
return carriers.fire_access_event(cid, gem::CarrierAccessEvent::EndAccess);
return true;
});
if (!outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer (not running?)");
} else if (!*outcome) {
resp->set_code(pb::Ack::INVALID_OBJECT);
resp->set_message("no carrier '" + cid + "' (announce it with WAITING first)");
} else if (!**outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("E87 access table rejects that transition");
} else {
resp->set_code(pb::Ack::ACCEPT);
}
return grpc::Status::OK;
}
grpc::Status Describe(grpc::ServerContext*, const pb::Empty*,
pb::EquipmentDescription* resp) override {
resp->set_model_name(descr_model_);
resp->set_software_rev(descr_rev_);
for (const auto& [name, _] : vars_) resp->add_variables(name);
for (const auto& [name, _] : events_) resp->add_events(name);
for (const auto& [name, _] : alarms_) resp->add_alarms(name);
for (const auto& rcmd : commands_) resp->add_commands(rcmd);
for (const auto& [_, name] : ecnames_) resp->add_constants(name);
return grpc::Status::OK;
}
grpc::Status FlushSpool(grpc::ServerContext*, const pb::SpoolFlushRequest* req,
pb::Ack* resp) override {
const bool purge = req->purge();
auto done = rt_.read_sync([this, purge]() {
if (purge) rt_.model().spool.clear();
else rt_.model().spool.drain(); // toward the host (see S6F23)
return true;
});
resp->set_code(done ? pb::Ack::ACCEPT : pb::Ack::CANNOT_DO_NOW);
if (!done) resp->set_message("engine io thread did not answer");
return grpc::Status::OK;
}
grpc::Status SendTerminalMessage(grpc::ServerContext*, const pb::TerminalMessage* req,
pb::Ack* resp) override {
const auto tid = static_cast<uint8_t>(req->tid());
const std::string text = req->text();
auto delivered = rt_.read_sync([this, tid, text]() {
return rt_.deliver_or_spool(
gem::s10f1_terminal_display_single(tid, text), "S10F1 (tool)");
});
if (!delivered) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer");
} else if (!*delivered) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("no host connected and stream 10 is not spoolable");
} else {
resp->set_code(pb::Ack::ACCEPT);
}
return grpc::Status::OK;
}
// Operator-panel control-state transitions (e.g. "offline for maintenance").
// Fires the operator events on the io thread and reports what the E30 table
// actually did: ACCEPT iff the equipment landed in the requested state,
@@ -609,6 +729,8 @@ class EquipmentService final : public pb::Equipment::Service {
std::map<std::string, uint32_t> events_; // CEID by name
std::map<std::string, uint32_t> alarms_; // ALID by name AND stringified id
std::map<uint32_t, std::string> ecnames_; // ECID -> name (ConstantChange)
std::vector<std::string> commands_; // RCMDs (Describe)
std::string descr_model_, descr_rev_; // device header (Describe)
// WatchHealth plumbing: observers (io thread) bump the version and wake the
// per-stream wait loops (gRPC threads).
+13 -3
View File
@@ -14,6 +14,7 @@
#include <utility>
#include <vector>
#include "secsgem/gem/handler_slot.hpp"
#include "secsgem/gem/carrier_state.hpp"
#include "secsgem/gem/load_port_state.hpp"
@@ -62,9 +63,15 @@ class CarrierStore {
CarrierStore(CarrierStore&&) = delete;
CarrierStore& operator=(CarrierStore&&) = delete;
// set_ replaces the primary handler (legacy semantics); add_ appends an
// observer that survives set_ calls (see handler_slot.hpp). The gRPC
// daemon observes via add_ to forward E87 actions onto its tool stream.
void set_id_handler(IDChangeHandler h) { on_id_ = std::move(h); }
void set_slot_map_handler(SlotMapChangeHandler h) { on_sm_ = std::move(h); }
void set_access_handler(AccessChangeHandler h) { on_acc_ = std::move(h); }
void add_id_handler(IDChangeHandler h) { on_id_.add(std::move(h)); }
void add_slot_map_handler(SlotMapChangeHandler h) { on_sm_.add(std::move(h)); }
void add_access_handler(AccessChangeHandler h) { on_acc_.add(std::move(h)); }
enum class CreateResult { Created, Denied_AlreadyExists };
@@ -301,9 +308,12 @@ class CarrierStore {
}
std::map<std::string, Carrier> carriers_;
IDChangeHandler on_id_;
SlotMapChangeHandler on_sm_;
AccessChangeHandler on_acc_;
HandlerSlot<const std::string&, CarrierIDStatus, CarrierIDStatus,
CarrierIDEvent> on_id_;
HandlerSlot<const std::string&, SlotMapStatus, SlotMapStatus,
SlotMapEvent> on_sm_;
HandlerSlot<const std::string&, CarrierAccessStatus, CarrierAccessStatus,
CarrierAccessEvent> on_acc_;
bool persistent_ = false;
std::filesystem::path journal_dir_;
uint64_t next_seq_ = 0;