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/// Identifier for a [`SchedContext`] registered with an Executor.
135/// 110.B.b adds storage `[Option<SchedContext>; MAX_SC]`; this index
136/// addresses into that array.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct SchedContextId(pub u8);
139
140/// First-class scheduling capability — one SC per scheduling concern,
141/// shared by every callback that should run under the same budget /
142/// period / deadline / class.
143///
144/// Phase 110.B.a defines the shape; 110.B.b's builder methods on
145/// Executor consume it.
146#[derive(Debug, Clone, Copy, Default)]
147pub struct SchedContext {
148 pub class: SchedClass,
149 pub priority: Priority,
150 pub period_us: OptUs,
151 pub budget_us: OptUs,
152 pub deadline_us: OptUs,
153 pub deadline_policy: DeadlinePolicy,
154 /// Phase 110.F — opt-in OS-level priority for per-callback
155 /// dispatch. `0` (default) means "no per-callback OS priority"
156 /// — the executor's cooperative dispatch path runs every
157 /// callback bound to this SC. Non-zero values trigger the
158 /// per-priority worker-pool path (registered via
159 /// `Executor::register_os_priority_dispatcher`); each callback
160 /// then runs on a worker thread the OS scheduler has elevated
161 /// to that numeric priority.
162 ///
163 /// Numeric meaning is platform-defined (POSIX 1..99 for
164 /// SCHED_FIFO; FreeRTOS 0..configMAX_PRIORITIES-1; Zephyr
165 /// direction-flipped). Chain-priority assignment + chain
166 /// grouping happen at the orchestration layer and are out of
167 /// executor scope.
168 pub os_pri: u8,
169 /// Phase 110.G — time-triggered window offset within the
170 /// executor's major frame. `None` (sentinel `0`) = always
171 /// eligible (no TT gate); `Some(off)` + `tt_window_duration_us`
172 /// gates dispatch to the half-open interval
173 /// `[off, off + duration) mod major_frame`.
174 ///
175 /// Independent of `class` — a `Sporadic`-class SC can also be TT-
176 /// gated; both gates apply (skip dispatch when EITHER fails).
177 /// Pairs with `Executor::register_time_triggered_dispatcher`
178 /// which sets the major-frame length.
179 pub tt_window_offset_us: OptUs,
180 /// Phase 110.G — time-triggered window length. See
181 /// `tt_window_offset_us`.
182 pub tt_window_duration_us: OptUs,
183}
184
185/// Phase 110.E.b — atomic sporadic-server state for ISR-driven
186/// refill. ISR / timer-thread context calls `refill_thunk` to top up
187/// the budget; spin_once reads atomically without any `&mut` access.
188///
189/// Replaces the polled-clock `SporadicState` shape on platforms with
190/// a `PlatformTimer` impl. The Executor still keeps the legacy
191/// `SporadicState` path active on `feature = "std"` so the
192/// transition is non-breaking.
193pub struct AtomicSporadicState {
194 pub budget_remaining_us: portable_atomic::AtomicU32,
195 /// Wraps every ~50 days at ms resolution; saturates per
196 /// `tick`'s monotonic-clock contract. portable-atomic provides
197 /// AtomicU32 even on RISC-V `riscv32imc` / Cortex-M0+ that lack
198 /// native 32-bit atomics.
199 pub last_refill_ms: portable_atomic::AtomicU32,
200 pub budget_capacity_us: u32,
201 pub period_us: u32,
202 /// Phase 110.E.b — cumulative count of dispatched callbacks
203 /// whose measured wall-clock runtime exceeded the SC's
204 /// `budget_us`. Bumped by the per-callback runtime closure
205 /// inside `Executor::spin_once` (std-only — the no_std fallback
206 /// continues to use the polled `SporadicState` path without
207 /// per-callback overrun accounting). Cooperative single-thread
208 /// dispatch can't preempt a runaway callback, so this counter
209 /// is the diagnostic signal — the design's oneshot-IRQ-and-
210 /// cancel pattern is structurally equivalent for non-preemptive
211 /// callbacks, and `last_overrun_us` carries the worst-case
212 /// observation for tuning. Both reset by `clear_overrun_stats`.
213 pub overrun_count: portable_atomic::AtomicU32,
214 /// Phase 110.E.b — most recent dispatch's overrun amount
215 /// (`measured_us - budget_us`). `0` when no overrun has been
216 /// observed since the last `clear_overrun_stats`. Used by
217 /// monitoring code that wants to size the budget against
218 /// worst-case observed runtime.
219 pub last_overrun_us: portable_atomic::AtomicU32,
220}
221
222impl AtomicSporadicState {
223 pub const fn new(budget_us: u32, period_us: u32) -> Self {
224 Self {
225 budget_remaining_us: portable_atomic::AtomicU32::new(budget_us),
226 last_refill_ms: portable_atomic::AtomicU32::new(0),
227 budget_capacity_us: budget_us,
228 period_us,
229 overrun_count: portable_atomic::AtomicU32::new(0),
230 last_overrun_us: portable_atomic::AtomicU32::new(0),
231 }
232 }
233
234 /// Record one overrun: callback measured runtime exceeded the
235 /// SC's `budget_us`. Bumps `overrun_count` + stores the absolute
236 /// overrun amount in `last_overrun_us`. Called from the
237 /// per-callback runtime closure inside `Executor::spin_once`.
238 #[inline]
239 pub fn record_overrun(&self, overrun_us: u32) {
240 self.overrun_count
241 .fetch_add(1, portable_atomic::Ordering::Relaxed);
242 self.last_overrun_us
243 .store(overrun_us, portable_atomic::Ordering::Relaxed);
244 }
245
246 /// Reset both overrun statistics. Useful when tuning the budget
247 /// across windows (monitoring code logs + clears periodically).
248 #[inline]
249 pub fn clear_overrun_stats(&self) {
250 self.overrun_count
251 .store(0, portable_atomic::Ordering::Relaxed);
252 self.last_overrun_us
253 .store(0, portable_atomic::Ordering::Relaxed);
254 }
255
256 /// Read the budget atomically; spin_once consults this to decide
257 /// whether to skip the SC's entries this cycle.
258 pub fn has_budget(&self) -> bool {
259 self.budget_remaining_us
260 .load(portable_atomic::Ordering::Acquire)
261 > 0
262 }
263
264 /// Saturating subtract — used by spin_once after dispatching a
265 /// callback bound to this SC.
266 pub fn consume(&self, us: u32) {
267 let mut cur = self
268 .budget_remaining_us
269 .load(portable_atomic::Ordering::Acquire);
270 loop {
271 let next = cur.saturating_sub(us);
272 match self.budget_remaining_us.compare_exchange_weak(
273 cur,
274 next,
275 portable_atomic::Ordering::Release,
276 portable_atomic::Ordering::Acquire,
277 ) {
278 Ok(_) => return,
279 Err(observed) => cur = observed,
280 }
281 }
282 }
283}
284
285/// C-callable refill thunk that `PlatformTimer::create_periodic`
286/// invokes from the platform's timer context. Single atomic store —
287/// safe in any thread / ISR context.
288///
289/// # Safety
290/// `user_data` must point at a live `AtomicSporadicState`; the caller
291/// of `PlatformTimer::create_periodic` owns the lifetime contract.
292pub extern "C" fn atomic_sporadic_refill_thunk(user_data: *mut core::ffi::c_void) {
293 if user_data.is_null() {
294 return;
295 }
296 let state = unsafe { &*(user_data as *const AtomicSporadicState) };
297 state
298 .budget_remaining_us
299 .store(state.budget_capacity_us, portable_atomic::Ordering::Release);
300}
301
302/// Phase 110.E — user-space sporadic-server runtime state.
303///
304/// Tracks remaining `budget_us` for the current period and the wall-
305/// clock instant of the last refill. The executor consults this state
306/// during dispatch: when `budget_remaining_us` reaches 0 the SC is
307/// suppressed until the next period boundary, at which point a refill
308/// resets the counter.
309///
310/// Refill cadence is polled — each `spin_once` checks whether the
311/// elapsed time since the last refill exceeds `period_us` and tops
312/// the budget back up. Less precise than an ISR-driven refill (Phase
313/// 110.E's per-platform timer hook is what gets that) but correct as
314/// an upper-bound bandwidth limiter.
315#[derive(Debug, Clone, Copy)]
316pub struct SporadicState {
317 pub budget_remaining_us: u32,
318 pub budget_capacity_us: u32,
319 pub period_us: u32,
320 pub last_refill_ms: u64,
321}
322
323impl SporadicState {
324 pub const fn new(budget_us: u32, period_us: u32) -> Self {
325 Self {
326 budget_remaining_us: budget_us,
327 budget_capacity_us: budget_us,
328 period_us,
329 last_refill_ms: 0,
330 }
331 }
332
333 /// Apply elapsed-time accounting since the previous spin. Returns
334 /// `true` if the SC has remaining budget after the refill check.
335 pub fn tick(&mut self, now_ms: u64, delta_us: u32) -> bool {
336 // Refill at period boundaries — coarse but correct.
337 if now_ms.saturating_sub(self.last_refill_ms) >= self.period_us as u64 / 1000 {
338 self.budget_remaining_us = self.budget_capacity_us;
339 self.last_refill_ms = now_ms;
340 }
341 self.budget_remaining_us = self.budget_remaining_us.saturating_sub(delta_us);
342 self.budget_remaining_us > 0
343 }
344}
345
346impl SchedContext {
347 pub const fn new_fifo() -> Self {
348 Self {
349 class: SchedClass::Fifo,
350 priority: Priority::Normal,
351 period_us: OptUs::NONE,
352 budget_us: OptUs::NONE,
353 deadline_us: OptUs::NONE,
354 deadline_policy: DeadlinePolicy::Activated,
355 os_pri: 0,
356 tt_window_offset_us: OptUs::NONE,
357 tt_window_duration_us: OptUs::NONE,
358 }
359 }
360}
361
362// ----------------------------------------------------------------------
363// Phase 110.G — TimeTriggered schedule-table API.
364//
365// ARINC-653-style cyclic executive: the major frame is partitioned
366// into fixed windows; each callback is bound to a window via
367// `SchedContext { tt_window_offset_us, tt_window_duration_us }`.
368// The runtime gate inside `Executor::spin_once` already enforces
369// per-window dispatch suppression (Phase 110.G runtime, landed
370// pre-session). This block adds the schedule-table types +
371// builder helpers so callers can declare a complete cyclic
372// schedule with a single API call instead of stitching
373// `create_sched_context` + `bind_handle_to_sched_context` together.
374// ----------------------------------------------------------------------
375
376/// One slot in a time-triggered schedule.
377///
378/// Window `[offset_us, offset_us + duration_us)` within the major
379/// frame. `name` is a static-lifetime label for diagnostics
380/// (logging, panic messages); the runtime never inspects it.
381#[derive(Debug, Clone, Copy)]
382pub struct TimeTriggeredWindow {
383 pub offset_us: u32,
384 pub duration_us: u32,
385 pub name: &'static str,
386}
387
388impl TimeTriggeredWindow {
389 pub const fn new(offset_us: u32, duration_us: u32, name: &'static str) -> Self {
390 Self {
391 offset_us,
392 duration_us,
393 name,
394 }
395 }
396}
397
398/// Fixed-size, no_std-friendly cyclic schedule. `N` is the
399/// declared maximum window count; `window_count` is the active
400/// length (callers can build the array up to `N` and set
401/// `window_count` to the actual size used).
402#[derive(Debug)]
403pub struct TimeTriggeredSchedule<const N: usize> {
404 pub major_frame_us: u32,
405 pub windows: [TimeTriggeredWindow; N],
406 pub window_count: usize,
407}
408
409impl<const N: usize> TimeTriggeredSchedule<N> {
410 /// Construct a schedule from an exhaustive `[TimeTriggeredWindow; N]`
411 /// array; `window_count` is set to `N`.
412 pub const fn new_full(major_frame_us: u32, windows: [TimeTriggeredWindow; N]) -> Self {
413 Self {
414 major_frame_us,
415 windows,
416 window_count: N,
417 }
418 }
419
420 /// Validate the schedule: every window must fit inside
421 /// `[0, major_frame_us)` and windows must be non-overlapping
422 /// in offset-sorted order. Sliding-window check; O(N²) is fine
423 /// because TT schedules are small (rarely > 16 slots).
424 pub fn validate(&self) -> Result<(), TimeTriggeredScheduleError> {
425 if self.major_frame_us == 0 {
426 return Err(TimeTriggeredScheduleError::ZeroMajorFrame);
427 }
428 if self.window_count > N {
429 return Err(TimeTriggeredScheduleError::WindowCountOverflow);
430 }
431 for (i, w) in self.windows[..self.window_count].iter().enumerate() {
432 if w.duration_us == 0 {
433 return Err(TimeTriggeredScheduleError::ZeroWindowDuration { window: i });
434 }
435 let end = (w.offset_us as u64) + (w.duration_us as u64);
436 if end > self.major_frame_us as u64 {
437 return Err(TimeTriggeredScheduleError::WindowExceedsMajorFrame { window: i });
438 }
439 for (j, other) in self.windows[..self.window_count].iter().enumerate() {
440 if i == j {
441 continue;
442 }
443 let o_end = (other.offset_us as u64) + (other.duration_us as u64);
444 let overlaps = (w.offset_us as u64) < o_end && (other.offset_us as u64) < end;
445 if overlaps {
446 return Err(TimeTriggeredScheduleError::WindowsOverlap {
447 window_a: i,
448 window_b: j,
449 });
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457/// Validation errors for a [`TimeTriggeredSchedule`].
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum TimeTriggeredScheduleError {
460 ZeroMajorFrame,
461 WindowCountOverflow,
462 ZeroWindowDuration { window: usize },
463 WindowExceedsMajorFrame { window: usize },
464 WindowsOverlap { window_a: usize, window_b: usize },
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn opt_us_sentinel_round_trip() {
473 assert!(!OptUs::NONE.is_some());
474 assert_eq!(OptUs::NONE.get(), None);
475 let some = OptUs::from_us(42);
476 assert!(some.is_some());
477 assert_eq!(some.get().map(|nz| nz.get()), Some(42));
478 assert_eq!(some.raw(), 42);
479 }
480
481 #[test]
482 fn opt_us_layout_is_u32() {
483 // ABI guard — `OptUs` MUST stay `#[repr(transparent)]` over
484 // `u32` so cbindgen emits a plain `uint32_t`.
485 assert_eq!(core::mem::size_of::<OptUs>(), core::mem::size_of::<u32>());
486 assert_eq!(core::mem::align_of::<OptUs>(), core::mem::align_of::<u32>());
487 }
488
489 #[test]
490 fn sched_context_default_is_fifo() {
491 let sc = SchedContext::default();
492 assert_eq!(sc.class, SchedClass::Fifo);
493 assert!(!sc.period_us.is_some());
494 assert!(!sc.budget_us.is_some());
495 assert!(!sc.deadline_us.is_some());
496 assert_eq!(sc.deadline_policy, DeadlinePolicy::Activated);
497 }
498}