From 072bf142d8f7089d5862eeb3a1074aa3b3ffd8bf Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:39:39 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.10.0=20=E2=80=94=20Watchdog=20&=20Li?= =?UTF-8?q?veness=20Rebuild=20(=C2=A7Phase=2014=20milestone=2054)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces rcp/watchdog.hpp in full, per the Satellite Package Disposition table's entry for this file: the pre-replacement client-driven periodic CommandType::Watchdog kick model built on rcp.hpp's Zone/Command/Controller is discarded, not adapted (that command type has no analog in the target protocol). The actual per-stream watchdog timeout/latch primitive already existed as rcp::e2e::RxWatchdog (v2.6.0); this milestone's job is the driver layer around it: - watchdog::StreamWatchdog::kick_from_request resets a stream's watchdog on every accepted inbound request, regardless of kind or safety tag, matching the "reset by any inbound request" rule directly instead of a separate client-side health poll. - StreamWatchdog::check reads a live regmap::RequestStreamConfig on every poll, reports HealthEvent::overflowed on timeout, and — only when rx_wd_safestate_enable is set — calls rcp::e2e::apply_watchdog_overflow to latch safe state and purge normal requests, reusing the v2.6.0 purge-normal/retain-safety rule unmodified. - watchdog::Manager multiplexes StreamWatchdog across every request stream the embedding application registers (keyed by an opaque uint64_t, e.g. avtp::StreamId::to_u64()) and fans out HealthEvents to subscribed callbacks. Rebuilds rcp/deadline.hpp per the disposition table's ADAPT call: the alive/dead-on-deadline concept survives, but every concrete signal source is rebound away from Status-subscription polling, which does not exist in the target model. deadline::Monitor is driven by two independent liveness signals per the roadmap's "and/or" — note_heartbeat (regmap::ResponseQueueConfig::flush_time, a new field added at this milestone) and note_lifecycle_change (lifecycle::ServerLifecycle's new subscribe_state_changed trigger, also added at this milestone) — either alone being sufficient evidence of liveness. Both additions are small, explicitly-scoped, and purely additive; no pre-existing field or transition rule in regmap.hpp/lifecycle.hpp changes. Nothing else in the tree depended on the old watchdog::Keeper or deadline::Monitor APIs (only their own tests did), so no legacy shim was needed, same as every other Phase 14 rebuild. New/rewritten coverage lives in tests/test_watchdog.cpp (REQ-WDG-001..008, entirely rewritten), tests/test_deadline.cpp (REQ-DL-001..008, entirely rewritten), and tests/test_lifecycle.cpp (REQ-LIFECYCLE-007, new), traced in .fusa-reqs.json. Bumps kVersion/project version to 2.10.0. Updates ROADMAP.md with a Done (v2.10.0) summary for milestone 54. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 72 +++++---- CMakeLists.txt | 2 +- ROADMAP.md | 58 +++++++ include/rcp/deadline.hpp | 255 ++++++++++++++++++------------ include/rcp/lifecycle.hpp | 52 +++++- include/rcp/regmap.hpp | 18 +++ include/rcp/version.hpp | 2 +- include/rcp/watchdog.hpp | 323 +++++++++++++++++++++++--------------- tests/test_deadline.cpp | 181 ++++++++++++++------- tests/test_lifecycle.cpp | 72 ++++++++- tests/test_watchdog.cpp | 312 +++++++++++++++++++++++++++--------- 11 files changed, 953 insertions(+), 394 deletions(-) diff --git a/.fusa-reqs.json b/.fusa-reqs.json index a91ac87..b6e1aea 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -882,128 +882,128 @@ }, { "id": "REQ-WDG-001", - "title": "Keeper kicks all zones periodically", - "text": "watchdog::Keeper shall send a CmdWatchdog command to every registered zone controller at the configured interval.", + "title": "StreamWatchdog reports overflow per rx_wd_enable/rx_wd_timeout_interval", + "text": "rcp::watchdog::StreamWatchdog::check shall report HealthEvent::overflowed only once more than RequestStreamConfig::rx_wd_timeout_interval milliseconds have elapsed since the stream's last kick_from_request call, and only while RequestStreamConfig::rx_wd_enable is set.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-002", - "title": "Zone starts Healthy", - "text": "A zone controller shall be in the Healthy state immediately after Keeper construction.", + "title": "kick_from_request resets the watchdog for any inbound request", + "text": "rcp::watchdog::StreamWatchdog::kick_from_request shall reset that stream's watchdog on every call, regardless of the accepted request's kind or is_safety tag.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-003", - "title": "Degraded after degrade_after misses", - "text": "A zone controller shall transition to Degraded after degrade_after consecutive kick failures.", + "title": "Overflow purges normal requests and retains safety-tagged ones when safestate-enabled", + "text": "rcp::watchdog::StreamWatchdog::check shall, on overflow while RequestStreamConfig::rx_wd_safestate_enable is set, latch the stream into safe state and cancel every pending/started non-safety request on the supplied RequestLedger while leaving is_safety=true records untouched, reporting HealthEvent::entered_safe_state and the purge count; it shall latch nothing and purge nothing when rx_wd_safestate_enable is clear.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-004", - "title": "Faulted after fault_after misses", - "text": "A zone controller shall transition to Faulted after fault_after consecutive kick failures.", + "title": "Latched safe state does not re-fire until explicitly cleared", + "text": "Once rcp::watchdog::StreamWatchdog::check has latched a stream into safe state, subsequent check calls shall not re-detect overflow for that stream until clear_safe_state is called.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-005", - "title": "Recovery to Healthy on success", - "text": "A zone controller shall transition back to Healthy on the next successful kick after any degraded or faulted state.", + "title": "Repeating info notification while latched", + "text": "rcp::watchdog::StreamWatchdog::check shall report HealthEvent::info_notification_due on every call while the stream is latched into safe state and RequestStreamConfig::rx_wd_info_enable is set, and shall not report it otherwise.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-006", - "title": "Callback fires on transition", - "text": "The health callback shall be invoked with the new HealthState whenever a zone's health state changes.", + "title": "Manager multiplexes independent per-stream watchdog state", + "text": "rcp::watchdog::Manager shall track each registered stream_key's watchdog state independently, such that kicking, overflowing, or latching one registered stream shall not affect any other registered stream's state.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-007", - "title": "Kick timeout respected", - "text": "Each kick shall use a Context with the configured timeout; a timeout response shall count as a miss.", + "title": "Unregistered stream operations report stream_not_registered", + "text": "rcp::watchdog::Manager::on_request_received, poll, and clear_safe_state shall return WatchdogErrc::stream_not_registered, unchanged, for any stream_key not passed to register_stream, without altering any other registered stream's state.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-WDG-008", - "title": "Close stops background thread", - "text": "Keeper::close() shall cause the background kick thread to terminate without hanging.", + "title": "Subscribed callbacks fire in registration order", + "text": "rcp::watchdog::Manager::poll shall invoke every callback registered via subscribe, in registration order, exactly once for each HealthEvent it produces.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-001", - "title": "Monitor subscribes to all zones", - "text": "deadline::Monitor shall subscribe to each registered zone controller's Status stream on construction.", + "title": "Never-reported target is dead", + "text": "rcp::deadline::LivenessTracker::dead shall return true for a target on which note_activity has never been called, regardless of the elapsed time argument.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-002", - "title": "Dead event on initial deadline", - "text": "The monitor shall emit LivenessEvent{alive=false} for any zone that does not publish a Status within the configured deadline, including the very first deadline period.", + "title": "Heartbeat and lifecycle-change signals are independently sufficient", + "text": "rcp::deadline::Monitor::note_heartbeat and note_lifecycle_change shall each independently reset a registered target's last-seen-activity clock, such that either call alone is sufficient to keep that target's LivenessTracker::dead false within the configured deadline.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-003", - "title": "Repeated dead events suppressed", - "text": "The monitor shall not re-emit LivenessEvent{alive=false} for a zone that is already known to be dead.", + "title": "Dead event fires on the first check of a never-reported target", + "text": "rcp::deadline::Monitor::check shall emit LivenessEvent{alive=false} for a registered target that has never received a note_heartbeat/note_lifecycle_change call, on the first check call that evaluates it.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-004", - "title": "Alive event on first status", - "text": "The monitor shall emit LivenessEvent{alive=true} when the first Status arrives for a previously-dead zone.", + "title": "Repeated dead events suppressed", + "text": "rcp::deadline::Monitor::check shall not re-emit LivenessEvent{alive=false} for a target whose alive state was already reported false by a prior check call.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-005", - "title": "Deadline timer resets on status", - "text": "Receipt of a Status shall reset the per-zone deadline timer.", + "title": "Alive event fires on recovery", + "text": "rcp::deadline::Monitor::check shall emit LivenessEvent{alive=true} for a target whose prior reported state was dead once a note_heartbeat/note_lifecycle_change call has occurred within the configured deadline before that check call.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-006", - "title": "alive() returns false before first status", - "text": "Monitor::alive(z) shall return false for any zone that has not yet published a Status.", + "title": "alive() returns false before any check", + "text": "rcp::deadline::Monitor::alive shall return false for any target not yet evaluated by check, including a target that has already received a note_heartbeat/note_lifecycle_change call.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-007", - "title": "Close terminates watch threads", - "text": "Monitor::close() shall signal all watch threads to stop.", + "title": "Subscribed callbacks fire in registration order", + "text": "rcp::deadline::Monitor::check shall invoke every callback registered via subscribe, in registration order, exactly once for each LivenessEvent it produces.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-DL-008", - "title": "Destructor joins threads", - "text": "The Monitor destructor shall join all watch threads before returning.", + "title": "Targets are evaluated independently", + "text": "rcp::deadline::Monitor::check shall evaluate each registered target's liveness independently, such that one target's deadline expiry or recovery shall not affect any other registered target's reported alive state.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -2912,6 +2912,14 @@ "level": "ASIL-B", "asil": "ASIL-B" }, + { + "id": "REQ-LIFECYCLE-007", + "title": "State-changed callback fires only on an actual state change", + "text": "ServerLifecycle::subscribe_state_changed callbacks shall be invoked with (previous, current) exactly once for every advance() or deconfigure() call that changes state(), in registration order, and shall not be invoked for a call that fails or that leaves state() unchanged.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, { "id": "REQ-REGMAP-001", "title": "Generic vs. functional endpoint config split", diff --git a/CMakeLists.txt b/CMakeLists.txt index 2311de2..5ef77ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.9.0 + VERSION 2.10.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index 2139f86..a00ee21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -798,6 +798,64 @@ pattern in `rcp/lifecycle.hpp`/`rcp/regmap.hpp`/`rcp/wakeup.hpp`. - Depends on: RC Server lifecycle & register map (v2.1.0), E2E safe points (v2.6.0) +**Done (v2.10.0):** `rcp/watchdog.hpp` is replaced in full, per the +Satellite Package Disposition table's entry for this file — the prior +client-driven `CommandType::Watchdog` kick model built on `rcp.hpp`'s +`Zone`/`Command`/`Controller` is discarded, not adapted. The actual +per-stream watchdog timeout/latch primitive already existed as +`rcp::e2e::RxWatchdog` (v2.6.0); this milestone's real work is the driver +layer around it. `watchdog::StreamWatchdog::kick_from_request` resets a +stream's watchdog on every accepted inbound request regardless of kind or +safety tag, matching the "reset by *any* inbound request" rule directly; +`StreamWatchdog::check` reads a live `regmap::RequestStreamConfig` on every +poll, reports `HealthEvent::overflowed` on timeout, and — only when +`rx_wd_safestate_enable` is set — calls `rcp::e2e::apply_watchdog_overflow` +to latch safe state and purge normal (non-safety) requests from the +supplied `request::RequestLedger`, exactly the v2.6.0 purge-normal/ +retain-safety rule reused unmodified. `watchdog::Manager` multiplexes +`StreamWatchdog` across every request stream the embedding application +registers, keyed by an opaque `uint64_t` (typically an +`avtp::StreamId::to_u64()`), and fans out `HealthEvent`s to subscribed +callbacks — this is the "driver/wiring layer" the roadmap called for, not +a reimplementation of watchdog detection itself. + +`rcp/deadline.hpp` is likewise rebuilt in full per the disposition table's +"ADAPT" call for this file: the concept of "declare a target dead once its +liveness signal goes silent past a deadline, alive again once it resumes" +survives from the pre-replacement `Monitor`, but every concrete signal +source is rebound away from `Status`-subscription polling, which does not +exist in the target model. Two independent liveness signals feed the new +`deadline::Monitor`, matching the roadmap's own "and/or": `note_heartbeat` +for `regmap::ResponseQueueConfig::flush_time`'s periodic flush cadence, and +`note_lifecycle_change` for `lifecycle::ServerLifecycle`'s new +`subscribe_state_changed` trigger — either alone is sufficient evidence of +liveness. Both `ResponseQueueConfig::flush_time` and +`ServerLifecycle::subscribe_state_changed` are small, explicitly-scoped +additions landed at this milestone (see those headers' own v2.10.0 comments) +since neither existed in the tree before this rebuild needed them; both are +purely additive; every pre-existing field and transition rule in +`regmap.hpp`/`lifecycle.hpp` is unchanged. `deadline::Monitor::check` +evaluates every registered target independently, emitting a +`LivenessEvent` on each alive/dead transition (including the initial dead +report for a target that has never reported) and suppressing repeats, +carrying forward the pre-replacement `Monitor`'s alive/dead semantics +without its background-thread-per-zone machinery — this header, like every +other one touched since v2.6.0, provides primitives driven by the +embedding application's own clock/scheduler, not a running thread of its +own. + +Nothing else in this tree depended on the old `watchdog::Keeper` or +`deadline::Monitor` APIs (only their own tests did; `rcp/redundancy.hpp` +mentions `watchdog::Keeper` only in a doc comment, with no code dependency +on it), so no legacy shim was needed, same as every other Phase 14 rebuild. +New/rewritten coverage lives in `tests/test_watchdog.cpp` +(`REQ-WDG-001..008`, entirely rewritten), `tests/test_deadline.cpp` +(`REQ-DL-001..008`, entirely rewritten), and `tests/test_lifecycle.cpp` +(`REQ-LIFECYCLE-007`, new), traced in `.fusa-reqs.json`. Full bit-for-bit +conformance against other TC18 implementations is not claimed — see this +file's own disclaimer pattern in `rcp/e2e.hpp`/`rcp/regmap.hpp`/ +`rcp/lifecycle.hpp`/`rcp/powerstate.hpp`. + ### 55. Authorization & Admission-Control Rebind (v2.11.0) - Rebind `authz.hpp`'s access-policy check from diff --git a/include/rcp/deadline.hpp b/include/rcp/deadline.hpp index a5b45b1..d083e7f 100644 --- a/include/rcp/deadline.hpp +++ b/include/rcp/deadline.hpp @@ -7,144 +7,195 @@ // fusa:req REQ-DL-007 // fusa:req REQ-DL-008 -// Liveness deadline monitor for zone controller Status streams (SG-001, SG-004). +// RC Server liveness monitor — rebuilt around the response/ack queue's +// periodic flush heartbeat and/or the EP0 lifecycle-state-changed trigger +// signal, since the OPEN Alliance TC18 Remote Control Protocol +// Specification v0.5.1_RC's target model has no `Status`-subscription +// concept to poll (extraction §3.10, §5.1). // -// A Monitor subscribes to each registered zone controller and resets a per-zone -// deadline timer on every incoming Status frame. If no Status arrives within the -// deadline, the zone transitions to dead and a LivenessEvent is emitted. -// Recovery to alive is reported as soon as the next Status arrives. +// ROADMAP.md milestone 54, "Watchdog & Liveness Rebuild (v2.10.0)": per the +// Satellite Package Disposition table's entry for `deadline.hpp` — "ADAPT": +// the *concept* of "declare a monitored target dead once its liveness +// signal has been silent past a configured deadline, alive again the +// moment it resumes" survives from the pre-replacement `Monitor`, but every +// concrete signal source is rebound. The old `Status`-subscription-driven +// `Monitor` (background thread per zone, `ctrl->subscribe`) is discarded in +// full; nothing else in this tree depended on that old API (only this +// file's own test did), so no legacy shim is needed here, same as +// rcp/e2e.hpp's/rcp/powerstate.hpp's/rcp/watchdog.hpp's equivalent notes. +// +// Two independent liveness signals feed this file's Monitor, per the +// roadmap's own "and/or" — either is sufficient evidence a target is +// alive, and this header does not distinguish which one produced a given +// note_*() call: +// - rcp/regmap.hpp's ResponseQueueConfig::flush_time (added at this +// milestone — see that header's own comment) is the durable register +// value behind the response/ack queue's periodic flush heartbeat; +// note_heartbeat() is the driver hook for observing one. +// - rcp/lifecycle.hpp's ServerLifecycle::subscribe_state_changed (also +// added at this milestone) is the EP0 lifecycle-state-changed trigger +// signal; note_lifecycle_change() is the driver hook for observing one +// (a caller typically wires ServerLifecycle::subscribe_state_changed +// directly to note_lifecycle_change). +// +// This header has no clock, thread, or transport of its own — deciding +// *when* a heartbeat/lifecycle-change event occurred (drives note_*()) and +// *when* to evaluate the configured deadline (drives check()) is left to +// the embedding application, same disclaimer as every other header in this +// codebase since v2.6.0. This is a deliberate simplification versus the +// pre-replacement Monitor's own background-thread-per-zone design, not an +// oversight: every other header this rewrite has touched since v2.6.0 +// already made the same "primitives, not a scheduler" choice, and there is +// no reason for this file to be the exception. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The concrete target- +// keying scheme (an opaque uint64_t the caller derives — see +// rcp/watchdog.hpp's identical convention, itself following +// rcp/regmap.hpp's Ep0 "opaque index" comment) and deadline-tracking shape +// chosen in this file are this implementation's own encoding of that +// behavior — full bit-for-bit conformance against other TC18 +// implementations is not claimed, same as the equivalent disclaimers in +// rcp/e2e.hpp, rcp/regmap.hpp, rcp/lifecycle.hpp, and rcp/watchdog.hpp. #pragma once -#include "rcp.hpp" - -#include #include +#include #include #include -#include -#include -#include #include namespace rcp { namespace deadline { +// ── LivenessEvent ───────────────────────────────────────────────────────────── + struct LivenessEvent { - Zone zone; - bool alive; - std::error_code err; // non-null when subscription itself failed + uint64_t target = 0; + bool alive = false; }; +// ── Config ──────────────────────────────────────────────────────────────────── + struct Config { - std::chrono::milliseconds deadline{50}; // 20 Hz status cadence + // The specification leaves the concrete liveness deadline to the + // implementation, same as rcp/powerstate.hpp's wakeup_repeat_limit; + // this default is this implementation's own choice. + std::chrono::milliseconds deadline{50}; }; inline Config default_config() { return Config{}; } -// ── Monitor ─────────────────────────────────────────────────────────────────── +// ── LivenessTracker ─────────────────────────────────────────────────────────── +// The single-target primitive: records the most recent liveness-signal +// timestamp and reports whether more than Config::deadline has elapsed +// since it, or whether the target has never reported at all (also treated +// as dead, same "never kicked" convention as e2e::RxWatchdog). Usable +// directly for a single monitored target; Monitor below is the +// multi-target multiplexer most embeddings actually want. +class LivenessTracker { +public: + // note_activity records one liveness signal at now_ms. Called from + // either note_heartbeat() or note_lifecycle_change() below — this class + // does not distinguish which source produced a given call, per this + // file's header comment. + void note_activity(uint64_t now_ms) noexcept { + last_seen_ms_ = now_ms; + has_ever_reported_ = true; + } + + bool has_ever_reported() const noexcept { return has_ever_reported_; } + + // dead reports true when this target has never reported, or when more + // than cfg.deadline has elapsed since its last recorded activity. + bool dead(const Config& cfg, uint64_t now_ms) const noexcept { + if (!has_ever_reported_) return true; + return (now_ms - last_seen_ms_) > static_cast(cfg.deadline.count()); + } + +private: + bool has_ever_reported_ = false; + uint64_t last_seen_ms_ = 0; +}; +// ── Monitor ─────────────────────────────────────────────────────────────────── +// Multiplexes LivenessTracker across every monitored target the embedding +// application registers, keyed by an opaque uint64_t the caller derives +// (see this file's header comment). Fires a LivenessEvent, to every +// subscribed callback, on every alive<->dead transition — including the +// initial dead report for a target that has never sent a liveness signal +// by the time check() first evaluates it — and suppresses repeat events +// for a state that has not changed. class Monitor { public: using LivenessCallback = std::function; - explicit Monitor(Config cfg, std::vector> ctrls) - : cfg_(cfg) { - for (auto& c : ctrls) { - states_[c->zone()] = false; - } - for (auto& c : ctrls) { - auto t = std::thread([this, c]{ watch(c); }); - watch_threads_.push_back(std::move(t)); - } - } - - ~Monitor() { - closed_.store(true, std::memory_order_release); - for (auto& t : watch_threads_) { - if (t.joinable()) t.join(); - } - } + // register_target begins tracking a target, initially considered dead + // (has_ever_reported() is false) until the first note_*() call. A + // harmless no-op if target is already registered. + void register_target(uint64_t target) { trackers_.try_emplace(target); } - bool alive(Zone z) const { - std::lock_guard lk(mu_); - auto it = states_.find(z); - return it != states_.end() && it->second; + void unregister_target(uint64_t target) noexcept { + trackers_.erase(target); + alive_.erase(target); } - void subscribe(LivenessCallback cb) { - std::lock_guard lk(mu_); - callbacks_.push_back(std::move(cb)); + bool is_registered(uint64_t target) const noexcept { + return trackers_.find(target) != trackers_.end(); } - void close() { - closed_.store(true, std::memory_order_release); + // note_heartbeat records the response/ack queue flush-cadence signal + // (regmap::ResponseQueueConfig::flush_time) for `target` at `now_ms`. + // No-op for an unregistered target. + void note_heartbeat(uint64_t target, uint64_t now_ms) noexcept { note(target, now_ms); } + + // note_lifecycle_change records the EP0 lifecycle-state-changed trigger + // signal (lifecycle::ServerLifecycle::subscribe_state_changed) for + // `target` at `now_ms`. No-op for an unregistered target. + void note_lifecycle_change(uint64_t target, uint64_t now_ms) noexcept { note(target, now_ms); } + + // check evaluates every registered target's deadline at now_ms + // independently — one target's liveness state never affects another's + // — and fires a LivenessEvent to every subscribed callback, in + // subscription order, for each target whose alive/dead state changed + // since the last check(). + void check(const Config& cfg, uint64_t now_ms) { + for (auto& kv : trackers_) { + uint64_t target = kv.first; + bool is_alive = !kv.second.dead(cfg, now_ms); + auto it = alive_.find(target); + bool first_check = (it == alive_.end()); + bool was_alive = !first_check && it->second; + if (!first_check && is_alive == was_alive) continue; // no change since last check() + alive_[target] = is_alive; + LivenessEvent ev{target, is_alive}; + for (auto& cb : callbacks_) cb(ev); + } } -private: - Config cfg_; - mutable std::mutex mu_; - std::map states_; - std::vector callbacks_; - std::atomic closed_{false}; - std::vector watch_threads_; - - void emit(const LivenessEvent& ev) { - std::lock_guard lk(mu_); - states_[ev.zone] = ev.alive; - for (auto& cb : callbacks_) cb(ev); + // alive reports the last state check() observed for target; false for + // a target never registered or never yet check()ed. + bool alive(uint64_t target) const noexcept { + auto it = alive_.find(target); + return it != alive_.end() && it->second; } - void watch(std::shared_ptr ctrl) { - Zone z = ctrl->zone(); - // Use a background context — the monitor drives lifetime. - auto ctx = Context::background(); - std::shared_ptr ch; - auto ec = ctrl->subscribe(ctx, ch); - if (ec) { - emit({z, false, ec}); - return; - } + // subscribe registers a callback fired, in registration order, on + // every LivenessEvent check() produces for any registered target. + void subscribe(LivenessCallback cb) { callbacks_.push_back(std::move(cb)); } - bool is_alive = false; - bool ever_reported = false; // first dead event always fires - auto deadline = std::chrono::steady_clock::now() + cfg_.deadline; - - while (!closed_.load(std::memory_order_acquire)) { - auto now = std::chrono::steady_clock::now(); - if (now >= deadline) { - // Deadline missed. - if (is_alive || !ever_reported) { - is_alive = false; - ever_reported = true; - emit({z, false, {}}); - } - deadline = now + cfg_.deadline; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - continue; - } - - if (ch->is_closed()) break; - - auto maybe = ch->try_recv(); - if (maybe) { - deadline = std::chrono::steady_clock::now() + cfg_.deadline; - if (!is_alive) { - is_alive = true; - emit({z, true, {}}); - } - } else { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - } - if (is_alive) emit({z, false, {}}); +private: + std::map trackers_; + std::map alive_; + std::vector callbacks_; + + void note(uint64_t target, uint64_t now_ms) noexcept { + auto it = trackers_.find(target); + if (it == trackers_.end()) return; + it->second.note_activity(now_ms); } }; -inline std::unique_ptr new_monitor( - Config cfg, - std::vector> ctrls) { - return std::make_unique(cfg, std::move(ctrls)); -} - } // namespace deadline } // namespace rcp diff --git a/include/rcp/lifecycle.hpp b/include/rcp/lifecycle.hpp index ffe0180..3c43605 100644 --- a/include/rcp/lifecycle.hpp +++ b/include/rcp/lifecycle.hpp @@ -4,6 +4,7 @@ // fusa:req REQ-LIFECYCLE-004 // fusa:req REQ-LIFECYCLE-005 // fusa:req REQ-LIFECYCLE-006 +// fusa:req REQ-LIFECYCLE-007 // RC Server lifecycle state machine — the 3-state HW_UNCONFIGURED / // HW_CONFIGURED / RCP_CONFIGURED progression an OPEN Alliance TC18 Remote @@ -28,11 +29,21 @@ // own encoding of that behavior — full bit-for-bit register-map // conformance against other TC18 implementations is a later-milestone // concern, same as rcp/avtp.hpp's disclaimer for the wire codec. +// +// ROADMAP.md milestone 54, "Watchdog & Liveness Rebuild (v2.10.0)": +// ServerLifecycle::subscribe_state_changed below is a small, explicitly- +// scoped addition for that milestone — rcp/deadline.hpp's liveness monitor +// needs an "EP0 lifecycle-state-changed" trigger signal to treat as +// evidence of RC Server liveness (there being no Status-subscription +// concept left to poll in the new model), and no such signal existed +// anywhere in this header prior to v2.10.0. It is purely additive: every +// pre-existing transition rule and locking policy above is unchanged. #pragma once #include #include #include +#include #include namespace rcp { @@ -99,6 +110,12 @@ inline std::error_code make_error_code(LifecycleErrc e) noexcept { // above this one. using PlausibilityCheck = std::function; +// StateChangedCallback is ROADMAP.md milestone 54's (v2.10.0) +// lifecycle-state-changed trigger signal: fired with (previous, current) +// exactly once for every ServerLifecycle-driven transition that actually +// changes state() — see subscribe_state_changed below. +using StateChangedCallback = std::function; + // ServerLifecycle owns the current ServerState and enforces: // - forward-only, single-step transitions via advance() (extraction §3.2: // a state-advance request always targets the next state in sequence; @@ -133,13 +150,17 @@ class ServerLifecycle { if (state_ == ServerState::HwUnconfigured && target == ServerState::HwConfigured) { if (hw_cfg_check_ && !hw_cfg_check_()) return make_error_code(LifecycleErrc::hw_cfg_inconsistent); + ServerState previous = state_; state_ = ServerState::HwConfigured; + notify_state_changed(previous, state_); return {}; } if (state_ == ServerState::HwConfigured && target == ServerState::RcpConfigured) { if (rcp_cfg_check_ && !rcp_cfg_check_()) return make_error_code(LifecycleErrc::rcp_cfg_inconsistent); + ServerState previous = state_; state_ = ServerState::RcpConfigured; + notify_state_changed(previous, state_); return {}; } return make_error_code(LifecycleErrc::invalid_transition); @@ -148,7 +169,11 @@ class ServerLifecycle { // deconfigure is the one sanctioned backward path: an explicit reset to // HW_UNCONFIGURED (e.g. on a hardware reset or an operator-triggered // reconfiguration), unlocking every register block again. - void deconfigure() noexcept { state_ = ServerState::HwUnconfigured; } + void deconfigure() noexcept { + ServerState previous = state_; + state_ = ServerState::HwUnconfigured; + notify_state_changed(previous, state_); + } // Register-locking queries (extraction §3.2's locking behavior, this // implementation's own realization of it — see class comment above). @@ -159,10 +184,35 @@ class ServerLifecycle { return state_ == ServerState::RcpConfigured; } + // subscribe_state_changed registers a callback fired, in registration + // order, every time a call above actually changes state() (extraction + // §5.1's EP0 pseudo-endpoint is the natural place a client would learn + // of such a transition; this class only models the trigger signal + // itself, not any wire-level notification of it — that is the + // embedding application's/rcp/regmap.hpp's concern, same "primitive, + // not a scheduler or transport" split as every other header in this + // codebase). A call to advance()/deconfigure() that fails, or that + // targets the state already current (deconfigure() from + // HwUnconfigured), fires no callback — see notify_state_changed. + void subscribe_state_changed(StateChangedCallback cb) { + state_changed_callbacks_.push_back(std::move(cb)); + } + private: ServerState state_ = ServerState::HwUnconfigured; PlausibilityCheck hw_cfg_check_; PlausibilityCheck rcp_cfg_check_; + std::vector state_changed_callbacks_; + + // notify_state_changed fires every subscribed callback exactly when + // `previous` and `current` actually differ — a no-change call (e.g. + // deconfigure() from an already-HwUnconfigured state) is deliberately + // silent, since this signal exists to mean "state changed", not + // "a transition was attempted". + void notify_state_changed(ServerState previous, ServerState current) noexcept { + if (previous == current) return; + for (auto& cb : state_changed_callbacks_) cb(previous, current); + } }; } // namespace lifecycle diff --git a/include/rcp/regmap.hpp b/include/rcp/regmap.hpp index 7cc50d6..9dabe69 100644 --- a/include/rcp/regmap.hpp +++ b/include/rcp/regmap.hpp @@ -48,6 +48,13 @@ // placeholder fields v2.1.0 reserved layout for. The behavior that reads // and acts on these fields lives in rcp/e2e.hpp and rcp/request.hpp, not // here — same config-vs-behavior split as everything else in this header. +// +// ROADMAP.md milestone 54, "Watchdog & Liveness Rebuild (v2.10.0)": +// ResponseQueueConfig gains flush_time — a small, explicitly-scoped +// addition rcp/deadline.hpp's rebuilt liveness monitor needs as a +// heartbeat-cadence signal, in place of the Status-subscription concept +// that does not exist in this model. Behavior lives in rcp/deadline.hpp, +// same split as the watchdog fields above. #pragma once #include @@ -223,6 +230,17 @@ struct EpIdMappingEntry { struct ResponseQueueConfig { uint16_t response_queue_size = 0; uint16_t ack_queue_size = 0; + + // Periodic response/ack queue flush cadence, in milliseconds (extraction + // §3.10). ROADMAP.md milestone 54 (v2.10.0) reuses this field as a + // liveness heartbeat signal: rcp/deadline.hpp treats each flush as + // evidence the owning RC Server is alive, since there is no Status- + // subscription concept to poll in this model. This field's unit is this + // implementation's own choice, same as every other concrete-width + // decision in this header; behavior that reads and acts on it lives in + // rcp/deadline.hpp, not here — same config-vs-behavior split as + // RequestStreamConfig's watchdog fields above. + uint32_t flush_time = 0; }; // ── svr_implemented_options bitmask ────────────────────────────────────────── diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 018986c..7653385 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.9.0"; +constexpr std::string_view kVersion = "2.10.0"; } // namespace rcp diff --git a/include/rcp/watchdog.hpp b/include/rcp/watchdog.hpp index 6526df1..3de601e 100644 --- a/include/rcp/watchdog.hpp +++ b/include/rcp/watchdog.hpp @@ -7,169 +7,244 @@ // fusa:req REQ-WDG-007 // fusa:req REQ-WDG-008 -// Watchdog keeper for ASIL-B zone controller liveness (SG-001, SG-003, SG-007). +// Per-request-stream watchdog driver — the embedding-application wiring +// layer that turns "any inbound request accepted on a stream" into the +// OPEN Alliance TC18 Remote Control Protocol Specification v0.5.1_RC's +// actual watchdog reset rule, and surfaces overflow/safe-state transitions +// through this module's own health-event API (extraction §3.8). // -// A Keeper periodically sends CommandType::Watchdog to each registered zone -// controller. Consecutive failures transition a zone through the health state -// machine: Healthy → Degraded → Faulted. +// ROADMAP.md milestone 54, "Watchdog & Liveness Rebuild (v2.10.0)", opening +// the second half of Phase 14: this header REPLACES this file's +// pre-replacement content in full, per the Satellite Package Disposition +// table's entry for `watchdog.hpp` — the prior client-driven periodic +// `CommandType::Watchdog` kick model built on rcp.hpp's +// `Zone`/`Command`/`Controller` is discarded, not adapted (that command +// type has no analog in the target protocol). Nothing else in this tree +// depended on the old `watchdog::Keeper` API (only this file's own test +// did), so no legacy shim is needed here, same as rcp/e2e.hpp's and +// rcp/powerstate.hpp's equivalent notes at v2.6.0/v2.9.0. +// +// The actual per-stream watchdog timeout/latch primitive already exists as +// rcp/e2e.hpp's `RxWatchdog` (landed at v2.6.0, ahead of the safe-state +// mechanism this file depends on) — this header does not reimplement +// watchdog timeout detection or the purge-normal/retain-safety queue rule; +// it wraps `RxWatchdog`/`apply_watchdog_overflow` as the driver that (a) +// resets ("kicks") a stream's watchdog on every accepted inbound request, +// regardless of that request's kind or safety tag, and (b) reads +// rcp/regmap.hpp's `RequestStreamConfig` for the live enable/timeout/ +// safe-state fields on every poll rather than caching a stale copy. This +// header has no clock, thread, or transport of its own — deciding *when* +// a request was "received" (drives kick_from_request) and *when* to poll +// for overflow (drives check()/poll()) is left to the embedding +// application, same disclaimer as every other endpoint/lifecycle header in +// this codebase since v2.6.0. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The concrete health-event +// shape and stream-keying scheme (an opaque uint64_t the caller derives, +// typically via avtp::StreamId::to_u64() — see rcp/regmap.hpp's Ep0 class +// comment for the same "opaque index" convention) chosen in this file are +// this implementation's own encoding of that behavior — full bit-for-bit +// conformance against other TC18 implementations is not claimed, same as +// the equivalent disclaimers in rcp/e2e.hpp, rcp/regmap.hpp, and +// rcp/powerstate.hpp. #pragma once -#include "rcp.hpp" +#include +#include +#include -#include -#include +#include +#include #include #include -#include -#include -#include +#include +#include +#include #include namespace rcp { namespace watchdog { -// ── HealthState ─────────────────────────────────────────────────────────────── +// ── Errors ──────────────────────────────────────────────────────────────────── -enum class HealthState : uint8_t { - Healthy = 0, - Degraded = 1, - Faulted = 2, +enum class WatchdogErrc : int { + stream_not_registered = 1, // target stream key was never passed to Manager::register_stream }; -inline std::string to_string(HealthState h) { - switch (h) { - case HealthState::Healthy: return "healthy"; - case HealthState::Degraded: return "degraded"; - case HealthState::Faulted: return "faulted"; - default: return "unknown"; - } +inline const std::error_category& watchdog_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.watchdog"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case WatchdogErrc::stream_not_registered: + return "rcp/watchdog: stream key was not registered with this Manager"; + default: + return "rcp/watchdog: unknown error"; + } + } + }; + static Cat instance; + return instance; } +inline std::error_code make_error_code(WatchdogErrc e) noexcept { + return {static_cast(e), watchdog_category()}; +} + +// ── HealthEvent ─────────────────────────────────────────────────────────────── +// What a caller-supplied HealthCallback receives. `overflowed` and +// `entered_safe_state` are independent: a watchdog can overflow on a stream +// whose rx_wd_safestate_enable is clear, in which case the timeout is +// reported but nothing is latched or purged (extraction §3.8's per-field +// gating — a register existing does not mean the behavior it gates is on). + struct HealthEvent { - Zone zone; - HealthState state; - std::error_code err; + uint64_t stream_key = 0; + bool overflowed = false; // this poll observed the watchdog timeout expire + bool entered_safe_state = false; // the expiry additionally latched safe state this poll + bool info_notification_due = false; // rx_wd_info_enable's repeating "still safe" notice is due now + size_t canceled_request_count = 0; // normal (non-safety) requests purged by this poll, if any }; -// ── Config ──────────────────────────────────────────────────────────────────── +// ── StreamWatchdog ──────────────────────────────────────────────────────────── +// The single-stream driver: wraps one e2e::RxWatchdog and drives it from two +// caller-invoked events — an accepted inbound request (kick_from_request) +// and a periodic poll (check) that reads whatever RequestStreamConfig/ +// RequestLedger the caller currently has live for this stream. Usable +// directly for a single-stream embedding; Manager below is the +// multi-stream multiplexer most embeddings actually want. +class StreamWatchdog { +public: + // kick_from_request resets this stream's watchdog. Call this once per + // request this stream *accepts*, regardless of request kind or + // is_safety — extraction §3.8's reset rule does not distinguish by + // request content, only "any inbound request on the stream" (the + // roadmap's own phrasing for what supersedes the old client-side + // CommandType::Watchdog poll). + void kick_from_request(uint64_t now_ms) noexcept { wd_.kick(now_ms); } + + // check evaluates `cfg` against `now_ms`: while not already latched + // into safe state, an elapsed rx_wd_timeout_interval is reported via + // HealthEvent::overflowed and, only when cfg.rx_wd_safestate_enable is + // set, additionally applies apply_watchdog_overflow (latch + purge) + // against `ledger`. Once latched, further overflow checks are skipped + // (the latch does not re-fire) and, while cfg.rx_wd_info_enable is set, + // every poll instead reports the repeating info-notification-due flag + // — repeat cadence is the caller's own polling interval, same + // "primitive, not scheduler" split as e2e::RxWatchdog itself. Returns + // std::nullopt when there is nothing new to report this poll. + std::optional check(uint64_t stream_key, const regmap::RequestStreamConfig& cfg, + request::RequestLedger& ledger, uint64_t now_ms) noexcept { + if (!wd_.in_safe_state() && wd_.overflowed(cfg, now_ms)) { + size_t canceled = e2e::apply_watchdog_overflow(cfg, wd_, ledger); + HealthEvent ev; + ev.stream_key = stream_key; + ev.overflowed = true; + ev.entered_safe_state = wd_.in_safe_state(); + ev.info_notification_due = wd_.should_emit_info_notification(cfg); + ev.canceled_request_count = canceled; + return ev; + } + if (wd_.should_emit_info_notification(cfg)) { + HealthEvent ev; + ev.stream_key = stream_key; + ev.info_notification_due = true; + return ev; + } + return std::nullopt; + } -struct Config { - std::chrono::milliseconds interval {10}; // 100 Hz - std::chrono::milliseconds timeout {5}; // per-kick deadline - int degrade_after{3}; - int fault_after {5}; -}; + bool in_safe_state() const noexcept { return wd_.in_safe_state(); } -inline Config default_config() { return Config{}; } + // clear_safe_state unlatches this stream's watchdog (e.g. on stream + // reconfiguration) — the same explicit, caller-driven unlatch e2e.hpp's + // RxWatchdog/RxStreamGuard already require elsewhere in this codebase; + // kick_from_request alone never clears a latched safe state. + void clear_safe_state() noexcept { wd_.clear_safe_state(); } -// ── Keeper ──────────────────────────────────────────────────────────────────── +private: + e2e::RxWatchdog wd_; +}; -class Keeper { +// ── Manager ─────────────────────────────────────────────────────────────────── +// Multiplexes StreamWatchdog across every request stream the embedding +// application registers, keyed by an opaque uint64_t the caller derives +// (see this file's header comment). This is the layer the roadmap calls +// out as the milestone's real job: wiring "any inbound request" receipt +// and periodic polling to e2e.hpp's primitives, per stream, and fanning +// out HealthEvents to subscribers — not a reimplementation of watchdog +// timeout detection itself. +class Manager { public: using HealthCallback = std::function; - explicit Keeper(Config cfg, std::vector> ctrls) - : cfg_(cfg) { - for (auto& c : ctrls) { - ctrls_[c->zone()] = c; - states_[c->zone()] = State{}; - } - run_thread_ = std::thread([this]{ run(); }); - } + // register_stream begins tracking a stream; a harmless no-op if + // stream_key is already registered (existing watchdog state, including + // any latched safe state, is left untouched). + void register_stream(uint64_t stream_key) { streams_.try_emplace(stream_key); } - ~Keeper() { - closed_.store(true, std::memory_order_release); - if (run_thread_.joinable()) run_thread_.join(); - } - - // health returns the current health state for zone. - HealthState health(Zone z) const { - std::lock_guard lk(mu_); - auto it = states_.find(z); - return it != states_.end() ? it->second.health : HealthState::Faulted; - } + // unregister_stream stops tracking a stream and discards its state. + void unregister_stream(uint64_t stream_key) noexcept { streams_.erase(stream_key); } - // subscribe registers a callback fired on every health state change. - // Not thread-safe with close(); register before starting operation. - void subscribe(HealthCallback cb) { - std::lock_guard lk(mu_); - callbacks_.push_back(std::move(cb)); + bool is_registered(uint64_t stream_key) const noexcept { + return streams_.find(stream_key) != streams_.end(); } - void close() { - closed_.store(true, std::memory_order_release); + // on_request_received is the driver hook the embedding transport calls + // once per accepted inbound request — see StreamWatchdog:: + // kick_from_request. Returns stream_not_registered, unchanged, for a + // key never passed to register_stream. + std::error_code on_request_received(uint64_t stream_key, uint64_t now_ms) noexcept { + auto it = streams_.find(stream_key); + if (it == streams_.end()) return make_error_code(WatchdogErrc::stream_not_registered); + it->second.kick_from_request(now_ms); + return {}; } -private: - struct State { - HealthState health = HealthState::Healthy; - int misses = 0; - uint32_t cmd_id = 0; - }; - - Config cfg_; - std::map> ctrls_; - mutable std::mutex mu_; - std::map states_; - std::vector callbacks_; - std::atomic closed_{false}; - std::thread run_thread_; - - void run() { - while (!closed_.load(std::memory_order_acquire)) { - kick_all(); - std::this_thread::sleep_for(cfg_.interval); + // poll drives StreamWatchdog::check for one registered stream and fans + // out any resulting HealthEvent to every subscribed callback, in + // subscription order. Returns stream_not_registered, unchanged, for a + // key never passed to register_stream; other streams' state is + // unaffected by that failure. + std::error_code poll(uint64_t stream_key, const regmap::RequestStreamConfig& cfg, + request::RequestLedger& ledger, uint64_t now_ms) { + auto it = streams_.find(stream_key); + if (it == streams_.end()) return make_error_code(WatchdogErrc::stream_not_registered); + auto ev = it->second.check(stream_key, cfg, ledger, now_ms); + if (ev.has_value()) { + for (auto& cb : callbacks_) cb(*ev); } + return {}; } - void kick_all() { - for (auto& kv : ctrls_) { - Zone z = kv.first; - auto ctrl = kv.second; - std::thread([this, z, ctrl]{ kick(z, ctrl); }).detach(); - } + bool in_safe_state(uint64_t stream_key) const noexcept { + auto it = streams_.find(stream_key); + return it != streams_.end() && it->second.in_safe_state(); } - void kick(Zone z, std::shared_ptr ctrl) { - uint32_t id; - { - std::lock_guard lk(mu_); - id = ++states_[z].cmd_id; - } - Command cmd; - cmd.id = id; - cmd.zone = z; - cmd.type = CommandType::Watchdog; - cmd.priority = Priority::High; - - auto ctx = Context::with_timeout(cfg_.timeout); - Response resp; - auto ec = ctrl->send(ctx, cmd, resp); - - std::lock_guard lk(mu_); - auto& st = states_[z]; - HealthState next = st.health; - if (!ec) { - st.misses = 0; - next = HealthState::Healthy; - } else { - ++st.misses; - if (st.misses >= cfg_.fault_after) next = HealthState::Faulted; - else if (st.misses >= cfg_.degrade_after) next = HealthState::Degraded; - } - if (next != st.health) { - st.health = next; - HealthEvent ev{z, next, ec}; - for (auto& cb : callbacks_) cb(ev); - } + std::error_code clear_safe_state(uint64_t stream_key) noexcept { + auto it = streams_.find(stream_key); + if (it == streams_.end()) return make_error_code(WatchdogErrc::stream_not_registered); + it->second.clear_safe_state(); + return {}; } -}; -inline std::unique_ptr new_keeper( - Config cfg, - std::vector> ctrls) { - return std::make_unique(cfg, std::move(ctrls)); -} + // subscribe registers a callback fired, in registration order, on + // every HealthEvent poll() produces for any registered stream. + void subscribe(HealthCallback cb) { callbacks_.push_back(std::move(cb)); } + +private: + std::map streams_; + std::vector callbacks_; +}; } // namespace watchdog } // namespace rcp + +// Enable std::error_code construction from rcp::watchdog::WatchdogErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/tests/test_deadline.cpp b/tests/test_deadline.cpp index 63e5d35..d4b32ae 100644 --- a/tests/test_deadline.cpp +++ b/tests/test_deadline.cpp @@ -7,80 +7,153 @@ // fusa:test REQ-DL-007 // fusa:test REQ-DL-008 +// Tests for rcp/deadline.hpp — the RC Server liveness monitor rebuilt +// around the response/ack queue flush heartbeat and the EP0 +// lifecycle-state-changed trigger (ROADMAP.md milestone 54, "Watchdog & +// Liveness Rebuild", v2.10.0). + #include -#include -#include -#include +#include "rcp/deadline.hpp" + +#include + +using namespace rcp::deadline; + +// ── LivenessTracker ──────────────────────────────────────────────────────────── + +TEST_CASE("LivenessTracker is dead until note_activity is ever called", "[deadline][REQ-DL-001]") { + LivenessTracker tr; + Config cfg = default_config(); + REQUIRE(tr.dead(cfg, 0)); + REQUIRE(tr.dead(cfg, 1'000'000)); // regardless of how much simulated time has passed + REQUIRE_FALSE(tr.has_ever_reported()); +} + +// ── Heartbeat and lifecycle-change are independently sufficient ───────────── + +TEST_CASE("Both note_heartbeat and note_lifecycle_change independently keep a target alive", + "[deadline][REQ-DL-002]") { + Config cfg; + cfg.deadline = std::chrono::milliseconds(50); + + Monitor mon_hb; + mon_hb.register_target(1); + mon_hb.note_heartbeat(1, 1'000); + mon_hb.check(cfg, 1'020); + REQUIRE(mon_hb.alive(1)); + + Monitor mon_lc; + mon_lc.register_target(1); + mon_lc.note_lifecycle_change(1, 1'000); + mon_lc.check(cfg, 1'020); + REQUIRE(mon_lc.alive(1)); +} + +// ── Dead event on first check of a never-reported target ───────────────────── -using namespace rcp; +TEST_CASE("check() emits a dead event on the first evaluation of a never-reported target", + "[deadline][REQ-DL-003]") { + Config cfg; + Monitor mon; + mon.register_target(1); -// ── Monitor creation ────────────────────────────────────────────────────────── + std::vector events; + mon.subscribe([&](const LivenessEvent& ev) { events.push_back(ev); }); -TEST_CASE("Deadline Monitor constructs without error", "[deadline][REQ-DL-001]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - deadline::Config cfg; - cfg.deadline = std::chrono::milliseconds(100); - deadline::Monitor mon(cfg, {ctrl}); + mon.check(cfg, 0); + REQUIRE(events.size() == 1); + REQUIRE(events[0].target == 1); + REQUIRE_FALSE(events[0].alive); } -// ── Dead zone detected ──────────────────────────────────────────────────────── +// ── Repeated dead events suppressed ─────────────────────────────────────────── -TEST_CASE("Deadline Monitor fires alive=false when no status arrives", "[deadline][REQ-DL-002][REQ-DL-003]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - deadline::Config cfg; - cfg.deadline = std::chrono::milliseconds(30); +TEST_CASE("check() does not re-emit a dead event for a target already known dead", + "[deadline][REQ-DL-004]") { + Config cfg; + Monitor mon; + mon.register_target(1); - std::atomic dead_count{0}; - deadline::Monitor mon(cfg, {ctrl}); - mon.subscribe([&](const deadline::LivenessEvent& ev) { - if (!ev.alive) ++dead_count; - }); + std::vector events; + mon.subscribe([&](const LivenessEvent& ev) { events.push_back(ev); }); - // No status published — wait for deadline to expire. - std::this_thread::sleep_for(std::chrono::milliseconds(150)); - REQUIRE(dead_count.load() > 0); + mon.check(cfg, 0); + mon.check(cfg, 100); + mon.check(cfg, 200); + REQUIRE(events.size() == 1); // only the first dead report } -// ── Live zone detected ──────────────────────────────────────────────────────── +// ── Alive event on recovery ──────────────────────────────────────────────────── -TEST_CASE("Deadline Monitor reports alive=true when status arrives", "[deadline][REQ-DL-004][REQ-DL-005]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - deadline::Config cfg; - cfg.deadline = std::chrono::milliseconds(200); +TEST_CASE("check() emits an alive event once a heartbeat arrives for a previously-dead target", + "[deadline][REQ-DL-005]") { + Config cfg; + cfg.deadline = std::chrono::milliseconds(50); - std::atomic alive_count{0}; - deadline::Monitor mon(cfg, {ctrl}); - mon.subscribe([&](const deadline::LivenessEvent& ev) { - if (ev.alive) ++alive_count; - }); + Monitor mon; + mon.register_target(1); - // Let the monitor start its watch thread then publish a status. - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - ctrl->publish({}); + std::vector events; + mon.subscribe([&](const LivenessEvent& ev) { events.push_back(ev); }); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - REQUIRE(alive_count.load() > 0); + mon.check(cfg, 0); // dead -- never reported + REQUIRE_FALSE(mon.alive(1)); + + mon.note_heartbeat(1, 10); + mon.check(cfg, 30); // within the 50ms deadline of the heartbeat at t=10 + REQUIRE(mon.alive(1)); + + REQUIRE(events.size() == 2); + REQUIRE_FALSE(events[0].alive); + REQUIRE(events[1].alive); +} + +// ── alive() before any check ────────────────────────────────────────────────── + +TEST_CASE("alive() returns false before check() has ever evaluated the target", + "[deadline][REQ-DL-006]") { + Monitor mon; + mon.register_target(1); + mon.note_heartbeat(1, 0); // reported activity, but never yet checked + + REQUIRE_FALSE(mon.alive(1)); + REQUIRE_FALSE(mon.alive(99)); // never even registered } -// ── alive() query ──────────────────────────────────────────────────────────── +// ── Subscribed callbacks ────────────────────────────────────────────────────── -TEST_CASE("Monitor::alive returns false before any status arrives", "[deadline][REQ-DL-006]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - deadline::Config cfg; - cfg.deadline = std::chrono::milliseconds(5000); // long timeout - deadline::Monitor mon(cfg, {ctrl}); - // Immediately after construction, no status has arrived. - REQUIRE_FALSE(mon.alive(Zone::FrontLeft)); +TEST_CASE("check() invokes every subscribed callback in registration order", + "[deadline][REQ-DL-007]") { + Config cfg; + Monitor mon; + mon.register_target(1); + + std::vector order; + mon.subscribe([&](const LivenessEvent&) { order.push_back(1); }); + mon.subscribe([&](const LivenessEvent&) { order.push_back(2); }); + + mon.check(cfg, 0); + REQUIRE(order == std::vector{1, 2}); } -// ── Close ───────────────────────────────────────────────────────────────────── +// ── Independence across targets ─────────────────────────────────────────────── + +TEST_CASE("check() evaluates each registered target independently", "[deadline][REQ-DL-008]") { + Config cfg; + cfg.deadline = std::chrono::milliseconds(50); + + Monitor mon; + mon.register_target(1); + mon.register_target(2); + + mon.note_heartbeat(1, 0); // target 1 stays alive; target 2 never reports + mon.check(cfg, 20); + + REQUIRE(mon.alive(1)); + REQUIRE_FALSE(mon.alive(2)); -TEST_CASE("Deadline Monitor::close stops watch threads", "[deadline][REQ-DL-007][REQ-DL-008]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - deadline::Config cfg; - cfg.deadline = std::chrono::milliseconds(100); - deadline::Monitor mon(cfg, {ctrl}); - mon.close(); - // Destructor should join without hanging. + mon.unregister_target(1); + REQUIRE_FALSE(mon.is_registered(1)); + REQUIRE(mon.is_registered(2)); } diff --git a/tests/test_lifecycle.cpp b/tests/test_lifecycle.cpp index 15e2858..4c6c510 100644 --- a/tests/test_lifecycle.cpp +++ b/tests/test_lifecycle.cpp @@ -4,14 +4,19 @@ // fusa:test REQ-LIFECYCLE-004 // fusa:test REQ-LIFECYCLE-005 // fusa:test REQ-LIFECYCLE-006 +// fusa:test REQ-LIFECYCLE-007 // Tests for rcp/lifecycle.hpp — the RC Server 3-state lifecycle machine // (ROADMAP.md milestone 45, "RC Server Lifecycle & Register-Map Model", -// v2.1.0). +// v2.1.0), plus the lifecycle-state-changed trigger signal added at +// ROADMAP.md milestone 54 ("Watchdog & Liveness Rebuild", v2.10.0). #include #include +#include +#include + using namespace rcp::lifecycle; // ── State encoding ───────────────────────────────────────────────────────────── @@ -130,3 +135,68 @@ TEST_CASE("deconfigure() resets to HW_UNCONFIGURED and unlocks both config block REQUIRE_FALSE(lc.advance(ServerState::HwConfigured)); REQUIRE(lc.state() == ServerState::HwConfigured); } + +// ── Lifecycle-state-changed trigger signal (milestone 54, v2.10.0) ────────────── + +TEST_CASE("subscribe_state_changed fires (previous, current) for each successful advance()", + "[lifecycle][REQ-LIFECYCLE-007]") { + ServerLifecycle lc; + std::vector> seen; + lc.subscribe_state_changed([&](ServerState previous, ServerState current) { + seen.emplace_back(previous, current); + }); + + REQUIRE_FALSE(lc.advance(ServerState::HwConfigured)); + REQUIRE_FALSE(lc.advance(ServerState::RcpConfigured)); + + REQUIRE(seen.size() == 2); + REQUIRE(seen[0] == std::make_pair(ServerState::HwUnconfigured, ServerState::HwConfigured)); + REQUIRE(seen[1] == std::make_pair(ServerState::HwConfigured, ServerState::RcpConfigured)); +} + +TEST_CASE("subscribe_state_changed does not fire for a failed advance() call", + "[lifecycle][REQ-LIFECYCLE-007]") { + ServerLifecycle lc(/*hw_cfg_check=*/[] { return false; }); + int fired = 0; + lc.subscribe_state_changed([&](ServerState, ServerState) { ++fired; }); + + auto ec = lc.advance(ServerState::HwConfigured); + REQUIRE(ec == make_error_code(LifecycleErrc::hw_cfg_inconsistent)); + REQUIRE(fired == 0); + + // An invalid_transition request (skipping a state) is likewise silent. + ServerLifecycle lc2; + int fired2 = 0; + lc2.subscribe_state_changed([&](ServerState, ServerState) { ++fired2; }); + REQUIRE(lc2.advance(ServerState::RcpConfigured) == make_error_code(LifecycleErrc::invalid_transition)); + REQUIRE(fired2 == 0); +} + +TEST_CASE("subscribe_state_changed fires on deconfigure() only when state actually changes", + "[lifecycle][REQ-LIFECYCLE-007]") { + ServerLifecycle lc; + int fired = 0; + lc.subscribe_state_changed([&](ServerState, ServerState) { ++fired; }); + + // Already HwUnconfigured -- deconfigure() is a no-op state-wise, so it + // must not fire. + lc.deconfigure(); + REQUIRE(fired == 0); + + REQUIRE_FALSE(lc.advance(ServerState::HwConfigured)); + REQUIRE(fired == 1); + + lc.deconfigure(); + REQUIRE(fired == 2); + REQUIRE(lc.state() == ServerState::HwUnconfigured); +} + +TEST_CASE("Multiple subscribers all fire, in registration order", "[lifecycle][REQ-LIFECYCLE-007]") { + ServerLifecycle lc; + std::vector order; + lc.subscribe_state_changed([&](ServerState, ServerState) { order.push_back(1); }); + lc.subscribe_state_changed([&](ServerState, ServerState) { order.push_back(2); }); + + REQUIRE_FALSE(lc.advance(ServerState::HwConfigured)); + REQUIRE(order == std::vector{1, 2}); +} diff --git a/tests/test_watchdog.cpp b/tests/test_watchdog.cpp index 354bf69..126d728 100644 --- a/tests/test_watchdog.cpp +++ b/tests/test_watchdog.cpp @@ -7,104 +7,260 @@ // fusa:test REQ-WDG-007 // fusa:test REQ-WDG-008 +// Tests for rcp/watchdog.hpp — the per-request-stream watchdog driver +// (ROADMAP.md milestone 54, "Watchdog & Liveness Rebuild", v2.10.0). + #include -#include -#include -#include -#include -#include +#include "rcp/watchdog.hpp" + +#include + +using namespace rcp::watchdog; +using rcp::regmap::RequestStreamConfig; +using rcp::request::request_record_for; +using rcp::request::RequestLedger; +using rcp::request::RequestState; +using rcp::request::RequestTypeOpcode; + +// ── StreamWatchdog::check — overflow detection ─────────────────────────────── + +TEST_CASE("StreamWatchdog never overflows while disabled or before any kick", "[watchdog][REQ-WDG-001]") { + RequestStreamConfig cfg; + cfg.rx_wd_timeout_interval = 100; + RequestLedger ledger; + StreamWatchdog wd; + + REQUIRE_FALSE(wd.check(/*stream_key=*/1, cfg, ledger, /*now_ms=*/10'000).has_value()); + + cfg.rx_wd_enable = true; + REQUIRE_FALSE(wd.check(1, cfg, ledger, 10'000).has_value()); // never kicked +} + +TEST_CASE("StreamWatchdog::check reports overflow once the timeout interval elapses since the last kick", + "[watchdog][REQ-WDG-001]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + RequestLedger ledger; + StreamWatchdog wd; + + wd.kick_from_request(1'000); + REQUIRE_FALSE(wd.check(1, cfg, ledger, 1'050).has_value()); + + auto ev = wd.check(1, cfg, ledger, 1'101); + REQUIRE(ev.has_value()); + REQUIRE(ev->overflowed); + REQUIRE(ev->stream_key == 1); +} + +// ── kick_from_request resets on any inbound request ────────────────────────── + +TEST_CASE("kick_from_request resets the watchdog regardless of what triggers it", + "[watchdog][REQ-WDG-002]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + RequestLedger ledger; + StreamWatchdog wd; + + wd.kick_from_request(1'000); + wd.kick_from_request(1'050); // a second, unrelated inbound request also resets it + REQUIRE_FALSE(wd.check(1, cfg, ledger, 1'100).has_value()); + REQUIRE(wd.check(1, cfg, ledger, 1'151).has_value()); +} + +// ── Overflow -> safe-state latch + purge-normal/retain-safety ─────────────── + +TEST_CASE("Overflow with rx_wd_safestate_enable latches safe state and purges normal requests", + "[watchdog][REQ-WDG-003]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + cfg.rx_wd_safestate_enable = true; + + RequestLedger ledger; + REQUIRE_FALSE(ledger.submit(request_record_for(1, RequestTypeOpcode::Compound, /*cs=*/false))); + REQUIRE_FALSE(ledger.submit(request_record_for(2, RequestTypeOpcode::CompoundSafety, /*cs=*/false))); + + StreamWatchdog wd; + wd.kick_from_request(0); + auto ev = wd.check(7, cfg, ledger, 200); + + REQUIRE(ev.has_value()); + REQUIRE(ev->overflowed); + REQUIRE(ev->entered_safe_state); + REQUIRE(ev->canceled_request_count == 1); + REQUIRE(wd.in_safe_state()); + REQUIRE(ledger.find(1)->state == RequestState::Canceled); + REQUIRE(ledger.find(2)->state == RequestState::Pending); +} + +TEST_CASE("Overflow without rx_wd_safestate_enable reports overflowed but does not latch or purge", + "[watchdog][REQ-WDG-003]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; // rx_wd_safestate_enable defaults to false -using namespace rcp; + RequestLedger ledger; + REQUIRE_FALSE(ledger.submit(request_record_for(1, RequestTypeOpcode::Compound, /*cs=*/false))); -// ── Keeper creation ─────────────────────────────────────────────────────────── + StreamWatchdog wd; + wd.kick_from_request(0); + auto ev = wd.check(1, cfg, ledger, 200); -TEST_CASE("Watchdog Keeper constructs without error", "[watchdog][REQ-WDG-001]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - watchdog::Config cfg; - cfg.interval = std::chrono::milliseconds(200); - cfg.timeout = std::chrono::milliseconds(50); - cfg.degrade_after = 3; - cfg.fault_after = 5; - watchdog::Keeper keeper(cfg, {ctrl}); + REQUIRE(ev.has_value()); + REQUIRE(ev->overflowed); + REQUIRE_FALSE(ev->entered_safe_state); + REQUIRE(ev->canceled_request_count == 0); + REQUIRE_FALSE(wd.in_safe_state()); + REQUIRE(ledger.find(1)->state == RequestState::Pending); } -// ── Health state transitions ────────────────────────────────────────────────── - -TEST_CASE("Watchdog transitions zone to Faulted after misses exceed fault_after", "[watchdog][REQ-WDG-002][REQ-WDG-003]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - - // Close so every kick returns ErrClosed immediately. - { auto ec = ctrl->close(); (void)ec; } - - watchdog::Config cfg; - cfg.interval = std::chrono::milliseconds(20); - cfg.timeout = std::chrono::milliseconds(5); - cfg.degrade_after = 2; - cfg.fault_after = 3; - - std::atomic last_state{watchdog::HealthState::Healthy}; - watchdog::Keeper keeper(cfg, {ctrl}); - keeper.subscribe([&](const watchdog::HealthEvent& ev) { - last_state.store(ev.state); - }); - - // The background keeper thread needs fault_after (3) miss cycles of 20 ms to - // reach Faulted. On loaded/preempted CI runners the thread may be scheduled - // late, so poll up to a generous deadline rather than asserting after one - // fixed sleep (which is flaky — observed on macOS shared runners). - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - while (last_state.load() != watchdog::HealthState::Faulted && - std::chrono::steady_clock::now() < deadline) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - - REQUIRE(last_state.load() == watchdog::HealthState::Faulted); +// ── Latch persistence / explicit clear ─────────────────────────────────────── + +TEST_CASE("Once latched, check() does not re-detect overflow until clear_safe_state", + "[watchdog][REQ-WDG-004]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + cfg.rx_wd_safestate_enable = true; + + RequestLedger ledger; + StreamWatchdog wd; + wd.kick_from_request(0); + REQUIRE(wd.check(1, cfg, ledger, 200)->overflowed); + REQUIRE(wd.in_safe_state()); + + // Still overdue at a later time, but the latch already fired -- no + // second "overflowed" report (should_emit_info_notification is off + // since rx_wd_info_enable defaults false, so this is nullopt). + REQUIRE_FALSE(wd.check(1, cfg, ledger, 500).has_value()); + + wd.clear_safe_state(); + REQUIRE_FALSE(wd.in_safe_state()); + + // A fresh kick + a fresh overdue interval can latch again. + wd.kick_from_request(500); + auto ev = wd.check(1, cfg, ledger, 700); + REQUIRE(ev.has_value()); + REQUIRE(ev->overflowed); + REQUIRE(wd.in_safe_state()); } -// ── Initial state ───────────────────────────────────────────────────────────── +// ── Repeating info notification ─────────────────────────────────────────────── -TEST_CASE("Watchdog zone starts Healthy", "[watchdog][REQ-WDG-004]") { - auto ctrl = std::make_shared(Zone::FrontLeft); +TEST_CASE("check() reports the repeating info notification while latched with rx_wd_info_enable set", + "[watchdog][REQ-WDG-005]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + cfg.rx_wd_safestate_enable = true; + cfg.rx_wd_info_enable = true; + + RequestLedger ledger; + StreamWatchdog wd; + wd.kick_from_request(0); + + auto overflow_ev = wd.check(1, cfg, ledger, 200); + REQUIRE(overflow_ev.has_value()); + REQUIRE(overflow_ev->info_notification_due); // due immediately on the latching poll too + + // Every subsequent poll while latched repeats the notification. + auto ev2 = wd.check(1, cfg, ledger, 300); + REQUIRE(ev2.has_value()); + REQUIRE_FALSE(ev2->overflowed); // already latched -- this is purely the repeat + REQUIRE(ev2->info_notification_due); + + auto ev3 = wd.check(1, cfg, ledger, 400); + REQUIRE(ev3.has_value()); + REQUIRE(ev3->info_notification_due); +} - watchdog::Config cfg; - cfg.interval = std::chrono::milliseconds(500); // long so no kicks fire - watchdog::Keeper keeper(cfg, {ctrl}); +TEST_CASE("check() reports nothing while latched with rx_wd_info_enable clear", + "[watchdog][REQ-WDG-005]") { + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + cfg.rx_wd_safestate_enable = true; // rx_wd_info_enable defaults false - REQUIRE(keeper.health(Zone::FrontLeft) == watchdog::HealthState::Healthy); + RequestLedger ledger; + StreamWatchdog wd; + wd.kick_from_request(0); + REQUIRE(wd.check(1, cfg, ledger, 200)->overflowed); + REQUIRE_FALSE(wd.check(1, cfg, ledger, 300).has_value()); } -// ── Health recovery ─────────────────────────────────────────────────────────── +// ── Manager: multi-stream independence ──────────────────────────────────────── -TEST_CASE("Watchdog zone stays Healthy when kicks succeed", "[watchdog][REQ-WDG-005][REQ-WDG-006]") { - auto ctrl = std::make_shared(Zone::FrontLeft); +TEST_CASE("Manager tracks each registered stream's watchdog state independently", + "[watchdog][REQ-WDG-006]") { + Manager mgr; + mgr.register_stream(1); + mgr.register_stream(2); - watchdog::Config cfg; - cfg.interval = std::chrono::milliseconds(20); - cfg.timeout = std::chrono::milliseconds(10); - cfg.degrade_after = 2; - cfg.fault_after = 3; + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + cfg.rx_wd_safestate_enable = true; + RequestLedger ledger1; + RequestLedger ledger2; - std::atomic last_state{watchdog::HealthState::Healthy}; - watchdog::Keeper keeper(cfg, {ctrl}); - keeper.subscribe([&](const watchdog::HealthEvent& ev) { - last_state.store(ev.state); - }); + REQUIRE_FALSE(mgr.on_request_received(1, 0)); + // Stream 2 is never kicked at all. - // Wait a few kick cycles — controller responds OK. - std::this_thread::sleep_for(std::chrono::milliseconds(150)); + REQUIRE_FALSE(mgr.poll(1, cfg, ledger1, 50)); // not yet overdue (kicked at t=0, timeout 100ms) + REQUIRE_FALSE(mgr.in_safe_state(1)); - // Should never have transitioned away from Healthy. - REQUIRE(last_state.load() == watchdog::HealthState::Healthy); + REQUIRE_FALSE(mgr.poll(2, cfg, ledger2, 50)); // never kicked, but timeout has not elapsed yet either + REQUIRE_FALSE(mgr.in_safe_state(2)); + + // Stream 1 crosses its deadline (elapsed 150ms > 100ms) and latches; + // stream 2 -- still never kicked, so still not yet overdue at t=50 in + // its own clock frame -- is unaffected. + REQUIRE_FALSE(mgr.poll(1, cfg, ledger1, 150)); + REQUIRE(mgr.in_safe_state(1)); + REQUIRE_FALSE(mgr.in_safe_state(2)); } -// ── Close ───────────────────────────────────────────────────────────────────── +// ── Unregistered stream error handling ─────────────────────────────────────── + +TEST_CASE("Operations on an unregistered stream return stream_not_registered without side effects", + "[watchdog][REQ-WDG-007]") { + Manager mgr; + mgr.register_stream(1); + + RequestStreamConfig cfg; + RequestLedger ledger; + + REQUIRE(mgr.on_request_received(99, 0) == make_error_code(WatchdogErrc::stream_not_registered)); + REQUIRE(mgr.poll(99, cfg, ledger, 0) == make_error_code(WatchdogErrc::stream_not_registered)); + REQUIRE(mgr.clear_safe_state(99) == make_error_code(WatchdogErrc::stream_not_registered)); + REQUIRE_FALSE(mgr.in_safe_state(99)); + + // Stream 1 remains registered and unaffected. + REQUIRE(mgr.is_registered(1)); + REQUIRE_FALSE(mgr.on_request_received(1, 0)); +} + +// ── Subscribed callbacks ────────────────────────────────────────────────────── + +TEST_CASE("Manager::poll invokes every subscribed callback in registration order", + "[watchdog][REQ-WDG-008]") { + Manager mgr; + mgr.register_stream(1); + + RequestStreamConfig cfg; + cfg.rx_wd_enable = true; + cfg.rx_wd_timeout_interval = 100; + RequestLedger ledger; + + std::vector order; + mgr.subscribe([&](const HealthEvent&) { order.push_back(1); }); + mgr.subscribe([&](const HealthEvent&) { order.push_back(2); }); + + REQUIRE_FALSE(mgr.on_request_received(1, 0)); + REQUIRE_FALSE(mgr.poll(1, cfg, ledger, 200)); // overflow -> one HealthEvent -TEST_CASE("Watchdog Keeper::close stops background kicks", "[watchdog][REQ-WDG-007][REQ-WDG-008]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - watchdog::Config cfg; - cfg.interval = std::chrono::milliseconds(50); - watchdog::Keeper keeper(cfg, {ctrl}); - keeper.close(); - // Destructor should join without hanging. + REQUIRE(order == std::vector{1, 2}); }