nros_node/executor/sched_context.rs
1//! Phase 110.B — `SchedContext` API + supporting types.
2//!
3//! A `SchedContext` is a first-class scheduling capability. Multiple
4//! callbacks share one SC; one OS priority slot per Executor regardless
5//! of callback count. Inspired by seL4 MCS (Mixed-Criticality
6//! Scheduling).
7//!
8//! 110.B.a (this commit) lands the type surface + `EdfReadySet`. The
9//! Executor builder methods (`create_sched_context`,
10//! ~~`register_subscription_in`~~, ...) and the cbindgen / C / C++ wrappers
11//! land in 110.B.b once the const-generic `Executor<MAX_HANDLES,
12//! MAX_SC>` reshape is sorted.
13
14use core::num::NonZeroU32;
15
16/// Optional time field with a sentinel `0` for "absent".
17///
18/// Phase 110.B keeps a stable `#[repr(transparent)]` u32 layout so
19/// cbindgen emits plain `uint32_t` for C consumers — `Option<NonZeroU32>`
20/// loses its niche optimization the moment a `#[repr(C)]` struct
21/// embeds it. Rust callers see the ergonomic
22/// [`get`](OptUs::get)-returning-`Option<NonZeroU32>` getter.
23///
24/// Sentinel `0` is physically meaningful for every time field on
25/// [`SchedContext`]: 0-period would mean infinite frequency, 0-budget
26/// means unbounded, 0-deadline means no deadline.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28#[repr(transparent)]
29pub struct OptUs(u32);
30
31impl OptUs {
32 pub const NONE: Self = Self(0);
33
34 pub const fn from_us(us: u32) -> Self {
35 Self(us)
36 }
37
38 pub const fn from_nz(nz: NonZeroU32) -> Self {
39 Self(nz.get())
40 }
41
42 /// Returns the inner value or `None` when the sentinel is set.
43 pub const fn get(self) -> Option<NonZeroU32> {
44 NonZeroU32::new(self.0)
45 }
46
47 pub const fn is_some(self) -> bool {
48 self.0 != 0
49 }
50
51 pub const fn raw(self) -> u32 {
52 self.0
53 }
54}
55
56/// Scheduling class — picks the runtime queue + selection policy for
57/// the contained callbacks.
58///
59/// Phase 110.A only exercises `Fifo`; `Edf` lands with the
60/// `EdfReadySet` plumb-up in 110.B.b; `Sporadic` is post-v1 (110.E);
61/// time-triggered windows are not a class but a per-`SchedContext` gate
62/// (`tt_window_offset_us` / `tt_window_duration_us`, 110.G).
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub enum SchedClass {
65 #[default]
66 Fifo,
67 Edf,
68 Sporadic,
69 BestEffort,
70}
71
72/// Criticality bucket for [`SchedContext`]. Phase 110.C uses this to
73/// pick which `RankedFifoSet` / `BucketedEdfSet` slot a callback
74/// dispatches through; later phases (110.D) map it to OS priority.
75///
76/// Default `Normal` keeps existing single-bucket workloads unchanged
77/// — every default-Fifo SC sits in `Normal`, so dispatch order is
78/// bit-identical to pre-110.C when no callback opts in to `Critical`
79/// or `BestEffort`.
80///
81/// Single-thread non-preemption note: a `BestEffort` callback already
82/// running blocks `Critical` work that becomes ready mid-cycle. Hard-
83/// RT scenarios need 110.D's multi-executor preemption.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Ord, PartialOrd)]
85pub enum Priority {
86 /// Highest-priority bucket. Drained first within a single
87 /// `spin_once` cycle; non-preemptive against in-flight lower-
88 /// priority callbacks (see Phase 110.D for preemption).
89 Critical = 0,
90 /// Default bucket. Most callbacks (and the auto-default Fifo SC)
91 /// live here.
92 #[default]
93 Normal = 1,
94 /// Lowest-priority bucket. Drained last; first to be skipped if a
95 /// future cycle-budget overrun forces an early return.
96 BestEffort = 2,
97}
98
99impl Priority {
100 pub const COUNT: usize = 3;
101
102 pub const fn index(self) -> usize {
103 self as usize
104 }
105
106 /// phase-509 P2 (RFC-0108 D2) -- the dispatch level of a callback whose
107 /// sched context is in this bucket and whose node holds contract `rank`
108 /// (0 = most urgent). Smaller levels dispatch first; equal levels keep
109 /// registration order.
110 ///
111 /// The three buckets are three RESERVED ranks around the contract's: a
112 /// `Critical` callback is level 0 and a `BestEffort` one level
113 /// [`DISPATCH_LEVELS`]` - 1` whatever their rank, and a `Normal` one (the
114 /// default) is `1 + rank`, clamped to [`MAX_CONTRACT_RANK`]. So the C and
115 /// C++ `Priority` API orders exactly as before, and an image whose nodes
116 /// hold one rank dispatches in exactly today's order.
117 pub const fn dispatch_level(self, rank: u8) -> u8 {
118 match self {
119 Priority::Critical => 0,
120 Priority::Normal => {
121 1 + if rank > MAX_CONTRACT_RANK {
122 MAX_CONTRACT_RANK
123 } else {
124 rank
125 }
126 }
127 Priority::BestEffort => (DISPATCH_LEVELS - 1) as u8,
128 }
129 }
130
131 /// The dispatch levels this bucket spans, `lo..hi`.
132 pub const fn level_range(self) -> (u8, u8) {
133 (self.dispatch_level(0), self.dispatch_level(u8::MAX) + 1)
134 }
135
136 /// The bucket at `index` ([`Self::index`]'s inverse); `Normal` past the end.
137 pub const fn from_index(index: usize) -> Self {
138 match index {
139 0 => Priority::Critical,
140 2 => Priority::BestEffort,
141 _ => Priority::Normal,
142 }
143 }
144}
145
146/// phase-509 P2 -- the number of dispatch levels one executor orders by: one
147/// per bit of the ready set's 32-bit presence word.
148pub const DISPATCH_LEVELS: usize = 32;
149
150/// phase-509 P2 -- the largest contract rank a `Normal` callback keeps. Levels
151/// 0 and 31 are the reserved `Critical` and `BestEffort` ranks, so the
152/// contract's ranks are levels 1..=30; a rank past this one shares the last.
153pub const MAX_CONTRACT_RANK: u8 = (DISPATCH_LEVELS - 3) as u8;
154
155/// How an EDF deadline is interpreted relative to a callback firing.
156///
157/// - `Released`: deadline is release_time + period. Default for
158/// timer-triggered callbacks.
159/// - `Activated`: deadline is activation_time + relative_deadline.
160/// Default for event-triggered subscriptions.
161/// - `Inherited`: deadline travels in the message header — latency-
162/// aware pipelines extract it per-message at dispatch time.
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
164pub enum DeadlinePolicy {
165 Released,
166 #[default]
167 Activated,
168 Inherited,
169}
170
171/// RFC-0052 / phase-296 W3b.5 — what the executor DOES when a dispatched
172/// callback runs past its bound SC's `deadline_us`. Distinct from
173/// [`DeadlinePolicy`] (which says where the deadline COMES from); this is
174/// the miss REACTION, lowered from the tier table's `deadline_policy`
175/// string (`ignore | warn | skip | fault`).
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub enum DeadlineAction {
178 /// Measure nothing, report nothing (uncontracted default).
179 #[default]
180 Ignore,
181 /// Push a `deadline-miss-runtime` violation onto the monitor drain.
182 Warn,
183 /// Warn AND skip the offending SC's remaining callbacks for the rest
184 /// of this spin cycle (damage containment: a runaway callback does
185 /// not get to also starve unrelated groups with its siblings).
186 Skip,
187 /// Warn AND invoke the executor's fault hook (panic when none is
188 /// registered — on embedded targets that is a watchdog-visible stop).
189 Fault,
190}
191
192impl DeadlineAction {
193 /// Lower the tier-table string (`[tasks.<t>].deadline_policy`).
194 /// Unknown strings map to `Ignore` — the bake already validated the
195 /// vocabulary; runtime tolerance here avoids a boot-time panic path.
196 ///
197 /// phase-462 W2 -- that string is no longer authored per tier and nothing
198 /// else. The launch contract's `on_violation` is now its SOURCE
199 /// (`nros-orchestration-ir::violation_agreement`, applied in
200 /// `plan_from_model`), and a tier table that states a different word is a
201 /// resolve-time refusal naming both. So these four variants are what the
202 /// contract's safety vocabulary reaches the target AS: the word in the
203 /// YAML file becomes something the executor does.
204 pub fn from_tier_str(s: &str) -> Self {
205 match s {
206 "warn" => Self::Warn,
207 "skip" => Self::Skip,
208 "fault" => Self::Fault,
209 _ => Self::Ignore,
210 }
211 }
212
213 /// The tier-table spelling of this action -- the inverse of
214 /// [`from_tier_str`](Self::from_tier_str).
215 ///
216 /// The bake and the runtime share no type (this crate is `no_std` and
217 /// knows nothing of the model), so the STRING is the interface between
218 /// `ViolationAction::as_tier_str` on the bake side and this. Spelling it
219 /// here rather than only on the bake side is what lets both sides pin the
220 /// same four words in a test.
221 pub const fn as_tier_str(self) -> &'static str {
222 match self {
223 Self::Ignore => "ignore",
224 Self::Warn => "warn",
225 Self::Skip => "skip",
226 Self::Fault => "fault",
227 }
228 }
229}
230
231/// Identifier for a [`SchedContext`] registered with an Executor.
232/// 110.B.b adds storage `[Option<SchedContext>; MAX_SC]`; this index
233/// addresses into that array.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
235pub struct SchedContextId(pub u8);
236
237/// First-class scheduling capability — one SC per scheduling concern,
238/// shared by every callback that should run under the same budget /
239/// period / deadline / class.
240///
241/// Phase 110.B.a defines the shape; 110.B.b's builder methods on
242/// Executor consume it.
243#[derive(Debug, Clone, Copy, Default)]
244pub struct SchedContext {
245 pub class: SchedClass,
246 pub priority: Priority,
247 pub period_us: OptUs,
248 pub budget_us: OptUs,
249 pub deadline_us: OptUs,
250 pub deadline_policy: DeadlinePolicy,
251 /// Phase 110.F — the OS-level priority this context's callbacks are meant
252 /// to run at: generated entries record their tier's kernel priority here
253 /// (`nros_cpp_create_sched_context_from_policy`). `0` (default) means none.
254 ///
255 /// Recorded, not acted on by the executor. The opt-in per-callback worker
256 /// pool that ran an `os_pri > 0` callback on an elevated thread is retired
257 /// (phase-509 P11): preemption is a level's, which runs at its band's
258 /// kernel priority (RFC-0108). The field stays, because the C and C++
259 /// sched-context configs carry it.
260 ///
261 /// Numeric meaning is platform-defined (POSIX 1..99 for SCHED_FIFO;
262 /// FreeRTOS 0..configMAX_PRIORITIES-1; Zephyr direction-flipped).
263 pub os_pri: u8,
264 /// Phase 110.G — time-triggered window offset within the
265 /// executor's major frame. `None` (sentinel `0`) = always
266 /// eligible (no TT gate); `Some(off)` + `tt_window_duration_us`
267 /// gates dispatch to the half-open interval
268 /// `[off, off + duration) mod major_frame`.
269 ///
270 /// Independent of `class` — a `Sporadic`-class SC can also be TT-
271 /// gated; both gates apply (skip dispatch when EITHER fails).
272 /// Pairs with `Executor::register_time_triggered_dispatcher`
273 /// which sets the major-frame length.
274 /// W3b.5 — reaction on a deadline miss (see [`DeadlineAction`]).
275 pub deadline_action: DeadlineAction,
276 pub tt_window_offset_us: OptUs,
277 /// Phase 110.G — time-triggered window length. See
278 /// `tt_window_offset_us`.
279 pub tt_window_duration_us: OptUs,
280}
281
282/// Phase 110.E.b — atomic sporadic-server state for ISR-driven
283/// refill. ISR / timer-thread context calls refill_thunk to top up
284/// the budget; spin_once reads atomically without any `&mut` access.
285///
286/// Replaces the polled-clock `SporadicState` shape on platforms with
287/// a `PlatformTimer` impl. The Executor still keeps the legacy
288/// `SporadicState` path active on `feature = "std"` so the
289/// transition is non-breaking.
290pub struct AtomicSporadicState {
291 pub budget_remaining_us: portable_atomic::AtomicU32,
292 /// Wraps every ~50 days at ms resolution; saturates per
293 /// `tick`'s monotonic-clock contract. portable-atomic provides
294 /// AtomicU32 even on RISC-V `riscv32imc` / Cortex-M0+ that lack
295 /// native 32-bit atomics.
296 pub last_refill_ms: portable_atomic::AtomicU32,
297 pub budget_capacity_us: u32,
298 pub period_us: u32,
299 /// Phase 110.E.b — cumulative count of dispatched callbacks
300 /// whose measured wall-clock runtime exceeded the SC's
301 /// `budget_us`. Bumped by the per-callback runtime closure
302 /// inside `Executor::spin_once` (std-only — the no_std fallback
303 /// continues to use the polled `SporadicState` path without
304 /// per-callback overrun accounting). Cooperative single-thread
305 /// dispatch can't preempt a runaway callback, so this counter
306 /// is the diagnostic signal — the design's oneshot-IRQ-and-
307 /// cancel pattern is structurally equivalent for non-preemptive
308 /// callbacks, and `last_overrun_us` carries the worst-case
309 /// observation for tuning. Both reset by `clear_overrun_stats`.
310 pub overrun_count: portable_atomic::AtomicU32,
311 /// WORST overrun amount seen (measured_us - budget_us) since the
312 /// last `clear_overrun_stats`; `0` when none has been observed.
313 ///
314 /// This used to `store` the most RECENT overrun while its doc claimed
315 /// it "carries the worst-case observation for tuning". A large overrun
316 /// followed by a small one silently replaced the number anyone would
317 /// have sized a budget from, and the only surviving evidence of the bad
318 /// dispatch was that `overrun_count` had gone up by one. It is a
319 /// `fetch_max` now, which is what the name and the purpose both say.
320 pub last_overrun_us: portable_atomic::AtomicU32,
321 /// Worst execution time observed on this SC, in microseconds, recorded
322 /// on EVERY dispatch rather than only on an overrun.
323 ///
324 /// `overrun_count` and `last_overrun_us` answer "did it exceed the
325 /// budget, and by how much". Neither can answer "how close did it come"
326 /// — a system that never overruns produces no evidence at all, and that
327 /// is exactly the system whose budget you are trying to choose. Sizing
328 /// `budget_us` from a measured maximum is the whole point of the field.
329 ///
330 /// Only meaningful where the dispatch loop actually measures: elapsed is
331 /// `0` unless a latency monitor, a deadline action, or a clock is
332 /// present (`measure_us` in `spin_once`). No clock, no number.
333 pub max_exec_us: portable_atomic::AtomicU32,
334}
335
336impl AtomicSporadicState {
337 pub const fn new(budget_us: u32, period_us: u32) -> Self {
338 Self {
339 budget_remaining_us: portable_atomic::AtomicU32::new(budget_us),
340 last_refill_ms: portable_atomic::AtomicU32::new(0),
341 budget_capacity_us: budget_us,
342 period_us,
343 overrun_count: portable_atomic::AtomicU32::new(0),
344 last_overrun_us: portable_atomic::AtomicU32::new(0),
345 max_exec_us: portable_atomic::AtomicU32::new(0),
346 }
347 }
348
349 /// Record one overrun: callback measured runtime exceeded the
350 /// SC's `budget_us`. Bumps `overrun_count` + stores the absolute
351 /// overrun amount in `last_overrun_us`. Called from the
352 /// per-callback runtime closure inside `Executor::spin_once`.
353 #[inline]
354 pub fn record_overrun(&self, overrun_us: u32) {
355 self.overrun_count
356 .fetch_add(1, portable_atomic::Ordering::Relaxed);
357 self.last_overrun_us
358 .fetch_max(overrun_us, portable_atomic::Ordering::Relaxed);
359 }
360
361 /// Record one dispatch's measured execution time. Called on EVERY
362 /// dispatch charged to this SC, overrun or not.
363 #[inline]
364 pub fn record_exec(&self, elapsed_us: u32) {
365 self.max_exec_us
366 .fetch_max(elapsed_us, portable_atomic::Ordering::Relaxed);
367 }
368
369 /// Worst execution time seen since the last `clear_overrun_stats`.
370 #[inline]
371 pub fn max_exec_us(&self) -> u32 {
372 self.max_exec_us.load(portable_atomic::Ordering::Relaxed)
373 }
374
375 /// Reset both overrun statistics. Useful when tuning the budget
376 /// across windows (monitoring code logs + clears periodically).
377 #[inline]
378 pub fn clear_overrun_stats(&self) {
379 self.overrun_count
380 .store(0, portable_atomic::Ordering::Relaxed);
381 self.last_overrun_us
382 .store(0, portable_atomic::Ordering::Relaxed);
383 self.max_exec_us
384 .store(0, portable_atomic::Ordering::Relaxed);
385 }
386
387 /// Read the budget atomically; spin_once consults this to decide
388 /// whether to skip the SC's entries this cycle.
389 pub fn has_budget(&self) -> bool {
390 self.budget_remaining_us
391 .load(portable_atomic::Ordering::Acquire)
392 > 0
393 }
394
395 /// Saturating subtract — used by spin_once after dispatching a
396 /// callback bound to this SC.
397 pub fn consume(&self, us: u32) {
398 let mut cur = self
399 .budget_remaining_us
400 .load(portable_atomic::Ordering::Acquire);
401 loop {
402 let next = cur.saturating_sub(us);
403 match self.budget_remaining_us.compare_exchange_weak(
404 cur,
405 next,
406 portable_atomic::Ordering::Release,
407 portable_atomic::Ordering::Acquire,
408 ) {
409 Ok(_) => return,
410 Err(observed) => cur = observed,
411 }
412 }
413 }
414}
415
416/// C-callable refill thunk that `PlatformTimer::create_periodic`
417/// invokes from the platform's timer context. Single atomic store —
418/// safe in any thread / ISR context.
419///
420/// # Safety
421/// `user_data` must point at a live `AtomicSporadicState`; the caller
422/// of `PlatformTimer::create_periodic` owns the lifetime contract.
423pub extern "C" fn atomic_sporadic_refill_thunk(user_data: *mut core::ffi::c_void) {
424 if user_data.is_null() {
425 return;
426 }
427 let state = unsafe { &*(user_data as *const AtomicSporadicState) };
428 state
429 .budget_remaining_us
430 .store(state.budget_capacity_us, portable_atomic::Ordering::Release);
431}
432
433/// Phase 110.E — user-space sporadic-server runtime state.
434///
435/// Tracks remaining `budget_us` for the current period and the wall-
436/// clock instant of the last refill. The executor consults this state
437/// during dispatch: when `budget_remaining_us` reaches 0 the SC is
438/// suppressed until the next period boundary, at which point a refill
439/// resets the counter.
440///
441/// Refill cadence is polled — each `spin_once` checks whether the
442/// elapsed time since the last refill exceeds `period_us` and tops
443/// the budget back up. Less precise than an ISR-driven refill (Phase
444/// 110.E's per-platform timer hook is what gets that) but correct as
445/// an upper-bound bandwidth limiter.
446#[derive(Debug, Clone, Copy)]
447pub struct SporadicState {
448 pub budget_remaining_us: u32,
449 pub budget_capacity_us: u32,
450 pub period_us: u32,
451 pub last_refill_ms: u64,
452 /// issue 0736 — consecutive spins in which this SC was skipped for want of
453 /// budget. A skip is invisible by construction: `spin_once` `continue`s
454 /// past the entry, so a starved tier looks exactly like an idle one. This
455 /// counter is what lets it say so. Reset on any dispatch.
456 pub consecutive_budget_skips: u32,
457 /// issue 0736 — skips and dispatches over a rolling window.
458 ///
459 /// The consecutive counter above catches TOTAL starvation quickly and
460 /// misses the commoner shape entirely: a budget that merely THROTTLES.
461 /// Measured on nuttx-arm/rust, a 5 ms budget per 10 ms period held the
462 /// tier to about a quarter of its declared rate — skip a few, refill,
463 /// dispatch, repeat — so no streak ever reached the consecutive threshold
464 /// and the tier was silently 4x slow. A declaration that cannot be met has
465 /// to be reported whether it starves or merely throttles.
466 pub window_skips: u32,
467 pub window_dispatches: u32,
468 /// Worst execution time observed on this SC, in microseconds, recorded
469 /// on EVERY dispatch.
470 ///
471 /// This is the polled path, and issue 0736 established it is the one
472 /// that matters: `has_budget` prefers the atomic state only when a
473 /// refill timer was registered, which no board or entry does. So a
474 /// number recorded only on the atomic side would be a number no shipped
475 /// image ever writes.
476 ///
477 /// Sizing `budget_us` needs a measured maximum, and the overrun
478 /// counters cannot supply one: they say nothing at all until the budget
479 /// is already exceeded, which is the wrong end of the question when the
480 /// budget is what you are trying to choose.
481 ///
482 /// Only meaningful where the dispatch loop measures: elapsed is `0`
483 /// unless a latency monitor, a deadline action, or a clock is present.
484 pub max_exec_us: u32,
485}
486
487impl SporadicState {
488 pub const fn new(budget_us: u32, period_us: u32) -> Self {
489 Self {
490 budget_remaining_us: budget_us,
491 budget_capacity_us: budget_us,
492 period_us,
493 last_refill_ms: 0,
494 consecutive_budget_skips: 0,
495 window_skips: 0,
496 window_dispatches: 0,
497 max_exec_us: 0,
498 }
499 }
500
501 /// Replenish the budget at period boundaries. Returns `true` if the SC has
502 /// budget remaining afterwards.
503 ///
504 /// issue 0736 — this used to be `tick(now_ms, delta_us)`, which refilled
505 /// AND then charged the whole inter-spin `delta_us` as consumption. A
506 /// budget bounds the CPU the SC's callbacks consume; the wall-clock gap
507 /// between two spins is not that, and on any target where the gap exceeds
508 /// the budget it exhausts the SC on every single spin no matter what the
509 /// callbacks did. Measured on nuttx-arm/rust: `delta_us` 10_000..80_000
510 /// against a 5_000 us budget, giving 1200 budget skips against 3 dispatches
511 /// while the sibling tier — identical but declaring no budget — dispatched
512 /// on all 450 of its spins.
513 ///
514 /// The `delta_us` charge was documented as a "worst-case attribution"
515 /// standing in for per-callback measurement. That measurement now exists
516 /// and runs on every flavour, so consumption belongs to
517 /// [`Self::consume`], called with what the callback actually cost.
518 pub fn refill(&mut self, now_ms: u64) -> bool {
519 // Refill at period boundaries — coarse but correct.
520 if now_ms.saturating_sub(self.last_refill_ms) >= self.period_us as u64 / 1000 {
521 self.budget_remaining_us = self.budget_capacity_us;
522 self.last_refill_ms = now_ms;
523 }
524 self.budget_remaining_us > 0
525 }
526
527 /// Charge measured callback runtime against the remaining budget.
528 ///
529 /// The polled-state twin of `AtomicSporadicState::consume`. Both exist
530 /// because the atomic one is only present when a caller has registered a
531 /// refill timer via `Executor::register_sporadic_timer` — which, outside
532 /// this crate's own tests, nothing does. Every shipped image runs THIS
533 /// path, so it is the one that has to be right (issue 0736).
534 pub fn consume(&mut self, us: u32) {
535 self.budget_remaining_us = self.budget_remaining_us.saturating_sub(us);
536 self.consecutive_budget_skips = 0;
537 self.window_dispatches = self.window_dispatches.saturating_add(1);
538 // Every dispatch, not only the overrunning ones. See `max_exec_us`.
539 if us > self.max_exec_us {
540 self.max_exec_us = us;
541 }
542 }
543
544 /// How many dispatch opportunities before the throttle ratio is judged.
545 /// Long enough that a brief burst of skips is not news; short enough to
546 /// close inside a short run. 1000 was the first value and it NEVER closed
547 /// on the measured case — that tier misses roughly 240 dispatches over the
548 /// whole e2e window, so a 1000-opportunity window is larger than the
549 /// evidence and the warning existed without ever being reachable, which is
550 /// the same silence it was written to break.
551 pub const BUDGET_WINDOW: u32 = 200;
552 /// Report when more than this share of the window was skipped. A quarter
553 /// is already a tier delivering at 75% of what it declared.
554 pub const BUDGET_SKIP_REPORT_PERMILLE: u32 = 250;
555
556 /// Close the window if it is full. Returns `Some((skips, total))` when the
557 /// window closed with a skip share worth reporting.
558 pub fn take_budget_window(&mut self) -> Option<(u32, u32)> {
559 let total = self.window_skips + self.window_dispatches;
560 if total < Self::BUDGET_WINDOW {
561 return None;
562 }
563 let skips = self.window_skips;
564 self.window_skips = 0;
565 self.window_dispatches = 0;
566 if skips.saturating_mul(1000) / total.max(1) >= Self::BUDGET_SKIP_REPORT_PERMILLE {
567 Some((skips, total))
568 } else {
569 None
570 }
571 }
572
573 /// Record one budget-skipped dispatch. Returns `Some(n)` when the streak
574 /// has reached a length worth reporting — at 100, then each power of ten —
575 /// so a starved SC is loud once and does not then flood the console.
576 pub fn note_budget_skip(&mut self) -> Option<u32> {
577 self.consecutive_budget_skips = self.consecutive_budget_skips.saturating_add(1);
578 self.window_skips = self.window_skips.saturating_add(1);
579 let n = self.consecutive_budget_skips;
580 // Once when the streak is clearly not noise, then sparsely: a starved
581 // SC must be loud, and must not then drown the console it is reporting
582 // on. (The measured case ran 1200 consecutive skips.)
583 if n == 100 || (n > 100 && n.is_multiple_of(1000)) {
584 Some(n)
585 } else {
586 None
587 }
588 }
589}
590
591impl SchedContext {
592 pub const fn new_fifo() -> Self {
593 Self {
594 class: SchedClass::Fifo,
595 priority: Priority::Normal,
596 period_us: OptUs::NONE,
597 budget_us: OptUs::NONE,
598 deadline_us: OptUs::NONE,
599 deadline_policy: DeadlinePolicy::Activated,
600 os_pri: 0,
601 deadline_action: DeadlineAction::Ignore,
602 tt_window_offset_us: OptUs::NONE,
603 tt_window_duration_us: OptUs::NONE,
604 }
605 }
606
607 /// RFC-0052 — the **common backend** that lowers an RTOS-agnostic tier
608 /// policy (`[tasks.<t>]` class / budget / period / deadline) to a
609 /// [`SchedContext`]. ONE implementation shared by every language: the Rust
610 /// runtime (`ExecutorNodeRuntime::apply_tier_sched_policy`)
611 /// and the C / C++ entries (`nros_{c,cpp}_create_sched_context_from_policy`)
612 /// all call this, so the mapping can never drift between codegen paths.
613 ///
614 /// - `real_time` + `budget_us` + `period_us` → [`SchedClass::Sporadic`]
615 /// (budget + RT replenishment period on the SC);
616 /// - `best_effort` → [`SchedClass::BestEffort`];
617 /// - `time_triggered` + `period_us` → a TT window (major frame =
618 /// `period_us`, window = `budget_us` or the whole frame). The returned
619 /// `Some(frame_us)` is the major frame the caller must install via
620 /// [`crate::executor::Executor::register_time_triggered_dispatcher`]
621 /// (the SC itself stays `Fifo`-class per the 110.G TT refactor);
622 /// - `deadline_us` sets the SC deadline; `deadline_policy` its
623 /// [`DeadlineAction`].
624 ///
625 /// Returns `None` when the tier declares no class / budget / deadline — the
626 /// caller keeps the default `Fifo` SC (byte-identical pre-policy behavior).
627 /// `os_pri` is intentionally NOT set here (it is an orthogonal per-callback
628 /// OS-priority knob the caller applies); this fn covers only the
629 /// RTOS-agnostic real-time policy.
630 pub fn from_tier_policy(
631 class: Option<&str>,
632 period_us: Option<u64>,
633 budget_us: Option<u64>,
634 deadline_us: Option<u64>,
635 deadline_policy: Option<&str>,
636 ) -> Option<(Self, Option<u32>)> {
637 let sporadic = class == Some("real_time") && budget_us.is_some() && period_us.is_some();
638 let best_effort = class == Some("best_effort");
639 let time_triggered = class == Some("time_triggered") && period_us.is_some();
640 if !sporadic && !best_effort && !time_triggered && deadline_us.is_none() {
641 return None;
642 }
643 let clamp = |v: u64| v.min(u32::MAX as u64) as u32;
644 let mut sc = Self::default();
645 let mut tt_frame = None;
646 if sporadic {
647 sc.class = SchedClass::Sporadic;
648 sc.budget_us = OptUs::from_us(clamp(budget_us.unwrap_or(0)));
649 sc.period_us = OptUs::from_us(clamp(period_us.unwrap_or(0)));
650 } else if best_effort {
651 sc.class = SchedClass::BestEffort;
652 } else if time_triggered {
653 tt_frame = Some(clamp(period_us.unwrap_or(0)));
654 let window_us = clamp(budget_us.unwrap_or(period_us.unwrap_or(0)));
655 sc.tt_window_duration_us = OptUs::from_us(window_us);
656 }
657 if let Some(d) = deadline_us {
658 sc.deadline_us = OptUs::from_us(clamp(d));
659 }
660 if let Some(action) = deadline_policy {
661 sc.deadline_action = DeadlineAction::from_tier_str(action);
662 }
663 Some((sc, tt_frame))
664 }
665}
666
667// ----------------------------------------------------------------------
668// Phase 110.G — TimeTriggered schedule-table API.
669//
670// ARINC-653-style cyclic executive: the major frame is partitioned
671// into fixed windows; each callback is bound to a window via
672// `SchedContext { tt_window_offset_us, tt_window_duration_us }`.
673// The runtime gate inside `Executor::spin_once` already enforces
674// per-window dispatch suppression (Phase 110.G runtime, landed
675// pre-session). This block adds the schedule-table types +
676// builder helpers so callers can declare a complete cyclic
677// schedule with a single API call instead of stitching
678// `create_sched_context` + `bind_handle_to_sched_context` together.
679// ----------------------------------------------------------------------
680
681/// One slot in a time-triggered schedule.
682///
683/// Window `[offset_us, offset_us + duration_us)` within the major
684/// frame. `name` is a static-lifetime label for diagnostics
685/// (logging, panic messages); the runtime never inspects it.
686#[derive(Debug, Clone, Copy)]
687pub struct TimeTriggeredWindow {
688 pub offset_us: u32,
689 pub duration_us: u32,
690 pub name: &'static str,
691}
692
693impl TimeTriggeredWindow {
694 pub const fn new(offset_us: u32, duration_us: u32, name: &'static str) -> Self {
695 Self {
696 offset_us,
697 duration_us,
698 name,
699 }
700 }
701}
702
703/// Fixed-size, no_std-friendly cyclic schedule. `N` is the
704/// declared maximum window count; `window_count` is the active
705/// length (callers can build the array up to `N` and set
706/// `window_count` to the actual size used).
707#[derive(Debug)]
708pub struct TimeTriggeredSchedule<const N: usize> {
709 pub major_frame_us: u32,
710 pub windows: [TimeTriggeredWindow; N],
711 pub window_count: usize,
712}
713
714impl<const N: usize> TimeTriggeredSchedule<N> {
715 /// Construct a schedule from an exhaustive `[TimeTriggeredWindow; N]`
716 /// array; `window_count` is set to `N`.
717 pub const fn new_full(major_frame_us: u32, windows: [TimeTriggeredWindow; N]) -> Self {
718 Self {
719 major_frame_us,
720 windows,
721 window_count: N,
722 }
723 }
724
725 /// Validate the schedule: every window must fit inside
726 /// `[0, major_frame_us)` and windows must be non-overlapping
727 /// in offset-sorted order. Sliding-window check; O(N²) is fine
728 /// because TT schedules are small (rarely > 16 slots).
729 pub fn validate(&self) -> Result<(), TimeTriggeredScheduleError> {
730 if self.major_frame_us == 0 {
731 return Err(TimeTriggeredScheduleError::ZeroMajorFrame);
732 }
733 if self.window_count > N {
734 return Err(TimeTriggeredScheduleError::WindowCountOverflow);
735 }
736 for (i, w) in self.windows[..self.window_count].iter().enumerate() {
737 if w.duration_us == 0 {
738 return Err(TimeTriggeredScheduleError::ZeroWindowDuration { window: i });
739 }
740 let end = (w.offset_us as u64) + (w.duration_us as u64);
741 if end > self.major_frame_us as u64 {
742 return Err(TimeTriggeredScheduleError::WindowExceedsMajorFrame { window: i });
743 }
744 for (j, other) in self.windows[..self.window_count].iter().enumerate() {
745 if i == j {
746 continue;
747 }
748 let o_end = (other.offset_us as u64) + (other.duration_us as u64);
749 let overlaps = (w.offset_us as u64) < o_end && (other.offset_us as u64) < end;
750 if overlaps {
751 return Err(TimeTriggeredScheduleError::WindowsOverlap {
752 window_a: i,
753 window_b: j,
754 });
755 }
756 }
757 }
758 Ok(())
759 }
760}
761
762/// Validation errors for a [`TimeTriggeredSchedule`].
763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
764pub enum TimeTriggeredScheduleError {
765 ZeroMajorFrame,
766 WindowCountOverflow,
767 ZeroWindowDuration { window: usize },
768 WindowExceedsMajorFrame { window: usize },
769 WindowsOverlap { window_a: usize, window_b: usize },
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn opt_us_sentinel_round_trip() {
778 assert!(!OptUs::NONE.is_some());
779 assert_eq!(OptUs::NONE.get(), None);
780 let some = OptUs::from_us(42);
781 assert!(some.is_some());
782 assert_eq!(some.get().map(|nz| nz.get()), Some(42));
783 assert_eq!(some.raw(), 42);
784 }
785
786 #[test]
787 fn opt_us_layout_is_u32() {
788 // ABI guard — `OptUs` MUST stay `#[repr(transparent)]` over
789 // `u32` so cbindgen emits a plain `uint32_t`.
790 assert_eq!(core::mem::size_of::<OptUs>(), core::mem::size_of::<u32>());
791 assert_eq!(core::mem::align_of::<OptUs>(), core::mem::align_of::<u32>());
792 }
793
794 #[test]
795 fn sched_context_default_is_fifo() {
796 let sc = SchedContext::default();
797 assert_eq!(sc.class, SchedClass::Fifo);
798 assert!(!sc.period_us.is_some());
799 assert!(!sc.budget_us.is_some());
800 assert!(!sc.deadline_us.is_some());
801 assert_eq!(sc.deadline_policy, DeadlinePolicy::Activated);
802 }
803
804 // RFC-0052 — the common-backend tier→SchedContext lowering, shared by the
805 // Rust runtime (`apply_tier_sched_policy`) and the C/C++ FFI
806 // (`nros_{c,cpp}_create_sched_context_from_policy`). One place, one test.
807
808 #[test]
809 fn from_tier_policy_real_time_lowers_to_sporadic() {
810 let (sc, tt) = SchedContext::from_tier_policy(
811 Some("real_time"),
812 Some(20_000),
813 Some(3_000),
814 Some(15_000),
815 Some("fault"),
816 )
817 .expect("real_time+budget+period is a policy");
818 assert_eq!(sc.class, SchedClass::Sporadic);
819 assert_eq!(sc.period_us.raw(), 20_000);
820 assert_eq!(sc.budget_us.raw(), 3_000);
821 assert_eq!(sc.deadline_us.raw(), 15_000);
822 assert_eq!(sc.deadline_action, DeadlineAction::Fault);
823 assert_eq!(tt, None);
824 }
825
826 #[test]
827 fn from_tier_policy_real_time_without_budget_or_period_is_not_sporadic() {
828 // `real_time` needs BOTH budget and period to be a sporadic server; a
829 // bare `real_time` with neither (and no deadline) declares no policy.
830 assert!(
831 SchedContext::from_tier_policy(Some("real_time"), None, None, None, None).is_none()
832 );
833 }
834
835 #[test]
836 fn from_tier_policy_best_effort() {
837 let (sc, tt) = SchedContext::from_tier_policy(Some("best_effort"), None, None, None, None)
838 .expect("best_effort is a policy");
839 assert_eq!(sc.class, SchedClass::BestEffort);
840 assert_eq!(tt, None);
841 }
842
843 #[test]
844 fn from_tier_policy_time_triggered_returns_major_frame() {
845 // TT stays Fifo-class (110.G) but returns the major frame to register
846 // and sets the window duration (budget → window, else whole frame).
847 let (sc, tt) = SchedContext::from_tier_policy(
848 Some("time_triggered"),
849 Some(10_000),
850 Some(4_000),
851 None,
852 None,
853 )
854 .expect("time_triggered+period is a policy");
855 assert_eq!(sc.class, SchedClass::Fifo);
856 assert_eq!(tt, Some(10_000));
857 assert_eq!(sc.tt_window_duration_us.raw(), 4_000);
858 }
859
860 #[test]
861 fn from_tier_policy_deadline_only() {
862 // A deadline with no RT class still yields a policy (Fifo + deadline).
863 let (sc, _tt) = SchedContext::from_tier_policy(None, None, None, Some(8_000), Some("warn"))
864 .expect("a deadline is a policy");
865 assert_eq!(sc.class, SchedClass::Fifo);
866 assert_eq!(sc.deadline_us.raw(), 8_000);
867 assert_eq!(sc.deadline_action, DeadlineAction::Warn);
868 }
869
870 #[test]
871 fn from_tier_policy_none_when_no_policy() {
872 // No class, no deadline → None → caller keeps the default Fifo SC.
873 assert!(SchedContext::from_tier_policy(None, Some(1_000), None, None, None).is_none());
874 assert!(SchedContext::from_tier_policy(Some("default"), None, None, None, None).is_none());
875 }
876
877 /// phase-462 W2 -- the four words are the interface between the bake and
878 /// this crate. `nros-orchestration-ir`'s `violation_agreement` pins the
879 /// same three non-default ones in `the_tier_spelling_round_trips`; if
880 /// either side renames one, the contract's `on_violation` silently lowers
881 /// to `Ignore` and the image stops reacting.
882 #[test]
883 fn the_contract_vocabulary_is_the_tier_vocabulary() {
884 for a in [
885 DeadlineAction::Ignore,
886 DeadlineAction::Warn,
887 DeadlineAction::Skip,
888 DeadlineAction::Fault,
889 ] {
890 assert_eq!(DeadlineAction::from_tier_str(a.as_tier_str()), a);
891 }
892 assert_eq!(DeadlineAction::Fault.as_tier_str(), "fault");
893 // An unreadable word is the default, not a boot panic.
894 assert_eq!(
895 DeadlineAction::from_tier_str("abort"),
896 DeadlineAction::Ignore
897 );
898 }
899}
900
901#[cfg(test)]
902mod exec_high_water_tests {
903 use super::*;
904
905 /// The number a budget gets sized from has to survive a later, smaller
906 /// dispatch. `max_exec_us` is a maximum, not a last-write.
907 #[test]
908 fn polled_state_keeps_the_worst_execution_not_the_latest() {
909 let mut st = SporadicState::new(1000, 10_000);
910 st.consume(120);
911 st.consume(900);
912 st.consume(30);
913 assert_eq!(st.max_exec_us, 900);
914 }
915
916 /// Recorded on EVERY dispatch, including ones well inside budget. A
917 /// system that never overruns still has to say how close it came.
918 #[test]
919 fn polled_state_records_without_any_overrun() {
920 let mut st = SporadicState::new(10_000, 20_000);
921 st.consume(700);
922 st.consume(450);
923 assert_eq!(
924 st.max_exec_us, 700,
925 "no overrun occurred, yet 700 is the evidence"
926 );
927 }
928
929 #[test]
930 fn atomic_state_keeps_the_worst_execution_not_the_latest() {
931 let st = AtomicSporadicState::new(1000, 10_000);
932 st.record_exec(120);
933 st.record_exec(900);
934 st.record_exec(30);
935 assert_eq!(st.max_exec_us(), 900);
936 }
937
938 /// Regression: `last_overrun_us` used to `store`, so a big overrun
939 /// followed by a small one lost the big one entirely -- while its own
940 /// doc promised "the worst-case observation for tuning".
941 #[test]
942 fn last_overrun_keeps_the_worst_not_the_most_recent() {
943 let st = AtomicSporadicState::new(1000, 10_000);
944 st.record_overrun(500);
945 st.record_overrun(20);
946 assert_eq!(
947 st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
948 500,
949 "a later small overrun must not erase the worst one"
950 );
951 assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 2);
952 }
953
954 #[test]
955 fn clearing_stats_resets_the_high_water_too() {
956 let st = AtomicSporadicState::new(1000, 10_000);
957 st.record_exec(4242);
958 st.record_overrun(7);
959 st.clear_overrun_stats();
960 assert_eq!(st.max_exec_us(), 0);
961 assert_eq!(
962 st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
963 0
964 );
965 assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 0);
966 }
967}