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 /// WORST overrun amount seen (`measured_us - budget_us`) since the
252 /// last `clear_overrun_stats`; `0` when none has been observed.
253 ///
254 /// This used to `store` the most RECENT overrun while its doc claimed
255 /// it "carries the worst-case observation for tuning". A large overrun
256 /// followed by a small one silently replaced the number anyone would
257 /// have sized a budget from, and the only surviving evidence of the bad
258 /// dispatch was that `overrun_count` had gone up by one. It is a
259 /// `fetch_max` now, which is what the name and the purpose both say.
260 pub last_overrun_us: portable_atomic::AtomicU32,
261 /// Worst execution time observed on this SC, in microseconds, recorded
262 /// on EVERY dispatch rather than only on an overrun.
263 ///
264 /// `overrun_count` and `last_overrun_us` answer "did it exceed the
265 /// budget, and by how much". Neither can answer "how close did it come"
266 /// — a system that never overruns produces no evidence at all, and that
267 /// is exactly the system whose budget you are trying to choose. Sizing
268 /// `budget_us` from a measured maximum is the whole point of the field.
269 ///
270 /// Only meaningful where the dispatch loop actually measures: elapsed is
271 /// `0` unless a latency monitor, a deadline action, or a clock is
272 /// present (`measure_us` in `spin_once`). No clock, no number.
273 pub max_exec_us: portable_atomic::AtomicU32,
274}
275
276impl AtomicSporadicState {
277 pub const fn new(budget_us: u32, period_us: u32) -> Self {
278 Self {
279 budget_remaining_us: portable_atomic::AtomicU32::new(budget_us),
280 last_refill_ms: portable_atomic::AtomicU32::new(0),
281 budget_capacity_us: budget_us,
282 period_us,
283 overrun_count: portable_atomic::AtomicU32::new(0),
284 last_overrun_us: portable_atomic::AtomicU32::new(0),
285 max_exec_us: portable_atomic::AtomicU32::new(0),
286 }
287 }
288
289 /// Record one overrun: callback measured runtime exceeded the
290 /// SC's `budget_us`. Bumps `overrun_count` + stores the absolute
291 /// overrun amount in `last_overrun_us`. Called from the
292 /// per-callback runtime closure inside `Executor::spin_once`.
293 #[inline]
294 pub fn record_overrun(&self, overrun_us: u32) {
295 self.overrun_count
296 .fetch_add(1, portable_atomic::Ordering::Relaxed);
297 self.last_overrun_us
298 .fetch_max(overrun_us, portable_atomic::Ordering::Relaxed);
299 }
300
301 /// Record one dispatch's measured execution time. Called on EVERY
302 /// dispatch charged to this SC, overrun or not.
303 #[inline]
304 pub fn record_exec(&self, elapsed_us: u32) {
305 self.max_exec_us
306 .fetch_max(elapsed_us, portable_atomic::Ordering::Relaxed);
307 }
308
309 /// Worst execution time seen since the last `clear_overrun_stats`.
310 #[inline]
311 pub fn max_exec_us(&self) -> u32 {
312 self.max_exec_us.load(portable_atomic::Ordering::Relaxed)
313 }
314
315 /// Reset both overrun statistics. Useful when tuning the budget
316 /// across windows (monitoring code logs + clears periodically).
317 #[inline]
318 pub fn clear_overrun_stats(&self) {
319 self.overrun_count
320 .store(0, portable_atomic::Ordering::Relaxed);
321 self.last_overrun_us
322 .store(0, portable_atomic::Ordering::Relaxed);
323 self.max_exec_us
324 .store(0, portable_atomic::Ordering::Relaxed);
325 }
326
327 /// Read the budget atomically; spin_once consults this to decide
328 /// whether to skip the SC's entries this cycle.
329 pub fn has_budget(&self) -> bool {
330 self.budget_remaining_us
331 .load(portable_atomic::Ordering::Acquire)
332 > 0
333 }
334
335 /// Saturating subtract — used by spin_once after dispatching a
336 /// callback bound to this SC.
337 pub fn consume(&self, us: u32) {
338 let mut cur = self
339 .budget_remaining_us
340 .load(portable_atomic::Ordering::Acquire);
341 loop {
342 let next = cur.saturating_sub(us);
343 match self.budget_remaining_us.compare_exchange_weak(
344 cur,
345 next,
346 portable_atomic::Ordering::Release,
347 portable_atomic::Ordering::Acquire,
348 ) {
349 Ok(_) => return,
350 Err(observed) => cur = observed,
351 }
352 }
353 }
354}
355
356/// C-callable refill thunk that `PlatformTimer::create_periodic`
357/// invokes from the platform's timer context. Single atomic store —
358/// safe in any thread / ISR context.
359///
360/// # Safety
361/// `user_data` must point at a live `AtomicSporadicState`; the caller
362/// of `PlatformTimer::create_periodic` owns the lifetime contract.
363pub extern "C" fn atomic_sporadic_refill_thunk(user_data: *mut core::ffi::c_void) {
364 if user_data.is_null() {
365 return;
366 }
367 let state = unsafe { &*(user_data as *const AtomicSporadicState) };
368 state
369 .budget_remaining_us
370 .store(state.budget_capacity_us, portable_atomic::Ordering::Release);
371}
372
373/// Phase 110.E — user-space sporadic-server runtime state.
374///
375/// Tracks remaining `budget_us` for the current period and the wall-
376/// clock instant of the last refill. The executor consults this state
377/// during dispatch: when `budget_remaining_us` reaches 0 the SC is
378/// suppressed until the next period boundary, at which point a refill
379/// resets the counter.
380///
381/// Refill cadence is polled — each `spin_once` checks whether the
382/// elapsed time since the last refill exceeds `period_us` and tops
383/// the budget back up. Less precise than an ISR-driven refill (Phase
384/// 110.E's per-platform timer hook is what gets that) but correct as
385/// an upper-bound bandwidth limiter.
386#[derive(Debug, Clone, Copy)]
387pub struct SporadicState {
388 pub budget_remaining_us: u32,
389 pub budget_capacity_us: u32,
390 pub period_us: u32,
391 pub last_refill_ms: u64,
392 /// issue 0736 — consecutive spins in which this SC was skipped for want of
393 /// budget. A skip is invisible by construction: `spin_once` `continue`s
394 /// past the entry, so a starved tier looks exactly like an idle one. This
395 /// counter is what lets it say so. Reset on any dispatch.
396 pub consecutive_budget_skips: u32,
397 /// issue 0736 — skips and dispatches over a rolling window.
398 ///
399 /// The consecutive counter above catches TOTAL starvation quickly and
400 /// misses the commoner shape entirely: a budget that merely THROTTLES.
401 /// Measured on nuttx-arm/rust, a 5 ms budget per 10 ms period held the
402 /// tier to about a quarter of its declared rate — skip a few, refill,
403 /// dispatch, repeat — so no streak ever reached the consecutive threshold
404 /// and the tier was silently 4x slow. A declaration that cannot be met has
405 /// to be reported whether it starves or merely throttles.
406 pub window_skips: u32,
407 pub window_dispatches: u32,
408 /// Worst execution time observed on this SC, in microseconds, recorded
409 /// on EVERY dispatch.
410 ///
411 /// This is the polled path, and issue 0736 established it is the one
412 /// that matters: `has_budget` prefers the atomic state only when a
413 /// refill timer was registered, which no board or entry does. So a
414 /// number recorded only on the atomic side would be a number no shipped
415 /// image ever writes.
416 ///
417 /// Sizing `budget_us` needs a measured maximum, and the overrun
418 /// counters cannot supply one: they say nothing at all until the budget
419 /// is already exceeded, which is the wrong end of the question when the
420 /// budget is what you are trying to choose.
421 ///
422 /// Only meaningful where the dispatch loop measures: elapsed is `0`
423 /// unless a latency monitor, a deadline action, or a clock is present.
424 pub max_exec_us: u32,
425}
426
427impl SporadicState {
428 pub const fn new(budget_us: u32, period_us: u32) -> Self {
429 Self {
430 budget_remaining_us: budget_us,
431 budget_capacity_us: budget_us,
432 period_us,
433 last_refill_ms: 0,
434 consecutive_budget_skips: 0,
435 window_skips: 0,
436 window_dispatches: 0,
437 max_exec_us: 0,
438 }
439 }
440
441 /// Replenish the budget at period boundaries. Returns `true` if the SC has
442 /// budget remaining afterwards.
443 ///
444 /// issue 0736 — this used to be `tick(now_ms, delta_us)`, which refilled
445 /// AND then charged the whole inter-spin `delta_us` as consumption. A
446 /// budget bounds the CPU the SC's callbacks consume; the wall-clock gap
447 /// between two spins is not that, and on any target where the gap exceeds
448 /// the budget it exhausts the SC on every single spin no matter what the
449 /// callbacks did. Measured on nuttx-arm/rust: `delta_us` 10_000..80_000
450 /// against a 5_000 us budget, giving 1200 budget skips against 3 dispatches
451 /// while the sibling tier — identical but declaring no budget — dispatched
452 /// on all 450 of its spins.
453 ///
454 /// The `delta_us` charge was documented as a "worst-case attribution"
455 /// standing in for per-callback measurement. That measurement now exists
456 /// and runs on every flavour, so consumption belongs to
457 /// [`Self::consume`], called with what the callback actually cost.
458 pub fn refill(&mut self, now_ms: u64) -> bool {
459 // Refill at period boundaries — coarse but correct.
460 if now_ms.saturating_sub(self.last_refill_ms) >= self.period_us as u64 / 1000 {
461 self.budget_remaining_us = self.budget_capacity_us;
462 self.last_refill_ms = now_ms;
463 }
464 self.budget_remaining_us > 0
465 }
466
467 /// Charge measured callback runtime against the remaining budget.
468 ///
469 /// The polled-state twin of `AtomicSporadicState::consume`. Both exist
470 /// because the atomic one is only present when a caller has registered a
471 /// refill timer via `Executor::register_sporadic_timer` — which, outside
472 /// this crate's own tests, nothing does. Every shipped image runs THIS
473 /// path, so it is the one that has to be right (issue 0736).
474 pub fn consume(&mut self, us: u32) {
475 self.budget_remaining_us = self.budget_remaining_us.saturating_sub(us);
476 self.consecutive_budget_skips = 0;
477 self.window_dispatches = self.window_dispatches.saturating_add(1);
478 // Every dispatch, not only the overrunning ones. See `max_exec_us`.
479 if us > self.max_exec_us {
480 self.max_exec_us = us;
481 }
482 }
483
484 /// How many dispatch opportunities before the throttle ratio is judged.
485 /// Long enough that a brief burst of skips is not news; short enough to
486 /// close inside a short run. 1000 was the first value and it NEVER closed
487 /// on the measured case — that tier misses roughly 240 dispatches over the
488 /// whole e2e window, so a 1000-opportunity window is larger than the
489 /// evidence and the warning existed without ever being reachable, which is
490 /// the same silence it was written to break.
491 pub const BUDGET_WINDOW: u32 = 200;
492 /// Report when more than this share of the window was skipped. A quarter
493 /// is already a tier delivering at 75% of what it declared.
494 pub const BUDGET_SKIP_REPORT_PERMILLE: u32 = 250;
495
496 /// Close the window if it is full. Returns `Some((skips, total))` when the
497 /// window closed with a skip share worth reporting.
498 pub fn take_budget_window(&mut self) -> Option<(u32, u32)> {
499 let total = self.window_skips + self.window_dispatches;
500 if total < Self::BUDGET_WINDOW {
501 return None;
502 }
503 let skips = self.window_skips;
504 self.window_skips = 0;
505 self.window_dispatches = 0;
506 if skips.saturating_mul(1000) / total.max(1) >= Self::BUDGET_SKIP_REPORT_PERMILLE {
507 Some((skips, total))
508 } else {
509 None
510 }
511 }
512
513 /// Record one budget-skipped dispatch. Returns `Some(n)` when the streak
514 /// has reached a length worth reporting — at 100, then each power of ten —
515 /// so a starved SC is loud once and does not then flood the console.
516 pub fn note_budget_skip(&mut self) -> Option<u32> {
517 self.consecutive_budget_skips = self.consecutive_budget_skips.saturating_add(1);
518 self.window_skips = self.window_skips.saturating_add(1);
519 let n = self.consecutive_budget_skips;
520 // Once when the streak is clearly not noise, then sparsely: a starved
521 // SC must be loud, and must not then drown the console it is reporting
522 // on. (The measured case ran 1200 consecutive skips.)
523 if n == 100 || (n > 100 && n.is_multiple_of(1000)) {
524 Some(n)
525 } else {
526 None
527 }
528 }
529}
530
531impl SchedContext {
532 pub const fn new_fifo() -> Self {
533 Self {
534 class: SchedClass::Fifo,
535 priority: Priority::Normal,
536 period_us: OptUs::NONE,
537 budget_us: OptUs::NONE,
538 deadline_us: OptUs::NONE,
539 deadline_policy: DeadlinePolicy::Activated,
540 os_pri: 0,
541 deadline_action: DeadlineAction::Ignore,
542 tt_window_offset_us: OptUs::NONE,
543 tt_window_duration_us: OptUs::NONE,
544 }
545 }
546
547 /// RFC-0052 — the **common backend** that lowers an RTOS-agnostic tier
548 /// policy (`[tiers.<t>]` class / budget / period / deadline) to a
549 /// [`SchedContext`]. ONE implementation shared by every language: the Rust
550 /// runtime ([`crate::node_runtime::ExecutorNodeRuntime::apply_tier_sched_policy`])
551 /// and the C / C++ entries (`nros_{c,cpp}_create_sched_context_from_policy`)
552 /// all call this, so the mapping can never drift between codegen paths.
553 ///
554 /// - `real_time` + `budget_us` + `period_us` → [`SchedClass::Sporadic`]
555 /// (budget + RT replenishment period on the SC);
556 /// - `best_effort` → [`SchedClass::BestEffort`];
557 /// - `time_triggered` + `period_us` → a TT window (major frame =
558 /// `period_us`, window = `budget_us` or the whole frame). The returned
559 /// `Some(frame_us)` is the major frame the caller must install via
560 /// [`crate::executor::Executor::register_time_triggered_dispatcher`]
561 /// (the SC itself stays `Fifo`-class per the 110.G TT refactor);
562 /// - `deadline_us` sets the SC deadline; `deadline_policy` its
563 /// [`DeadlineAction`].
564 ///
565 /// Returns `None` when the tier declares no class / budget / deadline — the
566 /// caller keeps the default `Fifo` SC (byte-identical pre-policy behavior).
567 /// `os_pri` is intentionally NOT set here (it is an orthogonal per-callback
568 /// OS-priority knob the caller applies); this fn covers only the
569 /// RTOS-agnostic real-time policy.
570 pub fn from_tier_policy(
571 class: Option<&str>,
572 period_us: Option<u64>,
573 budget_us: Option<u64>,
574 deadline_us: Option<u64>,
575 deadline_policy: Option<&str>,
576 ) -> Option<(Self, Option<u32>)> {
577 let sporadic = class == Some("real_time") && budget_us.is_some() && period_us.is_some();
578 let best_effort = class == Some("best_effort");
579 let time_triggered = class == Some("time_triggered") && period_us.is_some();
580 if !sporadic && !best_effort && !time_triggered && deadline_us.is_none() {
581 return None;
582 }
583 let clamp = |v: u64| v.min(u32::MAX as u64) as u32;
584 let mut sc = Self::default();
585 let mut tt_frame = None;
586 if sporadic {
587 sc.class = SchedClass::Sporadic;
588 sc.budget_us = OptUs::from_us(clamp(budget_us.unwrap_or(0)));
589 sc.period_us = OptUs::from_us(clamp(period_us.unwrap_or(0)));
590 } else if best_effort {
591 sc.class = SchedClass::BestEffort;
592 } else if time_triggered {
593 tt_frame = Some(clamp(period_us.unwrap_or(0)));
594 let window_us = clamp(budget_us.unwrap_or(period_us.unwrap_or(0)));
595 sc.tt_window_duration_us = OptUs::from_us(window_us);
596 }
597 if let Some(d) = deadline_us {
598 sc.deadline_us = OptUs::from_us(clamp(d));
599 }
600 if let Some(action) = deadline_policy {
601 sc.deadline_action = DeadlineAction::from_tier_str(action);
602 }
603 Some((sc, tt_frame))
604 }
605}
606
607// ----------------------------------------------------------------------
608// Phase 110.G — TimeTriggered schedule-table API.
609//
610// ARINC-653-style cyclic executive: the major frame is partitioned
611// into fixed windows; each callback is bound to a window via
612// `SchedContext { tt_window_offset_us, tt_window_duration_us }`.
613// The runtime gate inside `Executor::spin_once` already enforces
614// per-window dispatch suppression (Phase 110.G runtime, landed
615// pre-session). This block adds the schedule-table types +
616// builder helpers so callers can declare a complete cyclic
617// schedule with a single API call instead of stitching
618// `create_sched_context` + `bind_handle_to_sched_context` together.
619// ----------------------------------------------------------------------
620
621/// One slot in a time-triggered schedule.
622///
623/// Window `[offset_us, offset_us + duration_us)` within the major
624/// frame. `name` is a static-lifetime label for diagnostics
625/// (logging, panic messages); the runtime never inspects it.
626#[derive(Debug, Clone, Copy)]
627pub struct TimeTriggeredWindow {
628 pub offset_us: u32,
629 pub duration_us: u32,
630 pub name: &'static str,
631}
632
633impl TimeTriggeredWindow {
634 pub const fn new(offset_us: u32, duration_us: u32, name: &'static str) -> Self {
635 Self {
636 offset_us,
637 duration_us,
638 name,
639 }
640 }
641}
642
643/// Fixed-size, no_std-friendly cyclic schedule. `N` is the
644/// declared maximum window count; `window_count` is the active
645/// length (callers can build the array up to `N` and set
646/// `window_count` to the actual size used).
647#[derive(Debug)]
648pub struct TimeTriggeredSchedule<const N: usize> {
649 pub major_frame_us: u32,
650 pub windows: [TimeTriggeredWindow; N],
651 pub window_count: usize,
652}
653
654impl<const N: usize> TimeTriggeredSchedule<N> {
655 /// Construct a schedule from an exhaustive `[TimeTriggeredWindow; N]`
656 /// array; `window_count` is set to `N`.
657 pub const fn new_full(major_frame_us: u32, windows: [TimeTriggeredWindow; N]) -> Self {
658 Self {
659 major_frame_us,
660 windows,
661 window_count: N,
662 }
663 }
664
665 /// Validate the schedule: every window must fit inside
666 /// `[0, major_frame_us)` and windows must be non-overlapping
667 /// in offset-sorted order. Sliding-window check; O(N²) is fine
668 /// because TT schedules are small (rarely > 16 slots).
669 pub fn validate(&self) -> Result<(), TimeTriggeredScheduleError> {
670 if self.major_frame_us == 0 {
671 return Err(TimeTriggeredScheduleError::ZeroMajorFrame);
672 }
673 if self.window_count > N {
674 return Err(TimeTriggeredScheduleError::WindowCountOverflow);
675 }
676 for (i, w) in self.windows[..self.window_count].iter().enumerate() {
677 if w.duration_us == 0 {
678 return Err(TimeTriggeredScheduleError::ZeroWindowDuration { window: i });
679 }
680 let end = (w.offset_us as u64) + (w.duration_us as u64);
681 if end > self.major_frame_us as u64 {
682 return Err(TimeTriggeredScheduleError::WindowExceedsMajorFrame { window: i });
683 }
684 for (j, other) in self.windows[..self.window_count].iter().enumerate() {
685 if i == j {
686 continue;
687 }
688 let o_end = (other.offset_us as u64) + (other.duration_us as u64);
689 let overlaps = (w.offset_us as u64) < o_end && (other.offset_us as u64) < end;
690 if overlaps {
691 return Err(TimeTriggeredScheduleError::WindowsOverlap {
692 window_a: i,
693 window_b: j,
694 });
695 }
696 }
697 }
698 Ok(())
699 }
700}
701
702/// Validation errors for a [`TimeTriggeredSchedule`].
703#[derive(Debug, Clone, Copy, PartialEq, Eq)]
704pub enum TimeTriggeredScheduleError {
705 ZeroMajorFrame,
706 WindowCountOverflow,
707 ZeroWindowDuration { window: usize },
708 WindowExceedsMajorFrame { window: usize },
709 WindowsOverlap { window_a: usize, window_b: usize },
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn opt_us_sentinel_round_trip() {
718 assert!(!OptUs::NONE.is_some());
719 assert_eq!(OptUs::NONE.get(), None);
720 let some = OptUs::from_us(42);
721 assert!(some.is_some());
722 assert_eq!(some.get().map(|nz| nz.get()), Some(42));
723 assert_eq!(some.raw(), 42);
724 }
725
726 #[test]
727 fn opt_us_layout_is_u32() {
728 // ABI guard — `OptUs` MUST stay `#[repr(transparent)]` over
729 // `u32` so cbindgen emits a plain `uint32_t`.
730 assert_eq!(core::mem::size_of::<OptUs>(), core::mem::size_of::<u32>());
731 assert_eq!(core::mem::align_of::<OptUs>(), core::mem::align_of::<u32>());
732 }
733
734 #[test]
735 fn sched_context_default_is_fifo() {
736 let sc = SchedContext::default();
737 assert_eq!(sc.class, SchedClass::Fifo);
738 assert!(!sc.period_us.is_some());
739 assert!(!sc.budget_us.is_some());
740 assert!(!sc.deadline_us.is_some());
741 assert_eq!(sc.deadline_policy, DeadlinePolicy::Activated);
742 }
743
744 // RFC-0052 — the common-backend tier→SchedContext lowering, shared by the
745 // Rust runtime (`apply_tier_sched_policy`) and the C/C++ FFI
746 // (`nros_{c,cpp}_create_sched_context_from_policy`). One place, one test.
747
748 #[test]
749 fn from_tier_policy_real_time_lowers_to_sporadic() {
750 let (sc, tt) = SchedContext::from_tier_policy(
751 Some("real_time"),
752 Some(20_000),
753 Some(3_000),
754 Some(15_000),
755 Some("fault"),
756 )
757 .expect("real_time+budget+period is a policy");
758 assert_eq!(sc.class, SchedClass::Sporadic);
759 assert_eq!(sc.period_us.raw(), 20_000);
760 assert_eq!(sc.budget_us.raw(), 3_000);
761 assert_eq!(sc.deadline_us.raw(), 15_000);
762 assert_eq!(sc.deadline_action, DeadlineAction::Fault);
763 assert_eq!(tt, None);
764 }
765
766 #[test]
767 fn from_tier_policy_real_time_without_budget_or_period_is_not_sporadic() {
768 // `real_time` needs BOTH budget and period to be a sporadic server; a
769 // bare `real_time` with neither (and no deadline) declares no policy.
770 assert!(
771 SchedContext::from_tier_policy(Some("real_time"), None, None, None, None).is_none()
772 );
773 }
774
775 #[test]
776 fn from_tier_policy_best_effort() {
777 let (sc, tt) = SchedContext::from_tier_policy(Some("best_effort"), None, None, None, None)
778 .expect("best_effort is a policy");
779 assert_eq!(sc.class, SchedClass::BestEffort);
780 assert_eq!(tt, None);
781 }
782
783 #[test]
784 fn from_tier_policy_time_triggered_returns_major_frame() {
785 // TT stays Fifo-class (110.G) but returns the major frame to register
786 // and sets the window duration (budget → window, else whole frame).
787 let (sc, tt) = SchedContext::from_tier_policy(
788 Some("time_triggered"),
789 Some(10_000),
790 Some(4_000),
791 None,
792 None,
793 )
794 .expect("time_triggered+period is a policy");
795 assert_eq!(sc.class, SchedClass::Fifo);
796 assert_eq!(tt, Some(10_000));
797 assert_eq!(sc.tt_window_duration_us.raw(), 4_000);
798 }
799
800 #[test]
801 fn from_tier_policy_deadline_only() {
802 // A deadline with no RT class still yields a policy (Fifo + deadline).
803 let (sc, _tt) = SchedContext::from_tier_policy(None, None, None, Some(8_000), Some("warn"))
804 .expect("a deadline is a policy");
805 assert_eq!(sc.class, SchedClass::Fifo);
806 assert_eq!(sc.deadline_us.raw(), 8_000);
807 assert_eq!(sc.deadline_action, DeadlineAction::Warn);
808 }
809
810 #[test]
811 fn from_tier_policy_none_when_no_policy() {
812 // No class, no deadline → None → caller keeps the default Fifo SC.
813 assert!(SchedContext::from_tier_policy(None, Some(1_000), None, None, None).is_none());
814 assert!(SchedContext::from_tier_policy(Some("default"), None, None, None, None).is_none());
815 }
816}
817
818#[cfg(test)]
819mod exec_high_water_tests {
820 use super::*;
821
822 /// The number a budget gets sized from has to survive a later, smaller
823 /// dispatch. `max_exec_us` is a maximum, not a last-write.
824 #[test]
825 fn polled_state_keeps_the_worst_execution_not_the_latest() {
826 let mut st = SporadicState::new(1000, 10_000);
827 st.consume(120);
828 st.consume(900);
829 st.consume(30);
830 assert_eq!(st.max_exec_us, 900);
831 }
832
833 /// Recorded on EVERY dispatch, including ones well inside budget. A
834 /// system that never overruns still has to say how close it came.
835 #[test]
836 fn polled_state_records_without_any_overrun() {
837 let mut st = SporadicState::new(10_000, 20_000);
838 st.consume(700);
839 st.consume(450);
840 assert_eq!(
841 st.max_exec_us, 700,
842 "no overrun occurred, yet 700 is the evidence"
843 );
844 }
845
846 #[test]
847 fn atomic_state_keeps_the_worst_execution_not_the_latest() {
848 let st = AtomicSporadicState::new(1000, 10_000);
849 st.record_exec(120);
850 st.record_exec(900);
851 st.record_exec(30);
852 assert_eq!(st.max_exec_us(), 900);
853 }
854
855 /// Regression: `last_overrun_us` used to `store`, so a big overrun
856 /// followed by a small one lost the big one entirely -- while its own
857 /// doc promised "the worst-case observation for tuning".
858 #[test]
859 fn last_overrun_keeps_the_worst_not_the_most_recent() {
860 let st = AtomicSporadicState::new(1000, 10_000);
861 st.record_overrun(500);
862 st.record_overrun(20);
863 assert_eq!(
864 st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
865 500,
866 "a later small overrun must not erase the worst one"
867 );
868 assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 2);
869 }
870
871 #[test]
872 fn clearing_stats_resets_the_high_water_too() {
873 let st = AtomicSporadicState::new(1000, 10_000);
874 st.record_exec(4242);
875 st.record_overrun(7);
876 st.clear_overrun_stats();
877 assert_eq!(st.max_exec_us(), 0);
878 assert_eq!(
879 st.last_overrun_us.load(portable_atomic::Ordering::Relaxed),
880 0
881 );
882 assert_eq!(st.overrun_count.load(portable_atomic::Ordering::Relaxed), 0);
883 }
884}