feat(daemon): Phase E — production hardening, complete
tests / build-and-test (push) Successful in 2m59s
tests / thread-sanitizer (push) Successful in 3m28s
tests / tshark-dissector (push) Successful in 2m22s
tests / secs4j-interop (push) Successful in 2m6s
tests / python-interop (push) Failing after 3m8s
tests / libfuzzer (push) Successful in 3m44s
tests / build-and-test (push) Successful in 2m59s
tests / thread-sanitizer (push) Successful in 3m28s
tests / tshark-dissector (push) Successful in 2m22s
tests / secs4j-interop (push) Successful in 2m6s
tests / python-interop (push) Failing after 3m8s
tests / libfuzzer (push) Successful in 3m44s
Exposure: --grpc default flipped from 0.0.0.0 to 127.0.0.1 (the API is unauthenticated by design; auth belongs to the transport), Unix-domain- socket support (--grpc unix:///run/secs_gemd/api.sock = zero network surface), SECURITY.md documents the contract and ch42 gained a "Running it in production" section (which also documents the HSMS-SS single-session assumption). Graceful shutdown: SIGTERM/SIGINT land on an asio::signal_set on the io thread, which nudges grpc Shutdown with a 2s deadline (cancels open Subscribe/WatchHealth streams); Wait() returns on the MAIN thread, which stops the engine (rt->stop() joins the io thread, so it must not run on it). Exit 0, journal-safe, the in-code TODO is gone. --spool-dir added so host-bound events survive daemon restarts. Observability: --metrics serves Prometheus gauges secsgem_link_selected / secsgem_control_state / secsgem_spool_depth, wired via the Phase-0 add_link_observer/add_control_state_observer hooks + io-thread sampling. Deployment: deploy/secs_gemd.service — hardened systemd unit (DynamicUser, ProtectSystem=strict, StateDirectory for the spool, UDS for the API, TimeoutStopSec aligned with the graceful-shutdown window). Enforcement: tools/check_daemon_ops.sh proves all three operational claims (unix-socket gRPC accepts, all gauges present on /metrics, SIGTERM -> exit 0 + clean-stop log) — green; wired into tools/run_interop.sh (now 11 steps) and CI. CI python-interop lane also gained the pyclient and spool-restart steps, so every harness now runs in CI. TODO sweep: the shutdown TODO is fixed; the four remaining TODOs (nested list formats, C2-as-text, U8>2^63, CONNECTED link state) are deliberate deferred edge cases, each marked in code with context. Daemon suite re-verified green (175 assertions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+88
-13
@@ -1,17 +1,30 @@
|
||||
// secs_gemd — the SECS/GEM daemon.
|
||||
//
|
||||
// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background
|
||||
// thread, owning the HSMS link to the host, and exposes a small name-based gRPC
|
||||
// thread, owning the HSMS link to the host, and exposes the name-based gRPC
|
||||
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
|
||||
// — can drive the equipment without linking C++ or knowing SEMI.
|
||||
//
|
||||
// Increment 1: the universal "report state to the host" RPCs (SetVariables,
|
||||
// FireEvent) plus control-state visibility. Alarms, GetVariables, and the
|
||||
// host->tool Subscribe command stream follow (see docs/DAEMON_ROADMAP.md).
|
||||
// Operations (Phase E):
|
||||
// --grpc defaults to 127.0.0.1:50051 — NEVER expose the unauthenticated
|
||||
// API on the equipment LAN. For same-host tool software prefer a
|
||||
// Unix domain socket: --grpc unix:///run/secs_gemd.sock
|
||||
// --metrics Prometheus endpoint port (0 = disabled). Gauges: HSMS link
|
||||
// state, control state, spool depth.
|
||||
// SIGTERM/SIGINT: graceful shutdown — gRPC server drains (open streams are
|
||||
// cancelled), then the engine stops cleanly, so a supervised
|
||||
// restart (systemd / docker stop) never kills the spool journal
|
||||
// mid-write. Exit code 0.
|
||||
//
|
||||
// Deployment recipe: deploy/secs_gemd.service (systemd) and docs/42.
|
||||
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
||||
#include <asio.hpp>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -19,8 +32,11 @@
|
||||
#include "secsgem/daemon/equipment_service.hpp"
|
||||
#include "secsgem/gem/default_handlers.hpp"
|
||||
#include "secsgem/gem/runtime.hpp"
|
||||
#include "secsgem/metrics/prometheus.hpp"
|
||||
|
||||
namespace gem = secsgem::gem;
|
||||
namespace metrics = secsgem::metrics;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
|
||||
@@ -31,9 +47,12 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const std::string hsms_port = arg(argc, argv, "--port", "5000");
|
||||
const std::string grpc_addr = arg(argc, argv, "--grpc", "0.0.0.0:50051");
|
||||
const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
|
||||
const std::string hsms_port = arg(argc, argv, "--port", "5000");
|
||||
const std::string grpc_addr = arg(argc, argv, "--grpc", "127.0.0.1:50051");
|
||||
const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
|
||||
const std::string spool_dir = arg(argc, argv, "--spool-dir", "");
|
||||
const uint16_t metrics_port =
|
||||
static_cast<uint16_t>(std::stoi(arg(argc, argv, "--metrics", "0")));
|
||||
|
||||
gem::EquipmentRuntime::Config cfg;
|
||||
cfg.equipment_yaml = cfgdir + "/equipment.yaml";
|
||||
@@ -41,6 +60,7 @@ int main(int argc, char** argv) {
|
||||
cfg.process_job_yaml = cfgdir + "/process_job_state.yaml";
|
||||
cfg.control_job_yaml = cfgdir + "/control_job_state.yaml";
|
||||
cfg.port = static_cast<uint16_t>(std::stoi(hsms_port));
|
||||
cfg.spool_dir = spool_dir;
|
||||
cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; };
|
||||
|
||||
std::unique_ptr<gem::EquipmentRuntime> rt;
|
||||
@@ -52,9 +72,50 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
gem::register_default_handlers(*rt);
|
||||
|
||||
// ---- observability (before run_async: observers must land first) -------
|
||||
std::shared_ptr<metrics::Registry> registry;
|
||||
std::shared_ptr<metrics::PrometheusServer> exporter;
|
||||
std::shared_ptr<asio::steady_timer> gauge_timer;
|
||||
if (metrics_port != 0) {
|
||||
registry = std::make_shared<metrics::Registry>();
|
||||
registry->describe("secsgem_link_selected",
|
||||
"1 when an HSMS session is SELECTED, else 0",
|
||||
metrics::MetricType::Gauge);
|
||||
registry->describe("secsgem_control_state",
|
||||
"E30 control state (0=EquipOffline 1=AttemptOnline "
|
||||
"2=HostOffline 3=OnlineLocal 4=OnlineRemote)",
|
||||
metrics::MetricType::Gauge);
|
||||
registry->describe("secsgem_spool_depth", "Queued spool messages",
|
||||
metrics::MetricType::Gauge);
|
||||
rt->add_link_observer([registry](bool selected) {
|
||||
registry->set_gauge("secsgem_link_selected", selected ? 1.0 : 0.0);
|
||||
});
|
||||
rt->add_control_state_observer(
|
||||
[registry](gem::ControlState, gem::ControlState to, gem::ControlEvent) {
|
||||
registry->set_gauge("secsgem_control_state", static_cast<double>(to));
|
||||
});
|
||||
registry->set_gauge("secsgem_link_selected", 0.0);
|
||||
registry->set_gauge("secsgem_control_state",
|
||||
static_cast<double>(rt->control_state()));
|
||||
// Spool depth: sampled on the io thread (the model's owner).
|
||||
gauge_timer = std::make_shared<asio::steady_timer>(rt->io());
|
||||
auto tick = std::make_shared<std::function<void(std::error_code)>>();
|
||||
*tick = [registry, gauge_timer, tick, raw = rt.get()](std::error_code ec) {
|
||||
if (ec) return;
|
||||
registry->set_gauge("secsgem_spool_depth",
|
||||
static_cast<double>(raw->model().spool.size()));
|
||||
gauge_timer->expires_after(5s);
|
||||
gauge_timer->async_wait(*tick);
|
||||
};
|
||||
gauge_timer->expires_after(1s);
|
||||
gauge_timer->async_wait(*tick);
|
||||
exporter = std::make_shared<metrics::PrometheusServer>(
|
||||
rt->io(), metrics_port, registry);
|
||||
exporter->start();
|
||||
}
|
||||
|
||||
// Construct the service before starting the io thread: its constructor
|
||||
// snapshots the name->id/format maps from the model (see the service's
|
||||
// threading note).
|
||||
// snapshots the name->id/format maps and registers its observers.
|
||||
secsgem::daemon::EquipmentService service(*rt);
|
||||
rt->run_async(); // engine + HSMS link on a background thread
|
||||
|
||||
@@ -66,12 +127,26 @@ int main(int argc, char** argv) {
|
||||
std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Graceful shutdown: the signal lands on the io thread, which only nudges
|
||||
// the gRPC server; Wait() then returns on the MAIN thread, which tears the
|
||||
// engine down (rt->stop() joins the io thread — it must not run on it).
|
||||
// The deadline cancels open Subscribe/WatchHealth streams instead of
|
||||
// waiting for clients to hang up.
|
||||
asio::signal_set signals(rt->io(), SIGINT, SIGTERM);
|
||||
signals.async_wait([&server, &rt](std::error_code ec, int signo) {
|
||||
if (ec) return;
|
||||
rt->log("signal " + std::to_string(signo) + ": shutting down");
|
||||
server->Shutdown(std::chrono::system_clock::now() + 2s);
|
||||
});
|
||||
|
||||
std::cout << "[gemd] gRPC on " << grpc_addr
|
||||
<< (metrics_port ? "; metrics on :" + std::to_string(metrics_port) +
|
||||
"/metrics"
|
||||
: "")
|
||||
<< "; HSMS equipment on :" << hsms_port << std::endl;
|
||||
// TODO(daemon): graceful shutdown — handle SIGTERM/SIGINT by calling
|
||||
// server->Shutdown() and rt->stop() so in-flight RPCs drain and the spool
|
||||
// journal flushes, instead of dying mid-write when supervised (systemd/
|
||||
// docker stop). See DAEMON_ROADMAP Phase E.
|
||||
server->Wait();
|
||||
rt->stop();
|
||||
std::cout << "[gemd] stopped cleanly" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user