Skip to main content

nros_node/executor/
monitor.rs

1//! RFC-0052 / phase-296 W3b.4/.5 — on-target contract monitors.
2//!
3//! The baked shape mirrors Phase 211.H's `qos_overrides`: codegen emits a
4//! `&'static [MonitorSpec]` table (plus one `static PubMonitorCell` per
5//! contracted publisher) from the SystemModel's contract layer; the entry
6//! installs it on the executor before entity creation. An uncontracted
7//! image bakes an empty table — every path below dead-code-eliminates.
8//!
9//! Publish counting is an atomic bump on the publisher handle (no clock,
10//! no lock on the hot path); the rate check runs on spin ticks over a
11//! ~[`RATE_CHECK_INTERVAL_US`] window and pushes violations into a small
12//! ring the entry glue drains into the `nros-diagnostics` reporter.
13//!
14//! W3b.5 adds three more rules on the same drain:
15//! - `max-age-runtime` — subscriber take-age (`epoch_now - header.stamp`
16//!   peeked from the raw CDR buffer at [`RosMessage::STAMP_OFFSET`],
17//!   recorded into a [`SubMonitorCell`] on the take path).
18//! - `max-latency-runtime` — node-path (take → publish) latency: the
19//!   dispatch elapsed time is attributed to every monitored publisher
20//!   whose counter advanced during that dispatch (an upper bound on
21//!   take → publish, measured on the executor's monotonic clock).
22//! - `deadline-miss-runtime` — a dispatched callback ran past its bound
23//!   SchedContext's `deadline_us`; what ELSE happens is the tier's
24//!   [`DeadlineAction`](super::sched_context::DeadlineAction).
25
26use core::sync::atomic::Ordering;
27// portable-atomic: RMW ops (fetch_add/fetch_max/swap) exist even on
28// riscv32imc / Cortex-M0+ that lack native CAS (same choice as
29// `SporadicState` / `AtomicSporadicState` in sched_context.rs).
30use portable_atomic::AtomicU32;
31
32/// One contracted publisher's counters. Baked as a `static` by codegen
33/// (or declared by the fixture); the publisher handle bumps `count` on
34/// every publish, the executor reads deltas on spin ticks.
35#[derive(Debug, Default)]
36pub struct PubMonitorCell {
37    pub count: AtomicU32,
38    /// W3b.5 — max observed take→publish latency (µs) in the current
39    /// check window. Written by the dispatch loop (fetch_max), drained
40    /// (swap 0) by the latency check.
41    pub max_latency_us: AtomicU32,
42    /// Age of the stamp this publisher last put ON THE WIRE, in
43    /// microseconds: `epoch_now - outgoing header.stamp`.
44    ///
45    /// Distinct from `max_latency_us`, which times this node's own
46    /// take→publish work. This says how old the DATA is that the node just
47    /// published, which is the quantity a chain is made of.
48    ///
49    /// It exists to answer a question `max-age-runtime` cannot. That rule
50    /// measures `epoch_now - stamp` on the TAKE path, so if every node in a
51    /// chain propagates the original stamp -- the usual ROS convention, each
52    /// node copying its input's stamp to its output -- the age at the final
53    /// consumer already IS the end-to-end latency. If any node re-stamps
54    /// with `now`, the clock silently resets and the same number becomes
55    /// single-hop age instead. Same units, same magnitude, no warning.
56    ///
57    /// A publish age near zero on a node that consumes input is the
58    /// signature of re-stamping. Recording it here is what lets a chain's
59    /// provenance be checked at all, rather than assumed.
60    ///
61    /// `0` = never observed, matching the other cells: the type has no
62    /// `STAMP_OFFSET`, or no epoch source is installed.
63    pub last_publish_stamp_age_us: AtomicU32,
64}
65
66impl PubMonitorCell {
67    pub const fn new() -> Self {
68        Self {
69            count: AtomicU32::new(0),
70            max_latency_us: AtomicU32::new(0),
71            last_publish_stamp_age_us: AtomicU32::new(0),
72        }
73    }
74}
75
76/// One contracted subscriber's take-age accumulator (W3b.5). The take
77/// path records `epoch_now - header.stamp` per message (fetch_max); the
78/// age check drains it (swap 0) per window.
79#[derive(Debug, Default)]
80pub struct SubMonitorCell {
81    /// Max observed take-age (ms) in the current check window.
82    pub max_age_ms: AtomicU32,
83}
84
85impl SubMonitorCell {
86    pub const fn new() -> Self {
87        Self {
88            max_age_ms: AtomicU32::new(0),
89        }
90    }
91
92    /// Take-path hook: record one message's age. `stamp_us` is the
93    /// peeked `header.stamp` as µs since the UNIX epoch, `epoch_now_us`
94    /// the receive-side wall clock. A stamp from the future clamps to 0.
95    pub fn observe(&self, stamp_us: u64, epoch_now_us: u64) {
96        let age_ms = (epoch_now_us.saturating_sub(stamp_us) / 1_000).min(u32::MAX as u64) as u32;
97        self.max_age_ms.fetch_max(age_ms, Ordering::Relaxed);
98    }
99}
100
101/// Peek `Time { i32 sec; u32 nanosec }` little-endian at `offset` in a
102/// raw CDR receive buffer (encapsulation header included) and return µs
103/// since the UNIX epoch. `None` when the buffer is too short or the
104/// stamp is pre-epoch/zero (unstamped messages never fire age monitors).
105/// Record the age of the stamp a publisher just put on the wire.
106///
107/// Called from the publish path with the encoded CDR still in hand, using the
108/// same `STAMP_OFFSET` peek the take path uses. A no-op when the type carries
109/// no stamp, when no epoch source is installed, or when the publisher is
110/// uncontracted -- the same three ways `observe_age` folds away.
111///
112/// Stores rather than accumulates: this is "how old was the last thing
113/// published", a state, not a window maximum. A chain check wants the current
114/// value, and a max would be pinned forever by one stale message at startup.
115#[inline]
116pub fn observe_publish_stamp(cell: &PubMonitorCell, raw: &[u8], offset: usize, now_us: u64) {
117    if let Some(stamp_us) = peek_stamp_us(raw, offset) {
118        let age = now_us.saturating_sub(stamp_us).min(u32::MAX as u64) as u32;
119        cell.last_publish_stamp_age_us.store(age, Ordering::Relaxed);
120    }
121}
122
123pub fn peek_stamp_us(raw: &[u8], offset: usize) -> Option<u64> {
124    let sec_b = raw.get(offset..offset + 4)?;
125    let nsec_b = raw.get(offset + 4..offset + 8)?;
126    let sec = i32::from_le_bytes([sec_b[0], sec_b[1], sec_b[2], sec_b[3]]);
127    let nsec = u32::from_le_bytes([nsec_b[0], nsec_b[1], nsec_b[2], nsec_b[3]]);
128    if sec <= 0 {
129        return None;
130    }
131    Some(sec as u64 * 1_000_000 + nsec as u64 / 1_000)
132}
133
134/// One monitored publisher endpoint.
135#[derive(Debug, Clone, Copy)]
136pub struct MonitorSpec {
137    /// Topic name EXACTLY as the node passes it to `create_publisher`
138    /// (the SystemModel's wiring carries the same resolved name).
139    pub topic: &'static str,
140    /// Endpoint ref for violation reports (`<node FQN>/<endpoint>` — the
141    /// SystemModel contract key).
142    pub fqn: &'static str,
143    /// Declared publisher guarantee, in milli-Hz (fixed point: Hz × 1000).
144    /// 0 = no rate contract on this endpoint.
145    pub min_rate_hz_milli: u32,
146    /// W3b.5 — node-path budget (ms) for paths whose OUTPUT is this
147    /// endpoint (`contracts.node_paths[..].max_latency_ms`). 0 = no
148    /// latency contract.
149    pub max_latency_ms: u32,
150    /// The endpoint's counter cell.
151    pub cell: &'static PubMonitorCell,
152}
153
154/// One monitored subscriber endpoint (W3b.5 age contracts). Separate
155/// table from [`MonitorSpec`] — sub contracts key different endpoints
156/// and need no publish counter.
157#[derive(Debug, Clone, Copy)]
158pub struct AgeMonitorSpec {
159    /// Topic name EXACTLY as the node passes it to `create_subscription`.
160    pub topic: &'static str,
161    /// Endpoint ref for violation reports (the SystemModel contract key).
162    pub fqn: &'static str,
163    /// Declared max take-age (ms). 0 = no age contract.
164    pub max_age_ms: u32,
165    /// The endpoint's age accumulator.
166    pub cell: &'static SubMonitorCell,
167}
168
169/// Rate-check window (µs). Matches play_launch's ~5 s time-based trigger
170/// so both runtimes converge on comparable cadence.
171pub const RATE_CHECK_INTERVAL_US: u64 = 5_000_000;
172
173/// Max monitored endpoints per executor (const table, no_std).
174pub const MAX_MONITORS: usize = 8;
175/// Violation ring depth.
176pub const MAX_VIOLATIONS: usize = 8;
177
178/// A detected contract violation, in the play_launch rule-id vocabulary.
179#[derive(Debug, Clone)]
180pub struct Violation {
181    /// `"rate-hierarchy-runtime"` | `"max-age-runtime"` |
182    /// `"max-latency-runtime"` | `"deadline-miss-runtime"` |
183    /// `"timer-overrun-runtime"` | `"release-jitter-runtime"` |
184    /// `"stack-headroom-runtime"`.
185    pub rule: &'static str,
186    /// Violating endpoint ref (from the spec's `fqn`; the SC name for
187    /// deadline misses).
188    pub fqn: &'static str,
189    /// Measured value. Unit is per-rule: milli-Hz for the rate rule, ms
190    /// for age/latency, µs for deadline misses, dropped activations for
191    /// the timer-overrun rule.
192    pub measured: u32,
193    /// Declared bound, same unit as `measured`.
194    pub declared: u32,
195}
196
197/// Per-spec accounting state (parallel to the spec table).
198#[derive(Debug, Clone, Copy, Default)]
199pub(crate) struct MonitorState {
200    /// Window opened (a plain bool, not a 0-sentinel on the timestamp —
201    /// `now_us == 0` is a legitimate first sample on freshly-started
202    /// monotonic clocks).
203    pub(crate) opened: bool,
204    pub(crate) window_start_us: u64,
205    pub(crate) count_at_window_start: u32,
206    /// Suppress duplicate reports: only re-report after a clean window.
207    pub(crate) violated_last_window: bool,
208    /// W3b.5 — separate dedup for the latency rule on the same spec row.
209    pub(crate) latency_violated_last_window: bool,
210}
211
212/// Pure rate check over one window boundary. Returns `Some(violation)`
213/// when the window elapsed AND the measured rate is below the declared
214/// minimum (and we didn't already report last window).
215///
216/// Extracted from the executor so the math is unit-testable without a
217/// session: publish counting is injected via the cell, time via `now_us`.
218pub(crate) fn check_rate(
219    spec: &MonitorSpec,
220    state: &mut MonitorState,
221    now_us: u64,
222) -> Option<Violation> {
223    if spec.min_rate_hz_milli == 0 {
224        return None;
225    }
226    let count = spec.cell.count.load(Ordering::Relaxed);
227    if !state.opened {
228        // First observation: open the window, no verdict yet.
229        state.opened = true;
230        state.window_start_us = now_us;
231        state.count_at_window_start = count;
232        return None;
233    }
234    let window_us = now_us.saturating_sub(state.window_start_us);
235    if window_us < RATE_CHECK_INTERVAL_US {
236        return None;
237    }
238    let published = count.wrapping_sub(state.count_at_window_start) as u64;
239    // milli-Hz = published * 1e3 / window_s = published * 1e9 / window_us
240    let measured_milli_hz =
241        (published.saturating_mul(1_000_000_000) / window_us.max(1)).min(u32::MAX as u64) as u32;
242
243    // Roll the window.
244    state.window_start_us = now_us;
245    state.count_at_window_start = count;
246
247    if measured_milli_hz < spec.min_rate_hz_milli {
248        if state.violated_last_window {
249            return None; // still violated — already reported
250        }
251        state.violated_last_window = true;
252        Some(Violation {
253            rule: "rate-hierarchy-runtime",
254            fqn: spec.fqn,
255            measured: measured_milli_hz,
256            declared: spec.min_rate_hz_milli,
257        })
258    } else {
259        state.violated_last_window = false;
260        None
261    }
262}
263
264/// Pure latency check: drains the spec cell's window-max take→publish
265/// latency and fires when it exceeds the declared node-path budget.
266/// Same report-once-until-recovery semantics as the rate rule; runs on
267/// every monitor tick (the cell accumulates between ticks, so no window
268/// bookkeeping is needed — draining IS the window roll).
269pub(crate) fn check_latency(spec: &MonitorSpec, state: &mut MonitorState) -> Option<Violation> {
270    if spec.max_latency_ms == 0 {
271        return None;
272    }
273    let max_us = spec.cell.max_latency_us.swap(0, Ordering::Relaxed);
274    let max_ms = max_us / 1_000;
275    if max_ms > spec.max_latency_ms {
276        if state.latency_violated_last_window {
277            return None;
278        }
279        state.latency_violated_last_window = true;
280        Some(Violation {
281            rule: "max-latency-runtime",
282            fqn: spec.fqn,
283            measured: max_ms,
284            declared: spec.max_latency_ms,
285        })
286    } else {
287        // A quiet window (no dispatch attributed) also counts as clean —
288        // recovery resets the dedup like the rate rule's clean window.
289        state.latency_violated_last_window = false;
290        None
291    }
292}
293
294/// Per-age-spec dedup state.
295#[derive(Debug, Clone, Copy, Default)]
296pub(crate) struct AgeState {
297    pub(crate) violated_last_window: bool,
298}
299
300/// Pure age check: drains the sub cell's window-max take-age and fires
301/// when it exceeds the declared bound. Report-once-until-recovery.
302pub(crate) fn check_age(spec: &AgeMonitorSpec, state: &mut AgeState) -> Option<Violation> {
303    if spec.max_age_ms == 0 {
304        return None;
305    }
306    let max_ms = spec.cell.max_age_ms.swap(0, Ordering::Relaxed);
307    if max_ms > spec.max_age_ms {
308        if state.violated_last_window {
309            return None;
310        }
311        state.violated_last_window = true;
312        Some(Violation {
313            rule: "max-age-runtime",
314            fqn: spec.fqn,
315            measured: max_ms,
316            declared: spec.max_age_ms,
317        })
318    } else {
319        state.violated_last_window = false;
320        None
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    static CELL: PubMonitorCell = PubMonitorCell::new();
329
330    fn spec(min_milli: u32) -> MonitorSpec {
331        MonitorSpec {
332            topic: "/chatter",
333            fqn: "/demo/talker/chatter",
334            min_rate_hz_milli: min_milli,
335            max_latency_ms: 0,
336            cell: &CELL,
337        }
338    }
339
340    #[test]
341    fn slow_publisher_fires_once_until_recovery() {
342        CELL.count.store(0, Ordering::Relaxed);
343        let s = spec(100_000); // 100 Hz declared
344        let mut st = MonitorState::default();
345
346        // t=0: opens window.
347        assert!(check_rate(&s, &mut st, 0).is_none());
348        // 5 publishes in 5 s = 1 Hz — violation.
349        CELL.count.store(5, Ordering::Relaxed);
350        let v = check_rate(&s, &mut st, RATE_CHECK_INTERVAL_US).expect("fires");
351        assert_eq!(v.rule, "rate-hierarchy-runtime");
352        assert_eq!(v.fqn, "/demo/talker/chatter");
353        assert_eq!(v.measured, 1_000);
354        assert_eq!(v.declared, 100_000);
355        // Still slow next window — suppressed (no re-report spam).
356        CELL.count.store(10, Ordering::Relaxed);
357        assert!(check_rate(&s, &mut st, 2 * RATE_CHECK_INTERVAL_US).is_none());
358        // Recovers (500 publishes in 5 s = 100 Hz) — clean window resets.
359        CELL.count.store(510, Ordering::Relaxed);
360        assert!(check_rate(&s, &mut st, 3 * RATE_CHECK_INTERVAL_US).is_none());
361        // Degrades again — fires again.
362        CELL.count.store(511, Ordering::Relaxed);
363        assert!(check_rate(&s, &mut st, 4 * RATE_CHECK_INTERVAL_US).is_some());
364    }
365
366    #[test]
367    fn compliant_and_uncontracted_stay_silent() {
368        static C2: PubMonitorCell = PubMonitorCell::new();
369        let s = MonitorSpec {
370            topic: "/t",
371            fqn: "/n/t",
372            min_rate_hz_milli: 500, // 0.5 Hz
373            max_latency_ms: 0,
374            cell: &C2,
375        };
376        let mut st = MonitorState::default();
377        assert!(check_rate(&s, &mut st, 0).is_none());
378        C2.count.store(5, Ordering::Relaxed); // 1 Hz measured ≥ 0.5 Hz declared
379        assert!(check_rate(&s, &mut st, RATE_CHECK_INTERVAL_US).is_none());
380
381        // min_rate 0 = uncontracted: never fires, no state.
382        let s0 = MonitorSpec {
383            topic: "/t",
384            fqn: "/n/t",
385            min_rate_hz_milli: 0,
386            max_latency_ms: 0,
387            cell: &C2,
388        };
389        let mut st0 = MonitorState::default();
390        assert!(check_rate(&s0, &mut st0, 10 * RATE_CHECK_INTERVAL_US).is_none());
391    }
392
393    #[test]
394    fn stale_take_fires_age_once_until_recovery() {
395        static SC: SubMonitorCell = SubMonitorCell::new();
396        let s = AgeMonitorSpec {
397            topic: "/scan",
398            fqn: "/perc/detector/scan",
399            max_age_ms: 100,
400            cell: &SC,
401        };
402        let mut st = AgeState::default();
403
404        // Fresh message: stamped 5 ms ago — silent.
405        SC.observe(1_000_000_000, 1_000_005_000);
406        assert!(check_age(&s, &mut st).is_none());
407        // Stale: 250 ms old — fires with the measured age.
408        SC.observe(1_000_000_000, 1_000_250_000);
409        let v = check_age(&s, &mut st).expect("fires");
410        assert_eq!(v.rule, "max-age-runtime");
411        assert_eq!(v.fqn, "/perc/detector/scan");
412        assert_eq!(v.measured, 250);
413        assert_eq!(v.declared, 100);
414        // Still stale next window — suppressed.
415        SC.observe(1_000_000_000, 1_000_300_000);
416        assert!(check_age(&s, &mut st).is_none());
417        // Recovers — clean window resets; stale again refires.
418        SC.observe(1_000_000_000, 1_000_010_000);
419        assert!(check_age(&s, &mut st).is_none());
420        SC.observe(1_000_000_000, 1_000_999_000);
421        assert!(check_age(&s, &mut st).is_some());
422    }
423
424    #[test]
425    fn peek_stamp_reads_le_time_and_rejects_unstamped() {
426        // Encapsulation header (4B) + sec=100 nsec=5000 at offset 4.
427        let mut raw = [0u8; 12];
428        raw[4..8].copy_from_slice(&100i32.to_le_bytes());
429        raw[8..12].copy_from_slice(&5_000u32.to_le_bytes());
430        assert_eq!(peek_stamp_us(&raw, 4), Some(100_000_005));
431        // Zero / negative sec = unstamped: no age sample.
432        assert_eq!(peek_stamp_us(&[0u8; 12], 4), None);
433        // Short buffer: no panic, no sample.
434        assert_eq!(peek_stamp_us(&raw[..8], 4), None);
435    }
436
437    #[test]
438    fn slow_path_fires_latency_once_until_recovery() {
439        static C3: PubMonitorCell = PubMonitorCell::new();
440        let s = MonitorSpec {
441            topic: "/cmd",
442            fqn: "/ctrl/control/cmd",
443            min_rate_hz_milli: 0,
444            max_latency_ms: 10,
445            cell: &C3,
446        };
447        let mut st = MonitorState::default();
448        // 4 ms dispatch — within budget.
449        C3.max_latency_us.store(4_000, Ordering::Relaxed);
450        assert!(check_latency(&s, &mut st).is_none());
451        assert_eq!(C3.max_latency_us.load(Ordering::Relaxed), 0, "drained");
452        // 25 ms dispatch — fires.
453        C3.max_latency_us.store(25_000, Ordering::Relaxed);
454        let v = check_latency(&s, &mut st).expect("fires");
455        assert_eq!(v.rule, "max-latency-runtime");
456        assert_eq!(v.measured, 25);
457        assert_eq!(v.declared, 10);
458        // Still slow — suppressed; recovery resets.
459        C3.max_latency_us.store(30_000, Ordering::Relaxed);
460        assert!(check_latency(&s, &mut st).is_none());
461        C3.max_latency_us.store(1_000, Ordering::Relaxed);
462        assert!(check_latency(&s, &mut st).is_none());
463        C3.max_latency_us.store(30_000, Ordering::Relaxed);
464        assert!(check_latency(&s, &mut st).is_some());
465    }
466}
467
468/// Issue #514 — emit one violation to the log.
469///
470/// A log line is deliberately the floor rather than a `/diagnostics`
471/// publication: it needs no publisher, no topic wiring, and no contract
472/// on the reporting path itself, so it works on a bare RTOS image and
473/// during boot. Publishing the same verdicts as `DiagnosticArray`
474/// belongs on top of this, not instead of it.
475///
476/// Free function rather than an `Executor` method because every call
477/// site sits inside a loop that already borrows the executor's spec
478/// tables.
479pub(crate) fn log_violation(v: &Violation) {
480    nros_log::nros_warn!(
481        nros_log::get_logger("nros"),
482        "contract violation: {} {} measured={} declared={}",
483        v.rule,
484        v.fqn,
485        v.measured,
486        v.declared
487    );
488}
489
490/// Issue #505 — periodic activations a timer dropped because its
491/// executor was blocked past the period boundary.
492///
493/// This rule exists because `check_rate` cannot see an isolated stall:
494/// it samples publish counts over a ~5 s window, so 20 missed
495/// activations of a 100 Hz loop are a 0.4% rate deficit — under any
496/// sane declared minimum, silence. (And under
497/// [`TimerOverrunPolicy::CatchUp`](super::arena::TimerOverrunPolicy)
498/// the replayed activations refill the window entirely, so the rate
499/// rule reports a HEALTHY loop while the tier is stalling.) The
500/// overrun counter is exact, needs no window, and does not depend on
501/// clock resolution.
502///
503/// `overruns` is the timer's monotonic saturating counter;
504/// `last_reported` is the value at the previous check, so the verdict
505/// is on the delta. Returns a violation when more than `tolerated`
506/// activations were dropped since the last check.
507pub(crate) fn check_timer_overrun(
508    overruns: u32,
509    last_reported: &mut u32,
510    tolerated: u32,
511) -> Option<Violation> {
512    let dropped = overruns.saturating_sub(*last_reported);
513    *last_reported = overruns;
514    if dropped <= tolerated {
515        return None;
516    }
517    Some(Violation {
518        rule: "timer-overrun-runtime",
519        // Timer entries carry no name at this altitude; same stand-in
520        // as `deadline-miss-runtime`.
521        fqn: "timer",
522        measured: dropped,
523        declared: tolerated,
524    })
525}
526
527/// Issue #515 — report a spin wake that arrived so late the cadence it
528/// claims cannot have been met.
529///
530/// Like `check_timer_overrun` and unlike the rate/age/latency rules, this
531/// needs no baked spec table. The bound is the spin period ITSELF: the
532/// caller passes it to `spin_once` as the pacing quantum, it is what
533/// `system.toml` declares as `spin_period_us`, and a wake later than a full
534/// period past its predecessor means an activation's worth of cadence was
535/// lost. That is a contract failure for any declared period, so there is
536/// nothing further to declare.
537///
538/// The tolerance is one whole period rather than zero, and deliberately so.
539/// Sub-period lateness is ordinary scheduling noise -- on the measured FVP
540/// lane the executor is late on a large fraction of wakes while still
541/// holding its rate -- and a rule that fired on each one would report a
542/// healthy system as broken thousands of times a second. What is NOT
543/// ordinary is being a full period late, because by then the wake that
544/// should have happened in between never did.
545///
546/// `max_jitter_us` is the executor's high-water since the last check and
547/// `last_reported` the value at the previous one, so the verdict is on the
548/// delta -- the same shape as the overrun counter, and for the same reason:
549/// a maximum that has not moved is not a new fault.
550pub(crate) fn check_release_jitter(
551    max_jitter_us: u64,
552    last_reported: &mut u64,
553    period_us: u64,
554) -> Option<Violation> {
555    if period_us == 0 || max_jitter_us <= *last_reported {
556        return None;
557    }
558    *last_reported = max_jitter_us;
559    if max_jitter_us < period_us {
560        return None;
561    }
562    Some(Violation {
563        rule: "release-jitter-runtime",
564        // Same stand-in as the timer and deadline rules: the spin loop is
565        // not an endpoint and carries no fqn at this altitude.
566        fqn: "spin",
567        measured: max_jitter_us.min(u32::MAX as u64) as u32,
568        declared: period_us.min(u32::MAX as u64) as u32,
569    })
570}
571
572/// Report a spin thread whose stack has come closer to its end than the
573/// declared minimum.
574///
575/// Unlike every other rule here the bound CANNOT be derived from something
576/// already declared, and that is worth stating rather than papering over.
577/// `check_timer_overrun` and `check_release_jitter` both judge against a
578/// period the caller already passes in; there is no equivalent for a stack.
579/// The executor never sees `stack_bytes` -- it lives in the spawn attr and
580/// goes no further -- and the total is not portably queryable either:
581/// FreeRTOS exposes the high-water mark and not the size it was taken
582/// against, so even a percentage cannot be computed. A minimum headroom is
583/// therefore a real declaration, and `min_bytes == 0` means the caller has
584/// not made one, which disables the rule.
585///
586/// Reports on the WORST case, not on each crossing: `worst_reported` holds
587/// the lowest headroom already reported, so a stack hovering just under the
588/// bound says so once and then only when it gets worse. The same delta
589/// discipline as the overrun and jitter rules, inverted because for headroom
590/// smaller is worse.
591pub(crate) fn check_stack_headroom(
592    unused_bytes: usize,
593    min_bytes: usize,
594    worst_reported: &mut usize,
595) -> Option<Violation> {
596    if min_bytes == 0 || unused_bytes >= min_bytes {
597        return None;
598    }
599    // `usize::MAX` is the "nothing reported yet" sentinel: any real headroom
600    // is below it, so the first breach always reports.
601    if *worst_reported != usize::MAX && unused_bytes >= *worst_reported {
602        return None;
603    }
604    *worst_reported = unused_bytes;
605    Some(Violation {
606        rule: "stack-headroom-runtime",
607        // The spin thread is not an endpoint; same stand-in as the timer,
608        // deadline and jitter rules.
609        fqn: "stack",
610        measured: unused_bytes.min(u32::MAX as usize) as u32,
611        declared: min_bytes.min(u32::MAX as usize) as u32,
612    })
613}
614
615#[cfg(test)]
616mod stack_headroom_rule_tests {
617    use super::*;
618
619    /// No declared minimum means no claim to breach.
620    #[test]
621    fn a_zero_minimum_disables_the_rule() {
622        let mut worst = usize::MAX;
623        assert!(check_stack_headroom(8, 0, &mut worst).is_none());
624    }
625
626    #[test]
627    fn headroom_at_the_bound_is_not_a_breach() {
628        let mut worst = usize::MAX;
629        assert!(check_stack_headroom(1024, 1024, &mut worst).is_none());
630        assert!(check_stack_headroom(2048, 1024, &mut worst).is_none());
631    }
632
633    #[test]
634    fn reports_the_first_breach_with_both_numbers() {
635        let mut worst = usize::MAX;
636        let v = check_stack_headroom(512, 1024, &mut worst).expect("under the bound reports");
637        assert_eq!(v.rule, "stack-headroom-runtime");
638        assert_eq!(v.measured, 512);
639        assert_eq!(v.declared, 1024);
640    }
641
642    /// Smaller is worse for headroom, so the delta runs the other way.
643    #[test]
644    fn only_a_new_low_is_a_new_fault() {
645        let mut worst = usize::MAX;
646        assert!(check_stack_headroom(512, 1024, &mut worst).is_some());
647        assert!(check_stack_headroom(512, 1024, &mut worst).is_none());
648        assert!(check_stack_headroom(600, 1024, &mut worst).is_none());
649        let v = check_stack_headroom(100, 1024, &mut worst).expect("a new low reports");
650        assert_eq!(v.measured, 100);
651    }
652}
653
654#[cfg(test)]
655mod publish_stamp_tests {
656    use super::*;
657
658    /// CDR: 4-byte encapsulation header, then `Time { i32 sec; u32 nanosec }`
659    /// little-endian, so `sec` sits at byte 4 — the layout `STAMP_OFFSET`
660    /// encodes.
661    fn cdr_with_stamp(sec: i32, nanosec: u32) -> [u8; 12] {
662        let mut b = [0u8; 12];
663        b[4..8].copy_from_slice(&sec.to_le_bytes());
664        b[8..12].copy_from_slice(&nanosec.to_le_bytes());
665        b
666    }
667
668    #[test]
669    fn records_the_age_of_what_was_published() {
670        let cell = PubMonitorCell::new();
671        let raw = cdr_with_stamp(10, 0); // stamped at 10_000_000 us
672        observe_publish_stamp(&cell, &raw, 4, 10_500_000);
673        assert_eq!(
674            cell.last_publish_stamp_age_us.load(Ordering::Relaxed),
675            500_000,
676            "published data was half a second old"
677        );
678    }
679
680    /// The signature of a node that RE-STAMPED: it publishes data whose
681    /// stamp is now, so downstream age is single-hop, not end-to-end.
682    #[test]
683    fn a_restamping_node_shows_near_zero_age() {
684        let cell = PubMonitorCell::new();
685        let raw = cdr_with_stamp(10, 0);
686        observe_publish_stamp(&cell, &raw, 4, 10_000_000);
687        assert_eq!(cell.last_publish_stamp_age_us.load(Ordering::Relaxed), 0);
688    }
689
690    /// A state, not a window maximum: one stale message at startup must not
691    /// pin the value for the life of the process.
692    #[test]
693    fn the_latest_publish_replaces_the_previous() {
694        let cell = PubMonitorCell::new();
695        observe_publish_stamp(&cell, &cdr_with_stamp(10, 0), 4, 12_000_000);
696        assert_eq!(
697            cell.last_publish_stamp_age_us.load(Ordering::Relaxed),
698            2_000_000
699        );
700        observe_publish_stamp(&cell, &cdr_with_stamp(20, 0), 4, 20_100_000);
701        assert_eq!(
702            cell.last_publish_stamp_age_us.load(Ordering::Relaxed),
703            100_000,
704            "a fresh publish replaces the old age rather than maxing with it"
705        );
706    }
707
708    /// An unset stamp is not an age of `now`. `peek_stamp_us` rejects
709    /// `sec <= 0`, so a zeroed header records nothing at all.
710    #[test]
711    fn an_unstamped_message_records_nothing() {
712        let cell = PubMonitorCell::new();
713        observe_publish_stamp(&cell, &cdr_with_stamp(0, 0), 4, 5_000_000);
714        assert_eq!(
715            cell.last_publish_stamp_age_us.load(Ordering::Relaxed),
716            0,
717            "no stamp means no observation, not an enormous age"
718        );
719    }
720}
721
722#[cfg(test)]
723mod release_jitter_rule_tests {
724    use super::*;
725
726    /// Sub-period lateness is noise, not a violation -- otherwise a
727    /// healthy-but-jittery loop reports thousands of faults a second.
728    #[test]
729    fn tolerates_lateness_within_one_period() {
730        let mut last = 0;
731        assert!(check_release_jitter(4_000, &mut last, 5_000).is_none());
732        assert_eq!(last, 4_000, "still recorded, so the next delta is honest");
733    }
734
735    /// A full period late means the wake that belonged in between never
736    /// happened.
737    #[test]
738    fn reports_a_wake_a_whole_period_late() {
739        let mut last = 0;
740        let v = check_release_jitter(5_000, &mut last, 5_000).expect("one period late reports");
741        assert_eq!(v.rule, "release-jitter-runtime");
742        assert_eq!(v.measured, 5_000);
743        assert_eq!(v.declared, 5_000);
744    }
745
746    /// The verdict is on the DELTA. A high-water that has not moved is the
747    /// same fault already reported, not a new one.
748    #[test]
749    fn an_unchanged_maximum_is_not_a_new_fault() {
750        let mut last = 0;
751        assert!(check_release_jitter(9_000, &mut last, 5_000).is_some());
752        assert!(check_release_jitter(9_000, &mut last, 5_000).is_none());
753        assert!(check_release_jitter(12_000, &mut last, 5_000).is_some());
754    }
755
756    /// No declared period means no cadence to be late for.
757    #[test]
758    fn a_zero_period_declares_nothing() {
759        let mut last = 0;
760        assert!(check_release_jitter(1_000_000, &mut last, 0).is_none());
761    }
762}
763
764#[cfg(test)]
765mod timer_overrun_rule_tests {
766    use super::*;
767
768    #[test]
769    fn reports_the_delta_not_the_total() {
770        let mut last = 0;
771        let v = check_timer_overrun(19, &mut last, 0).expect("first drop reports");
772        assert_eq!(v.rule, "timer-overrun-runtime");
773        assert_eq!(v.measured, 19);
774        // Same total on the next check is not a new fault.
775        assert!(check_timer_overrun(19, &mut last, 0).is_none());
776        // Only the newly dropped activations are reported.
777        assert_eq!(check_timer_overrun(25, &mut last, 0).unwrap().measured, 6);
778    }
779
780    #[test]
781    fn a_clean_timer_never_reports() {
782        let mut last = 0;
783        for _ in 0..10 {
784            assert!(check_timer_overrun(0, &mut last, 0).is_none());
785        }
786    }
787
788    #[test]
789    fn tolerance_suppresses_small_drops() {
790        let mut last = 0;
791        assert!(check_timer_overrun(2, &mut last, 2).is_none());
792        assert_eq!(check_timer_overrun(6, &mut last, 2).unwrap().measured, 4);
793    }
794}