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
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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user