Files
raphael 9876dd9b5a
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
feat(daemon): D10 carriers + E16 ops RPCs + stress test + virtual fab
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>
2026-06-26 15:05:13 +02:00

539 lines
20 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include "secsgem/gem/handler_slot.hpp"
#include "secsgem/gem/carrier_state.hpp"
#include "secsgem/gem/load_port_state.hpp"
namespace secsgem::gem {
// One slot in a carrier slot map. E87-0716 slot states: Empty (0),
// NotEmpty (1), Undefined (3) — we keep the wire byte directly rather
// than wrapping it in an enum, since the application may carry tool-
// specific extensions.
struct CarrierSlot {
uint8_t state = 3; // undefined by default
};
struct Carrier {
std::string carrierid;
uint8_t port_id = 0; // 0 = unbound; ports are 1-based
std::vector<CarrierSlot> slots; // length == capacity (typ. 25)
std::unique_ptr<CarrierStateMachine> fsm;
std::filesystem::path journal_path; // empty when persistence disabled
};
struct LoadPort {
uint8_t port_id;
std::string carrierid; // empty when nothing associated
std::unique_ptr<LoadPortStateMachine> fsm;
std::filesystem::path journal_path; // empty when persistence disabled
};
// Both stores are non-movable for the same reason ProcessJobStore is —
// per-row FSMs close over `this` via their change-handler lambdas.
class CarrierStore {
public:
using IDChangeHandler =
std::function<void(const std::string& carrierid, CarrierIDStatus from,
CarrierIDStatus to, CarrierIDEvent)>;
using SlotMapChangeHandler =
std::function<void(const std::string& carrierid, SlotMapStatus from,
SlotMapStatus to, SlotMapEvent)>;
using AccessChangeHandler =
std::function<void(const std::string& carrierid, CarrierAccessStatus from,
CarrierAccessStatus to, CarrierAccessEvent)>;
CarrierStore() = default;
CarrierStore(const CarrierStore&) = delete;
CarrierStore& operator=(const CarrierStore&) = delete;
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 };
CreateResult create(std::string carrierid, uint8_t port_id = 0,
std::size_t capacity = 25) {
if (carriers_.count(carrierid)) return CreateResult::Denied_AlreadyExists;
install_(carrierid, port_id, std::vector<CarrierSlot>(capacity),
/*restore_id=*/std::nullopt, std::nullopt, std::nullopt);
if (persistent_) write_record_(carrierid);
return CreateResult::Created;
}
bool has(const std::string& cid) const { return carriers_.count(cid) > 0; }
const Carrier* get(const std::string& cid) const {
auto it = carriers_.find(cid);
return it == carriers_.end() ? nullptr : &it->second;
}
Carrier* get(const std::string& cid) {
auto it = carriers_.find(cid);
return it == carriers_.end() ? nullptr : &it->second;
}
bool fire_id_event(const std::string& cid, CarrierIDEvent e) {
auto* c = get(cid);
if (!(c && c->fsm->on_id_event(e))) return false;
if (persistent_) write_record_(cid);
return true;
}
bool fire_slot_map_event(const std::string& cid, SlotMapEvent e) {
auto* c = get(cid);
if (!(c && c->fsm->on_slot_map_event(e))) return false;
if (persistent_) write_record_(cid);
return true;
}
bool fire_access_event(const std::string& cid, CarrierAccessEvent e) {
auto* c = get(cid);
if (!(c && c->fsm->on_access_event(e))) return false;
if (persistent_) write_record_(cid);
return true;
}
// Application sets slots[i].state directly; persistence sync is opt-in
// through this helper so the journal records the new slot map.
void set_slot_state(const std::string& cid, std::size_t slot_idx,
uint8_t state) {
auto* c = get(cid);
if (!c || slot_idx >= c->slots.size()) return;
c->slots[slot_idx].state = state;
if (persistent_) write_record_(cid);
}
bool remove(const std::string& cid) {
auto it = carriers_.find(cid);
if (it == carriers_.end()) return false;
if (persistent_ && !it->second.journal_path.empty()) {
std::error_code ec;
std::filesystem::remove(it->second.journal_path, ec);
}
carriers_.erase(it);
return true;
}
std::size_t size() const { return carriers_.size(); }
std::vector<std::string> ids() const {
std::vector<std::string> out;
out.reserve(carriers_.size());
for (const auto& kv : carriers_) out.push_back(kv.first);
return out;
}
// ---- Persistence ----------------------------------------------------
// Mirrors SpoolStore::enable_persistence: scan `dir` for *.car records,
// replay them into the in-memory map, then keep the directory in sync
// on subsequent create / fire_* / remove calls. Atomic writes via
// .tmp + rename. Records that fail magic/version are silently dropped
// so a single bad file can't poison the whole store.
void enable_persistence(std::filesystem::path dir) {
namespace fs = std::filesystem;
journal_dir_ = std::move(dir);
persistent_ = true;
std::error_code ec;
fs::create_directories(journal_dir_, ec);
std::vector<std::pair<uint64_t, fs::path>> existing;
for (auto& entry : fs::directory_iterator(journal_dir_, ec)) {
if (!entry.is_regular_file()) continue;
const auto& p = entry.path();
if (p.extension() != ".car") continue;
uint64_t seq = 0;
try { seq = std::stoull(p.stem().string()); }
catch (...) { continue; }
existing.emplace_back(seq, p);
}
std::sort(existing.begin(), existing.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& [seq, path] : existing) {
auto rec = load_record_(path);
if (!rec) { fs::remove(path, ec); continue; }
install_(rec->carrierid, rec->port_id, std::move(rec->slots),
rec->id_state, rec->sm_state, rec->acc_state);
auto* c = get(rec->carrierid);
if (c) c->journal_path = path;
if (seq >= next_seq_) next_seq_ = seq + 1;
}
}
bool persistence_enabled() const { return persistent_; }
std::filesystem::path journal_dir() const { return journal_dir_; }
private:
struct Record {
std::string carrierid;
uint8_t port_id;
std::vector<CarrierSlot> slots;
CarrierIDStatus id_state;
SlotMapStatus sm_state;
CarrierAccessStatus acc_state;
};
// Carrier record (v1), big-endian throughout:
// [u8 magic = 0xC4][u8 version]
// [u8 id_state][u8 sm_state][u8 acc_state][u8 port_id]
// [u16 slot_count][slot_count × u8 slot.state]
// [u16 cid_len][cid_len × u8 carrierid]
//
// Upgrade discipline (mirrors PJ/Substrate): loader accepts any
// version in [1, kVersion]; future field additions go at the end
// and are gated behind `if (version >= N)` blocks.
static constexpr uint8_t kMagic = 0xC4;
static constexpr uint8_t kVersion = 0x01;
void install_(const std::string& carrierid, uint8_t port_id,
std::vector<CarrierSlot> slots,
std::optional<CarrierIDStatus> id_state,
std::optional<SlotMapStatus> sm_state,
std::optional<CarrierAccessStatus> acc_state) {
auto fsm = std::make_unique<CarrierStateMachine>();
if (id_state || sm_state || acc_state) {
fsm->restore_state(id_state.value_or(CarrierIDStatus::NotConfirmed),
sm_state.value_or(SlotMapStatus::NotRead),
acc_state.value_or(CarrierAccessStatus::NotAccessed));
}
const std::string id = carrierid;
fsm->set_id_handler([this, id](CarrierIDStatus f, CarrierIDStatus t,
CarrierIDEvent e) {
if (on_id_) on_id_(id, f, t, e);
});
fsm->set_slot_map_handler([this, id](SlotMapStatus f, SlotMapStatus t,
SlotMapEvent e) {
if (on_sm_) on_sm_(id, f, t, e);
});
fsm->set_access_handler([this, id](CarrierAccessStatus f, CarrierAccessStatus t,
CarrierAccessEvent e) {
if (on_acc_) on_acc_(id, f, t, e);
});
carriers_.emplace(carrierid, Carrier{carrierid, port_id, std::move(slots),
std::move(fsm), {}});
}
void write_record_(const std::string& carrierid) {
namespace fs = std::filesystem;
auto* c = get(carrierid);
if (!c) return;
fs::path target = c->journal_path;
if (target.empty()) {
char namebuf[32];
std::snprintf(namebuf, sizeof(namebuf), "%010llu.car",
static_cast<unsigned long long>(next_seq_++));
target = journal_dir_ / namebuf;
c->journal_path = target;
}
fs::path tmp = target;
tmp += ".tmp";
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return;
uint8_t header[6] = {
kMagic, kVersion,
static_cast<uint8_t>(c->fsm->id_status()),
static_cast<uint8_t>(c->fsm->slot_map_status()),
static_cast<uint8_t>(c->fsm->access_status()),
c->port_id};
out.write(reinterpret_cast<const char*>(header), sizeof(header));
auto write_be16 = [&](uint16_t v) {
uint8_t b[2] = {static_cast<uint8_t>(v >> 8), static_cast<uint8_t>(v)};
out.write(reinterpret_cast<const char*>(b), 2);
};
write_be16(static_cast<uint16_t>(c->slots.size()));
for (const auto& s : c->slots) {
out.put(static_cast<char>(s.state));
}
write_be16(static_cast<uint16_t>(c->carrierid.size()));
out.write(c->carrierid.data(),
static_cast<std::streamsize>(c->carrierid.size()));
out.flush();
out.close();
std::error_code ec;
fs::rename(tmp, target, ec);
if (ec) fs::remove(tmp, ec);
}
static std::optional<Record> load_record_(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return std::nullopt;
uint8_t header[6];
in.read(reinterpret_cast<char*>(header), sizeof(header));
if (!in || header[0] != kMagic ||
header[1] < 1 || header[1] > kVersion) return std::nullopt;
Record r;
r.id_state = static_cast<CarrierIDStatus>(header[2]);
r.sm_state = static_cast<SlotMapStatus>(header[3]);
r.acc_state = static_cast<CarrierAccessStatus>(header[4]);
r.port_id = header[5];
auto read_be16 = [&](uint16_t& v) {
uint8_t b[2];
in.read(reinterpret_cast<char*>(b), 2);
if (!in) return false;
v = static_cast<uint16_t>((uint16_t(b[0]) << 8) | b[1]);
return true;
};
uint16_t slot_count = 0;
if (!read_be16(slot_count)) return std::nullopt;
r.slots.resize(slot_count);
for (auto& s : r.slots) {
int c = in.get();
if (c == std::char_traits<char>::eof()) return std::nullopt;
s.state = static_cast<uint8_t>(c);
}
uint16_t cid_len = 0;
if (!read_be16(cid_len)) return std::nullopt;
r.carrierid.resize(cid_len);
in.read(r.carrierid.data(), cid_len);
if (!in) return std::nullopt;
return r;
}
std::map<std::string, Carrier> carriers_;
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;
};
class LoadPortStore {
public:
using TransferChangeHandler =
std::function<void(uint8_t portid, LoadPortTransferState from,
LoadPortTransferState to, LoadPortTransferEvent)>;
using ReservationChangeHandler =
std::function<void(uint8_t portid, LoadPortReservationStatus from,
LoadPortReservationStatus to, LoadPortReservationEvent)>;
using AssociationChangeHandler =
std::function<void(uint8_t portid, LoadPortAssociationStatus from,
LoadPortAssociationStatus to, LoadPortAssociationEvent)>;
LoadPortStore() = default;
LoadPortStore(const LoadPortStore&) = delete;
LoadPortStore& operator=(const LoadPortStore&) = delete;
LoadPortStore(LoadPortStore&&) = delete;
LoadPortStore& operator=(LoadPortStore&&) = delete;
void set_transfer_handler(TransferChangeHandler h) { on_tx_ = std::move(h); }
void set_reservation_handler(ReservationChangeHandler h) { on_rs_ = std::move(h); }
void set_association_handler(AssociationChangeHandler h) { on_as_ = std::move(h); }
// Idempotent: re-create a port_id is a no-op.
bool create(uint8_t port_id) {
if (ports_.count(port_id)) return false;
install_(port_id, "", std::nullopt, std::nullopt, std::nullopt);
if (persistent_) write_record_(port_id);
return true;
}
bool has(uint8_t pid) const { return ports_.count(pid) > 0; }
const LoadPort* get(uint8_t pid) const {
auto it = ports_.find(pid);
return it == ports_.end() ? nullptr : &it->second;
}
LoadPort* get(uint8_t pid) {
auto it = ports_.find(pid);
return it == ports_.end() ? nullptr : &it->second;
}
bool associate(uint8_t pid, std::string carrierid) {
auto* p = get(pid);
if (!p) return false;
p->carrierid = std::move(carrierid);
bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Associate);
if (persistent_) write_record_(pid);
return ok;
}
bool disassociate(uint8_t pid) {
auto* p = get(pid);
if (!p) return false;
p->carrierid.clear();
bool ok = p->fsm->on_association_event(LoadPortAssociationEvent::Disassociate);
if (persistent_) write_record_(pid);
return ok;
}
bool fire_transfer_event(uint8_t pid, LoadPortTransferEvent e) {
auto* p = get(pid);
if (!(p && p->fsm->on_transfer_event(e))) return false;
if (persistent_) write_record_(pid);
return true;
}
bool fire_reservation_event(uint8_t pid, LoadPortReservationEvent e) {
auto* p = get(pid);
if (!(p && p->fsm->on_reservation_event(e))) return false;
if (persistent_) write_record_(pid);
return true;
}
std::size_t size() const { return ports_.size(); }
std::vector<uint8_t> ids() const {
std::vector<uint8_t> out;
out.reserve(ports_.size());
for (const auto& kv : ports_) out.push_back(kv.first);
return out;
}
// ---- Persistence ----------------------------------------------------
// One file per port: `<port_id 0-padded>.lp`. Port IDs are 1255 so
// we can use the port id directly as the filename stem.
void enable_persistence(std::filesystem::path dir) {
namespace fs = std::filesystem;
journal_dir_ = std::move(dir);
persistent_ = true;
std::error_code ec;
fs::create_directories(journal_dir_, ec);
for (auto& entry : fs::directory_iterator(journal_dir_, ec)) {
if (!entry.is_regular_file()) continue;
const auto& p = entry.path();
if (p.extension() != ".lp") continue;
auto rec = load_record_(p);
if (!rec) { fs::remove(p, ec); continue; }
install_(rec->port_id, rec->carrierid, rec->tx_state, rec->rs_state,
rec->as_state);
auto* lp = get(rec->port_id);
if (lp) lp->journal_path = p;
}
}
bool persistence_enabled() const { return persistent_; }
std::filesystem::path journal_dir() const { return journal_dir_; }
private:
struct Record {
uint8_t port_id;
std::string carrierid;
LoadPortTransferState tx_state;
LoadPortReservationStatus rs_state;
LoadPortAssociationStatus as_state;
};
// Load-port record (v1):
// [u8 magic = 0xC5][u8 version]
// [u8 port_id][u8 tx_state][u8 rs_state][u8 as_state]
// [u16 cid_len][cid_len × u8 carrierid]
//
// Upgrade discipline: loader accepts any version in [1, kVersion];
// future fields append at the end behind an `if (version >= N)` gate.
static constexpr uint8_t kMagic = 0xC5;
static constexpr uint8_t kVersion = 0x01;
void install_(uint8_t port_id, std::string carrierid,
std::optional<LoadPortTransferState> tx,
std::optional<LoadPortReservationStatus> rs,
std::optional<LoadPortAssociationStatus> as) {
auto fsm = std::make_unique<LoadPortStateMachine>();
if (tx || rs || as) {
fsm->restore_state(tx.value_or(LoadPortTransferState::InService),
rs.value_or(LoadPortReservationStatus::NotReserved),
as.value_or(LoadPortAssociationStatus::NotAssociated));
}
const uint8_t pid = port_id;
fsm->set_transfer_handler([this, pid](LoadPortTransferState f,
LoadPortTransferState t,
LoadPortTransferEvent e) {
if (on_tx_) on_tx_(pid, f, t, e);
});
fsm->set_reservation_handler([this, pid](LoadPortReservationStatus f,
LoadPortReservationStatus t,
LoadPortReservationEvent e) {
if (on_rs_) on_rs_(pid, f, t, e);
});
fsm->set_association_handler([this, pid](LoadPortAssociationStatus f,
LoadPortAssociationStatus t,
LoadPortAssociationEvent e) {
if (on_as_) on_as_(pid, f, t, e);
});
ports_.emplace(port_id, LoadPort{port_id, std::move(carrierid),
std::move(fsm), {}});
}
void write_record_(uint8_t pid) {
namespace fs = std::filesystem;
auto* lp = get(pid);
if (!lp) return;
if (lp->journal_path.empty()) {
char namebuf[16];
std::snprintf(namebuf, sizeof(namebuf), "%03u.lp", unsigned(pid));
lp->journal_path = journal_dir_ / namebuf;
}
fs::path tmp = lp->journal_path;
tmp += ".tmp";
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return;
uint8_t header[6] = {
kMagic, kVersion, pid,
static_cast<uint8_t>(lp->fsm->transfer_state()),
static_cast<uint8_t>(lp->fsm->reservation_state()),
static_cast<uint8_t>(lp->fsm->association_state())};
out.write(reinterpret_cast<const char*>(header), sizeof(header));
uint16_t cid_len = static_cast<uint16_t>(lp->carrierid.size());
uint8_t lb[2] = {static_cast<uint8_t>(cid_len >> 8),
static_cast<uint8_t>(cid_len)};
out.write(reinterpret_cast<const char*>(lb), 2);
out.write(lp->carrierid.data(),
static_cast<std::streamsize>(lp->carrierid.size()));
out.flush();
out.close();
std::error_code ec;
fs::rename(tmp, lp->journal_path, ec);
if (ec) fs::remove(tmp, ec);
}
static std::optional<Record> load_record_(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return std::nullopt;
uint8_t header[6];
in.read(reinterpret_cast<char*>(header), sizeof(header));
if (!in || header[0] != kMagic ||
header[1] < 1 || header[1] > kVersion) return std::nullopt;
Record r;
r.port_id = header[2];
r.tx_state = static_cast<LoadPortTransferState>(header[3]);
r.rs_state = static_cast<LoadPortReservationStatus>(header[4]);
r.as_state = static_cast<LoadPortAssociationStatus>(header[5]);
uint8_t lb[2];
in.read(reinterpret_cast<char*>(lb), 2);
if (!in) return std::nullopt;
uint16_t cid_len = static_cast<uint16_t>((uint16_t(lb[0]) << 8) | lb[1]);
r.carrierid.resize(cid_len);
in.read(r.carrierid.data(), cid_len);
if (cid_len && !in) return std::nullopt;
return r;
}
std::map<uint8_t, LoadPort> ports_;
TransferChangeHandler on_tx_;
ReservationChangeHandler on_rs_;
AssociationChangeHandler on_as_;
bool persistent_ = false;
std::filesystem::path journal_dir_;
};
} // namespace secsgem::gem