Files
raphael 8a48ffeed4 feat(gem): multi-observer state-change handlers via HandlerSlot
The single-slot set_*_handler pattern was a structural blocker, hit twice:
the daemon could not observe control-state changes because
register_default_handlers owns the slot, forcing GetControlState to read the
FSM cross-thread (a data race), and blocking WatchHealth and the Subscribe
stream's ControlStateChange variant.

HandlerSlot<Args...> keeps a primary slot with exact legacy semantics
(set_ replaces — one existing test depends on replacement) plus an
append-only observer list (add_) that survives set_ calls. Fire sites are
textually unchanged (operator bool / operator() / assign-from-function).

Applied to ControlStateMachine + ProcessJobStore + ControlJobStore (the
roadmap-critical three; the remaining single-slot classes follow the same
3-line pattern as needed). EquipmentRuntime gains an atomic control-state
mirror registered as an observer — control_state() is now safe from any
thread, retiring the GetControlState race — plus add_control_state_observer
and add_link_observer (selected/closed fan-out), the hooks WatchHealth and
Subscribe need.

Tests: observer ordering, set-replaces-primary-but-observers-survive,
observers-without-primary, PJ-store coexistence, and the runtime scenario
that was previously impossible (mirror + observer + default-handlers set_).
Core 464/464 (2816 assertions), daemon 16/16, live GEM300 demo passes with
single-fire control-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:57:53 +02:00

52 lines
1.7 KiB
C++

#pragma once
#include <functional>
#include <utility>
#include <vector>
namespace secsgem::gem {
// Replaces the single-slot `std::function on_change_` pattern with a primary
// slot plus an append-only observer list, so multiple parties can watch one
// state machine without fighting over a single handler.
//
// - `slot = fn;` / set_*_handler(fn) -> sets/replaces the PRIMARY handler.
// Exactly the legacy single-slot semantics: callers that always owned the
// slot (register_default_handlers, apps, tests) keep working unchanged,
// including replacement.
// - `slot.add(fn)` / add_*_handler(fn) -> appends an OBSERVER. Observers are
// immune to later set_ calls — this is what lets the runtime keep an
// atomic control-state mirror (and later: WatchHealth, the Subscribe
// stream) while default_handlers still owns the primary slot.
//
// Invocation order: primary first, then observers in registration order.
// Copyable (fire sites that snapshot the handler before invoking keep
// working). Not thread-safe: register on the owning thread before the
// io_context runs, fire on the io thread — same contract as before.
template <typename... Args>
class HandlerSlot {
public:
using Fn = std::function<void(Args...)>;
HandlerSlot& operator=(Fn f) {
primary_ = std::move(f);
return *this;
}
void add(Fn f) { observers_.push_back(std::move(f)); }
void operator()(Args... args) const {
if (primary_) primary_(args...);
for (const auto& o : observers_)
if (o) o(args...);
}
explicit operator bool() const { return static_cast<bool>(primary_) || !observers_.empty(); }
private:
Fn primary_;
std::vector<Fn> observers_;
};
} // namespace secsgem::gem