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>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user