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}