Skip to main content

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/// `TimeTriggered` is post-v1 (110.G).
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum SchedClass {
64    #[default]
65    Fifo,
66    Edf,
67    Sporadic,
68    BestEffort,
69    /// Deprecated as of Phase 110.G refactor — TT is now an
70    /// orthogonal slot-membership annotation via
71    /// `SchedContext.tt_window_offset_us` /
72    /// `tt_window_duration_us`, not a class. Keeping the variant
73    /// for one release so exhaustive matches don't break; treated
74    /// as `Fifo` in dispatch.
75    #[deprecated(
76        since = "0.1.0",
77        note = "use SchedContext.tt_window_offset_us + tt_window_duration_us instead; \
78                TT now cooperates with Fifo / Edf / Sporadic / BestEffort classes"
79    )]
80    TimeTriggered,
81}
82
83/// Criticality bucket for [`SchedContext`]. Phase 110.C uses this to
84/// pick which `BucketedFifoSet` / `BucketedEdfSet` slot a callback
85/// dispatches through; later phases (110.D) map it to OS priority.
86///
87/// Default `Normal` keeps existing single-bucket workloads unchanged
88/// — every default-Fifo SC sits in `Normal`, so dispatch order is
89/// bit-identical to pre-110.C when no callback opts in to `Critical`
90/// or `BestEffort`.
91///
92/// Single-thread non-preemption note: a `BestEffort` callback already
93/// running blocks `Critical` work that becomes ready mid-cycle. Hard-
94/// RT scenarios need 110.D's multi-executor preemption.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Ord, PartialOrd)]
96pub enum Priority {
97    /// Highest-priority bucket. Drained first within a single
98    /// `spin_once` cycle; non-preemptive against in-flight lower-
99    /// priority callbacks (see Phase 110.D for preemption).
100    Critical = 0,
101    /// Default bucket. Most callbacks (and the auto-default Fifo SC)
102    /// live here.
103    #[default]
104    Normal = 1,
105    /// Lowest-priority bucket. Drained last; first to be skipped if a
106    /// future cycle-budget overrun forces an early return.
107    BestEffort = 2,
108}
109
110impl Priority {
111    pub const COUNT: usize = 3;
112
113    pub const fn index(self) -> usize {
114        self as usize
115    }
116}
117
118/// How an EDF deadline is interpreted relative to a callback firing.
119///
120/// - `Released`: deadline is `release_time + period`. Default for
121///   timer-triggered callbacks.
122/// - `Activated`: deadline is `activation_time + relative_deadline`.
123///   Default for event-triggered subscriptions.
124/// - `Inherited`: deadline travels in the message header — latency-
125///   aware pipelines extract it per-message at dispatch time.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127pub enum DeadlinePolicy {
128    Released,
129    #[default]
130    Activated,
131    Inherited,
132}
133
134/// RFC-0052 / phase-296 W3b.5 — what the executor DOES when a dispatched
135/// callback runs past its bound SC's `deadline_us`. Distinct from
136/// [`DeadlinePolicy`] (which says where the deadline COMES from); this is
137/// the miss REACTION, lowered from the tier table's `deadline_policy`
138/// string (`ignore | warn | skip | fault`).
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140pub enum DeadlineAction {
141    /// Measure nothing, report nothing (uncontracted default).
142    #[default]
143    Ignore,
144    /// Push a `deadline-miss-runtime` violation onto the monitor drain.
145    Warn,
146    /// Warn AND skip the offending SC's remaining callbacks for the rest
147    /// of this spin cycle (damage containment: a runaway callback does
148    /// not get to also starve unrelated groups with its siblings).
149    Skip,
150    /// Warn AND invoke the executor's fault hook (panic when none is
151    /// registered — on embedded targets that is a watchdog-visible stop).
152    Fault,
153}
154
155impl DeadlineAction {
156    /// Lower the tier-table string (`[tiers.<t>].deadline_policy`).
157    /// Unknown strings map to `Ignore` — the bake already validated the
158    /// vocabulary; runtime tolerance here avoids a boot-time panic path.
159    pub fn from_tier_str(s: &str) -> Self {
160        match s {
161            "warn" => Self::Warn,
162            "skip" => Self::Skip,
163            "fault" => Self::Fault,
164            _ => Self::Ignore,
165        }
166    }
167}
168
169/// Identifier for a [`SchedContext`] registered with an Executor.
170/// 110.B.b adds storage `[Option<SchedContext>; MAX_SC]`; this index
171/// addresses into that array.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub struct SchedContextId(pub u8);
174
175/// First-class scheduling capability — one SC per scheduling concern,
176/// shared by every callback that should run under the same budget /
177/// period / deadline / class.
178///
179/// Phase 110.B.a defines the shape; 110.B.b's builder methods on
180/// Executor consume it.
181#[derive(Debug, Clone, Copy, Default)]
182pub struct SchedContext {
183    pub class: SchedClass,
184    pub priority: Priority,
185    pub period_us: OptUs,
186    pub budget_us: OptUs,
187    pub deadline_us: OptUs,
188    pub deadline_policy: DeadlinePolicy,
189    /// Phase 110.F — opt-in OS-level priority for per-callback
190    /// dispatch. `0` (default) means "no per-callback OS priority"
191    /// — the executor's cooperative dispatch path runs every
192    /// callback bound to this SC. Non-zero values trigger the
193    /// per-priority worker-pool path (registered via
194    /// `Executor::register_os_priority_dispatcher`); each callback
195    /// then runs on a worker thread the OS scheduler has elevated
196    /// to that numeric priority.
197    ///
198    /// Numeric meaning is platform-defined (POSIX 1..99 for
199    /// SCHED_FIFO; FreeRTOS 0..configMAX_PRIORITIES-1; Zephyr
200    /// direction-flipped). Chain-priority assignment + chain
201    /// grouping happen at the orchestration layer and are out of
202    /// executor scope.
203    pub os_pri: u8,
204    /// Phase 110.G — time-triggered window offset within the
205    /// executor's major frame. `None` (sentinel `0`) = always
206    /// eligible (no TT gate); `Some(off)` + `tt_window_duration_us`
207    /// gates dispatch to the half-open interval
208    /// `[off, off + duration) mod major_frame`.
209    ///
210    /// Independent of `class` — a `Sporadic`-class SC can also be TT-
211    /// gated; both gates apply (skip dispatch when EITHER fails).
212    /// Pairs with `Executor::register_time_triggered_dispatcher`
213    /// which sets the major-frame length.
214    /// W3b.5 — reaction on a deadline miss (see [`DeadlineAction`]).
215    pub deadline_action: DeadlineAction,
216    pub tt_window_offset_us: OptUs,
217    /// Phase 110.G — time-triggered window length. See
218    /// `tt_window_offset_us`.
219    pub tt_window_duration_us: OptUs,
220}
221
222/// Phase 110.E.b — atomic sporadic-server state for ISR-driven
223/// refill. ISR / timer-thread context calls `refill_thunk` to top up
224/// the budget; spin_once reads atomically without any `&mut` access.
225///
226/// Replaces the polled-clock `SporadicState` shape on platforms with
227/// a `PlatformTimer` impl. The Executor still keeps the legacy
228/// `SporadicState` path active on `feature = "std"` so the
229/// transition is non-breaking.
230pub struct AtomicSporadicState {
231    pub budget_remaining_us: portable_atomic::AtomicU32,
232    /// Wraps every ~50 days at ms resolution; saturates per
233    /// `tick`'s monotonic-clock contract. portable-atomic provides
234    /// AtomicU32 even on RISC-V `riscv32imc` / Cortex-M0+ that lack
235    /// native 32-bit atomics.
236    pub last_refill_ms: portable_atomic::AtomicU32,
237    pub budget_capacity_us: u32,
238    pub period_us: u32,
239    /// Phase 110.E.b — cumulative count of dispatched callbacks
240    /// whose measured wall-clock runtime exceeded the SC's
241    /// `budget_us`. Bumped by the per-callback runtime closure
242    /// inside `Executor::spin_once` (std-only — the no_std fallback
243    /// continues to use the polled `SporadicState` path without
244    /// per-callback overrun accounting). Cooperative single-thread
245    /// dispatch can't preempt a runaway callback, so this counter
246    /// is the diagnostic signal — the design's oneshot-IRQ-and-
247    /// cancel pattern is structurally equivalent for non-preemptive
248    /// callbacks, and `last_overrun_us` carries the worst-case
249    /// observation for tuning. Both reset by `clear_overrun_stats`.
250    pub overrun_count: portable_atomic::AtomicU32,
251    /// Phase 110.E.b — most recent dispatch's overrun amount
252    /// (`measured_us - budget_us`). `0` when no overrun has been
253    /// observed since the last `clear_overrun_stats`. Used by
254    /// monitoring code that wants to size the budget against
255    /// worst-case observed runtime.
256    pub last_overrun_us: portable_atomic::AtomicU32,
257}
258
259impl AtomicSporadicState {
260    pub const fn new(budget_us: u32, period_us: u32) -> Self {
261        Self {
262            budget_remaining_us: portable_atomic::AtomicU32::new(budget_us),
263            last_refill_ms: portable_atomic::AtomicU32::new(0),
264            budget_capacity_us: budget_us,
265            period_us,
266            overrun_count: portable_atomic::AtomicU32::new(0),
267            last_overrun_us: portable_atomic::AtomicU32::new(0),
268        }
269    }
270
271    /// Record one overrun: callback measured runtime exceeded the
272    /// SC's `budget_us`. Bumps `overrun_count` + stores the absolute
273    /// overrun amount in `last_overrun_us`. Called from the
274    /// per-callback runtime closure inside `Executor::spin_once`.
275    #[inline]
276    pub fn record_overrun(&self, overrun_us: u32) {
277        self.overrun_count
278            .fetch_add(1, portable_atomic::Ordering::Relaxed);
279        self.last_overrun_us
280            .store(overrun_us, portable_atomic::Ordering::Relaxed);
281    }
282
283    /// Reset both overrun statistics. Useful when tuning the budget
284    /// across windows (monitoring code logs + clears periodically).
285    #[inline]
286    pub fn clear_overrun_stats(&self) {
287        self.overrun_count
288            .store(0, portable_atomic::Ordering::Relaxed);
289        self.last_overrun_us
290            .store(0, portable_atomic::Ordering::Relaxed);
291    }
292
293    /// Read the budget atomically; spin_once consults this to decide
294    /// whether to skip the SC's entries this cycle.
295    pub fn has_budget(&self) -> bool {
296        self.budget_remaining_us
297            .load(portable_atomic::Ordering::Acquire)
298            > 0
299    }
300
301    /// Saturating subtract — used by spin_once after dispatching a
302    /// callback bound to this SC.
303    pub fn consume(&self, us: u32) {
304        let mut cur = self
305            .budget_remaining_us
306            .load(portable_atomic::Ordering::Acquire);
307        loop {
308            let next = cur.saturating_sub(us);
309            match self.budget_remaining_us.compare_exchange_weak(
310                cur,
311                next,
312                portable_atomic::Ordering::Release,
313                portable_atomic::Ordering::Acquire,
314            ) {
315                Ok(_) => return,
316                Err(observed) => cur = observed,
317            }
318        }
319    }
320}
321
322/// C-callable refill thunk that `PlatformTimer::create_periodic`
323/// invokes from the platform's timer context. Single atomic store —
324/// safe in any thread / ISR context.
325///
326/// # Safety
327/// `user_data` must point at a live `AtomicSporadicState`; the caller
328/// of `PlatformTimer::create_periodic` owns the lifetime contract.
329pub extern "C" fn atomic_sporadic_refill_thunk(user_data: *mut core::ffi::c_void) {
330    if user_data.is_null() {
331        return;
332    }
333    let state = unsafe { &*(user_data as *const AtomicSporadicState) };
334    state
335        .budget_remaining_us
336        .store(state.budget_capacity_us, portable_atomic::Ordering::Release);
337}
338
339/// Phase 110.E — user-space sporadic-server runtime state.
340///
341/// Tracks remaining `budget_us` for the current period and the wall-
342/// clock instant of the last refill. The executor consults this state
343/// during dispatch: when `budget_remaining_us` reaches 0 the SC is
344/// suppressed until the next period boundary, at which point a refill
345/// resets the counter.
346///
347/// Refill cadence is polled — each `spin_once` checks whether the
348/// elapsed time since the last refill exceeds `period_us` and tops
349/// the budget back up. Less precise than an ISR-driven refill (Phase
350/// 110.E's per-platform timer hook is what gets that) but correct as
351/// an upper-bound bandwidth limiter.
352#[derive(Debug, Clone, Copy)]
353pub struct SporadicState {
354    pub budget_remaining_us: u32,
355    pub budget_capacity_us: u32,
356    pub period_us: u32,
357    pub last_refill_ms: u64,
358}
359
360impl SporadicState {
361    pub const fn new(budget_us: u32, period_us: u32) -> Self {
362        Self {
363            budget_remaining_us: budget_us,
364            budget_capacity_us: budget_us,
365            period_us,
366            last_refill_ms: 0,
367        }
368    }
369
370    /// Apply elapsed-time accounting since the previous spin. Returns
371    /// `true` if the SC has remaining budget after the refill check.
372    pub fn tick(&mut self, now_ms: u64, delta_us: u32) -> bool {
373        // Refill at period boundaries — coarse but correct.
374        if now_ms.saturating_sub(self.last_refill_ms) >= self.period_us as u64 / 1000 {
375            self.budget_remaining_us = self.budget_capacity_us;
376            self.last_refill_ms = now_ms;
377        }
378        self.budget_remaining_us = self.budget_remaining_us.saturating_sub(delta_us);
379        self.budget_remaining_us > 0
380    }
381}
382
383impl SchedContext {
384    pub const fn new_fifo() -> Self {
385        Self {
386            class: SchedClass::Fifo,
387            priority: Priority::Normal,
388            period_us: OptUs::NONE,
389            budget_us: OptUs::NONE,
390            deadline_us: OptUs::NONE,
391            deadline_policy: DeadlinePolicy::Activated,
392            os_pri: 0,
393            deadline_action: DeadlineAction::Ignore,
394            tt_window_offset_us: OptUs::NONE,
395            tt_window_duration_us: OptUs::NONE,
396        }
397    }
398}
399
400// ----------------------------------------------------------------------
401// Phase 110.G — TimeTriggered schedule-table API.
402//
403// ARINC-653-style cyclic executive: the major frame is partitioned
404// into fixed windows; each callback is bound to a window via
405// `SchedContext { tt_window_offset_us, tt_window_duration_us }`.
406// The runtime gate inside `Executor::spin_once` already enforces
407// per-window dispatch suppression (Phase 110.G runtime, landed
408// pre-session). This block adds the schedule-table types +
409// builder helpers so callers can declare a complete cyclic
410// schedule with a single API call instead of stitching
411// `create_sched_context` + `bind_handle_to_sched_context` together.
412// ----------------------------------------------------------------------
413
414/// One slot in a time-triggered schedule.
415///
416/// Window `[offset_us, offset_us + duration_us)` within the major
417/// frame. `name` is a static-lifetime label for diagnostics
418/// (logging, panic messages); the runtime never inspects it.
419#[derive(Debug, Clone, Copy)]
420pub struct TimeTriggeredWindow {
421    pub offset_us: u32,
422    pub duration_us: u32,
423    pub name: &'static str,
424}
425
426impl TimeTriggeredWindow {
427    pub const fn new(offset_us: u32, duration_us: u32, name: &'static str) -> Self {
428        Self {
429            offset_us,
430            duration_us,
431            name,
432        }
433    }
434}
435
436/// Fixed-size, no_std-friendly cyclic schedule. `N` is the
437/// declared maximum window count; `window_count` is the active
438/// length (callers can build the array up to `N` and set
439/// `window_count` to the actual size used).
440#[derive(Debug)]
441pub struct TimeTriggeredSchedule<const N: usize> {
442    pub major_frame_us: u32,
443    pub windows: [TimeTriggeredWindow; N],
444    pub window_count: usize,
445}
446
447impl<const N: usize> TimeTriggeredSchedule<N> {
448    /// Construct a schedule from an exhaustive `[TimeTriggeredWindow; N]`
449    /// array; `window_count` is set to `N`.
450    pub const fn new_full(major_frame_us: u32, windows: [TimeTriggeredWindow; N]) -> Self {
451        Self {
452            major_frame_us,
453            windows,
454            window_count: N,
455        }
456    }
457
458    /// Validate the schedule: every window must fit inside
459    /// `[0, major_frame_us)` and windows must be non-overlapping
460    /// in offset-sorted order. Sliding-window check; O(N²) is fine
461    /// because TT schedules are small (rarely > 16 slots).
462    pub fn validate(&self) -> Result<(), TimeTriggeredScheduleError> {
463        if self.major_frame_us == 0 {
464            return Err(TimeTriggeredScheduleError::ZeroMajorFrame);
465        }
466        if self.window_count > N {
467            return Err(TimeTriggeredScheduleError::WindowCountOverflow);
468        }
469        for (i, w) in self.windows[..self.window_count].iter().enumerate() {
470            if w.duration_us == 0 {
471                return Err(TimeTriggeredScheduleError::ZeroWindowDuration { window: i });
472            }
473            let end = (w.offset_us as u64) + (w.duration_us as u64);
474            if end > self.major_frame_us as u64 {
475                return Err(TimeTriggeredScheduleError::WindowExceedsMajorFrame { window: i });
476            }
477            for (j, other) in self.windows[..self.window_count].iter().enumerate() {
478                if i == j {
479                    continue;
480                }
481                let o_end = (other.offset_us as u64) + (other.duration_us as u64);
482                let overlaps = (w.offset_us as u64) < o_end && (other.offset_us as u64) < end;
483                if overlaps {
484                    return Err(TimeTriggeredScheduleError::WindowsOverlap {
485                        window_a: i,
486                        window_b: j,
487                    });
488                }
489            }
490        }
491        Ok(())
492    }
493}
494
495/// Validation errors for a [`TimeTriggeredSchedule`].
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum TimeTriggeredScheduleError {
498    ZeroMajorFrame,
499    WindowCountOverflow,
500    ZeroWindowDuration { window: usize },
501    WindowExceedsMajorFrame { window: usize },
502    WindowsOverlap { window_a: usize, window_b: usize },
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn opt_us_sentinel_round_trip() {
511        assert!(!OptUs::NONE.is_some());
512        assert_eq!(OptUs::NONE.get(), None);
513        let some = OptUs::from_us(42);
514        assert!(some.is_some());
515        assert_eq!(some.get().map(|nz| nz.get()), Some(42));
516        assert_eq!(some.raw(), 42);
517    }
518
519    #[test]
520    fn opt_us_layout_is_u32() {
521        // ABI guard — `OptUs` MUST stay `#[repr(transparent)]` over
522        // `u32` so cbindgen emits a plain `uint32_t`.
523        assert_eq!(core::mem::size_of::<OptUs>(), core::mem::size_of::<u32>());
524        assert_eq!(core::mem::align_of::<OptUs>(), core::mem::align_of::<u32>());
525    }
526
527    #[test]
528    fn sched_context_default_is_fifo() {
529        let sc = SchedContext::default();
530        assert_eq!(sc.class, SchedClass::Fifo);
531        assert!(!sc.period_us.is_some());
532        assert!(!sc.budget_us.is_some());
533        assert!(!sc.deadline_us.is_some());
534        assert_eq!(sc.deadline_policy, DeadlinePolicy::Activated);
535    }
536}