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:
@@ -151,3 +151,56 @@ TEST_CASE("custom table: a row that only sets ack, no transition") {
|
||||
CHECK(sm.on_host_request_online() == OnlineAck::NotAccept);
|
||||
CHECK(sm.state() == ControlState::HostOffline);
|
||||
}
|
||||
|
||||
// ---- multi-observer (HandlerSlot) ------------------------------------------
|
||||
|
||||
TEST_CASE("add_state_change_handler observers fire alongside the primary, in order") {
|
||||
ControlStateMachine sm;
|
||||
std::vector<int> order;
|
||||
Recorder primary, obs1, obs2;
|
||||
|
||||
sm.set_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
||||
order.push_back(0);
|
||||
primary.handler()(f, t, e);
|
||||
});
|
||||
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
||||
order.push_back(1);
|
||||
obs1.handler()(f, t, e);
|
||||
});
|
||||
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
|
||||
order.push_back(2);
|
||||
obs2.handler()(f, t, e);
|
||||
});
|
||||
|
||||
CHECK(sm.on_host_request_online() == OnlineAck::Accept); // 2 transitions
|
||||
|
||||
// All three saw both transitions, primary before observers each time.
|
||||
CHECK(primary.changes.size() == 2);
|
||||
CHECK(obs1.changes.size() == 2);
|
||||
CHECK(obs2.changes.size() == 2);
|
||||
REQUIRE(order.size() == 6);
|
||||
CHECK(order == std::vector<int>{0, 1, 2, 0, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("set_state_change_handler replaces the primary but observers survive") {
|
||||
ControlStateMachine sm;
|
||||
Recorder old_primary, new_primary, observer;
|
||||
|
||||
sm.set_state_change_handler(old_primary.handler());
|
||||
sm.add_state_change_handler(observer.handler());
|
||||
sm.set_state_change_handler(new_primary.handler()); // replaces old_primary ONLY
|
||||
|
||||
sm.on_host_request_online();
|
||||
|
||||
CHECK(old_primary.changes.empty()); // replaced
|
||||
CHECK(new_primary.changes.size() == 2); // active primary
|
||||
CHECK(observer.changes.size() == 2); // survived the re-set
|
||||
}
|
||||
|
||||
TEST_CASE("observers work with no primary handler installed") {
|
||||
ControlStateMachine sm;
|
||||
Recorder observer;
|
||||
sm.add_state_change_handler(observer.handler());
|
||||
sm.on_host_request_online();
|
||||
CHECK(observer.changes.size() == 2);
|
||||
}
|
||||
|
||||
@@ -221,3 +221,19 @@ TEST_CASE("Store: set_alert toggles per-PJ alert flag") {
|
||||
CHECK(store.get("PJ-1")->alert_enabled == false);
|
||||
CHECK_FALSE(store.set_alert("ghost", false));
|
||||
}
|
||||
|
||||
TEST_CASE("ProcessJobStore: add_state_change_handler observer coexists with primary") {
|
||||
ProcessJobStore store;
|
||||
int primary_calls = 0, observer_calls = 0;
|
||||
store.set_state_change_handler(
|
||||
[&](const std::string&, ProcessJobState, ProcessJobState,
|
||||
ProcessJobEvent) { ++primary_calls; });
|
||||
store.add_state_change_handler(
|
||||
[&](const std::string&, ProcessJobState, ProcessJobState,
|
||||
ProcessJobEvent) { ++observer_calls; });
|
||||
|
||||
CHECK(store.create("PJ-OBS", "RECIPE-A", {"lot1"}) ==
|
||||
ProcessJobStore::CreateResult::Created); // NoState -> Queued fires both
|
||||
CHECK(primary_calls == 1);
|
||||
CHECK(observer_calls == 1);
|
||||
}
|
||||
|
||||
@@ -64,3 +64,26 @@ TEST_CASE("EquipmentRuntime.on_command registers the behaviour hook on the model
|
||||
CHECK(ran);
|
||||
CHECK(res.ack == gem::HostCmdAck::Accept);
|
||||
}
|
||||
|
||||
TEST_CASE("EquipmentRuntime control-state mirror tracks transitions; observers coexist with default handlers") {
|
||||
gem::EquipmentRuntime rt(test_config());
|
||||
|
||||
int observed = 0;
|
||||
rt.add_control_state_observer(
|
||||
[&](gem::ControlState, gem::ControlState, gem::ControlEvent) { ++observed; });
|
||||
|
||||
// Simulate what register_default_handlers does: claim the PRIMARY slot
|
||||
// after the runtime (mirror) and the observer are already registered.
|
||||
rt.control().set_state_change_handler(
|
||||
[](gem::ControlState, gem::ControlState, gem::ControlEvent) {});
|
||||
|
||||
CHECK(rt.control_state() == gem::ControlState::HostOffline); // mirror initial
|
||||
|
||||
rt.control().on_host_request_online(); // HostOffline -> ... -> OnlineRemote
|
||||
|
||||
// The atomic mirror followed the FSM, and the added observer survived the
|
||||
// primary set_ call — the exact scenario that used to be impossible.
|
||||
CHECK(rt.control_state() == gem::ControlState::OnlineRemote);
|
||||
CHECK(rt.control_state() == rt.control().state());
|
||||
CHECK(observed == 2); // two chained transitions
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user