nros_platform/board/tier.rs
1//! Per-tier scheduling descriptors — Phase 228.E (RFC-0015 execution
2//! model, RFC-0016 priority mapping).
3//!
4//! A [`TierSpec`] names one RTOS task that an `Executor` will run on a
5//! shared RMW session. The orchestration `main()` (codegen-emitted)
6//! passes a `&[TierSpec]` to the board's `run_tiers(...)`; the board
7//! opens the session once, then spawns one task per spec — each task
8//! opens an `Executor` over the *same* session (the `Borrowed` store),
9//! sets its `active_groups` filter, registers nodes (only its tier's
10//! callbacks take), and spins. The LEAST urgent tier runs on the boot
11//! task itself ([`boot_tier_index`]); the rest are spawned.
12//!
13//! Issue 0636 — that last sentence used to read "the highest-priority tier
14//! runs on the boot task", and it was both wrong and harmful. Boards took
15//! `tiers[0]`, and `resolve_tiers` orders by RAW number descending WITHOUT
16//! inverting per kernel, so `tiers[0]` is the most urgent tier on
17//! bigger-number-wins kernels (NuttX, FreeRTOS, POSIX) and the least urgent on
18//! smaller-number-wins ones (Zephyr, ThreadX). Which tier owned the session
19//! therefore depended on the kernel's number direction, which nobody chose.
20//!
21//! On a uniprocessor with FIFO scheduling, an owner that outranks its peers and
22//! then spins starves them: a lower-priority tier runs only in whatever gap the
23//! owner's `spin_once` happens to leave. Measured on NuttX at 1 of 5 runs
24//! reaching a spawned tier's first statement at all. `sched_yield` cannot fix
25//! it — under SCHED_FIFO a yield rotates the caller within its OWN priority
26//! queue and never lets a lower-priority thread run, which is why two partial
27//! fixes moved the rate to 4 of 6 and could not converge.
28//!
29//! So the owner is chosen, not inherited from an ordering: it is the tier that
30//! outranks nothing.
31//!
32//! Priorities are declared on a normalized **0–31** scale (RFC-0016):
33//! 0 = idle, 12 = normal (default app), 31 = critical. The per-RTOS
34//! mappers below lower that to each kernel's native range. Keeping the
35//! scale RTOS-agnostic lets the same `system.toml [tiers.*]` deploy
36//! across families without rewriting priorities.
37
38/// One scheduling tier: an RTOS task running an `Executor` over the
39/// shared session, admitting only the listed callback groups.
40///
41/// All fields are literal-constructible so the codegen emitter can bake
42/// a `const`/`static` array of these straight from the resolved tier
43/// table in `nros-plan.json`.
44#[derive(Clone, Copy, Debug)]
45pub struct TierSpec<'a> {
46 /// Tier name (matches the `system.toml [tiers.<name>]` key); used
47 /// for the spawned task's debug name.
48 pub name: &'a str,
49 /// Callback groups admitted on this tier. Passed verbatim to
50 /// `Executor::set_active_groups`; an empty slice = wildcard
51 /// (admit every group — the single-tier degenerate case).
52 pub groups: &'a [&'a str],
53 /// **Raw per-RTOS** task priority — the value passed straight to the
54 /// native spawn call. The system author writes it in
55 /// `[tiers.<name>.<rtos>].priority`, so it is already in the target
56 /// kernel's scale (FreeRTOS 0–7, ThreadX 0–31 lower=higher, …);
57 /// `i64` admits Zephyr's negative coop priorities. (The
58 /// `*_priority_for` mappers in this module are a separate utility for
59 /// authors who prefer a normalized 0–31 scale; the codegen path uses
60 /// the raw value verbatim.)
61 pub priority: i64,
62 /// Task stack size in bytes. `0` = let the board pick its default.
63 pub stack_bytes: usize,
64 /// Spin period for this tier's `spin_once` loop, in microseconds.
65 pub spin_period_us: u64,
66 // -- RFC-0052 / phase-296 W2 — the previously-dropped tier fields ride
67 // -- the spec end-to-end. Boards consume what their kernel offers; the
68 // -- bake already rejected platform-inapplicable knobs (fail-loud), so
69 // -- an unconsumed Some(..) here is a board TODO, not a silent config
70 // -- loss.
71 /// CPU core to pin the tier task to (SMP boards); `None` = unpinned.
72 pub core: Option<u32>,
73 /// ThreadX preemption threshold (ThreadX targets only; bake-validated).
74 pub preempt_threshold: Option<i64>,
75 /// Round-robin time slice in µs (#0266): `Some` requests time-slicing among
76 /// same-priority tiers. ThreadX-only today (bake-validated); `None` = FIFO.
77 pub time_slice_us: Option<u64>,
78 /// Scheduling class: `"best_effort"` | `"real_time"` |
79 /// `"time_triggered"` (bake rejects `"interrupt"`); `None` = plain
80 /// priority tier.
81 pub class: Option<&'a str>,
82 /// Callback period (µs) — `time_triggered` window period / sporadic
83 /// replenishment period.
84 pub period_us: Option<u64>,
85 /// Execution-time budget (µs) — sporadic-server budget (W3 wires it
86 /// into the executor's `SchedContext`).
87 pub budget_us: Option<u64>,
88 /// Relative deadline (µs) for the deadline monitor (W3).
89 pub deadline_us: Option<u64>,
90 /// On deadline miss: `"ignore"` | `"warn"` | `"skip"` | `"fault"`.
91 pub deadline_policy: Option<&'a str>,
92}
93
94/// Which way a kernel's raw priority numbers run.
95///
96/// `TierSpec::priority` is RAW — the number the system author wrote in
97/// `[tiers.<name>.<rtos>].priority`, already in the target kernel's scale — so
98/// only the board knows which end is urgent. Issue 0636.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum PriorityDirection {
101 /// NuttX (1..=255), FreeRTOS (0..=7), POSIX SCHED_FIFO: bigger wins.
102 BiggerIsMoreUrgent,
103 /// Zephyr (negative = cooperative), ThreadX (0..=31): smaller wins.
104 SmallerIsMoreUrgent,
105}
106
107/// Index of the tier the BOOT task should run: the least urgent one.
108///
109/// Issue 0636 — the boot task owns the session and spins forever, so it must
110/// not outrank the tiers it spawned. See the module docs for what happened when
111/// it did.
112///
113/// * **Ties keep the earliest index**, so a table whose tiers all declare the
114/// same priority (or none) behaves exactly as `tiers[0]` did.
115/// * **An undeclared priority is least urgent, but only where 0 is out of
116/// range.** On bigger-wins kernels the valid range starts at 1 and `0` is the
117/// "inherit" sentinel every board already tests for, so a tier that declared
118/// nothing makes no claim and is the safest owner. On smaller-wins kernels 0
119/// is a REAL priority — a very urgent one on ThreadX — and treating it as a
120/// sentinel would hand the session to the most urgent tier, which is the bug
121/// this function exists to prevent.
122#[must_use]
123pub fn boot_tier_index(tiers: &[TierSpec<'_>], direction: PriorityDirection) -> usize {
124 /// Bigger = more urgent, in one comparable scale.
125 fn urgency(priority: i64, direction: PriorityDirection) -> i64 {
126 match direction {
127 PriorityDirection::BiggerIsMoreUrgent if priority <= 0 => i64::MIN,
128 PriorityDirection::BiggerIsMoreUrgent => priority,
129 // Negate rather than subtract: the range is the kernel's, and any
130 // fixed origin would be one more number to keep in step with it.
131 PriorityDirection::SmallerIsMoreUrgent => priority.saturating_neg(),
132 }
133 }
134 let mut best = 0;
135 for (i, tier) in tiers.iter().enumerate().skip(1) {
136 if urgency(tier.priority, direction) < urgency(tiers[best].priority, direction) {
137 best = i;
138 }
139 }
140 best
141}
142
143impl<'a> TierSpec<'a> {
144 /// A degenerate single tier: wildcard groups, normal priority, the
145 /// board's default stack. Equivalent to today's single-task entry.
146 pub const fn single() -> TierSpec<'static> {
147 TierSpec {
148 name: "default",
149 groups: &[],
150 priority: 0,
151 stack_bytes: 0,
152 spin_period_us: 1_000,
153 core: None,
154 preempt_threshold: None,
155 time_slice_us: None,
156 class: None,
157 period_us: None,
158 budget_us: None,
159 deadline_us: None,
160 deadline_policy: None,
161 }
162 }
163}
164
165/// FreeRTOS native priority (0..=`configMAX_PRIORITIES-1`, here 0–7)
166/// for a normalized 0–31 priority. RFC-0016 §Design: linear
167/// interpolation `(n*7 + 15) / 31` (round-to-nearest), so 0→0 (idle)
168/// and 31→7 (highest). Higher number = higher priority on FreeRTOS.
169pub const fn freertos_priority_for(normalized: u8) -> u8 {
170 let n = clamp31(normalized) as u32;
171 ((n * 7 + 15) / 31) as u8
172}
173
174/// ThreadX native priority (0..=31, **lower = higher priority**) for a
175/// normalized 0–31 priority. RFC-0016: inverted scale `31 - n`, so the
176/// normalized idle (0) maps to ThreadX 31 (lowest) and normalized
177/// critical (31) maps to ThreadX 0 (highest).
178pub const fn threadx_priority_for(normalized: u8) -> u8 {
179 31 - clamp31(normalized)
180}
181
182/// POSIX `nice` value (`-20`..=`19`, **lower = more CPU**) for a
183/// normalized 0–31 priority. Best-effort: native preemption normally
184/// uses the default scheduler (strict ordering needs `SCHED_FIFO` +
185/// privileges), so this is an advisory niceness, linear over the scale
186/// and clamped, with idle (0) pinned to the maximum `19`. Anchors track
187/// the RFC-0016 table (12→0 normal, 31→-20 critical).
188pub const fn posix_nice_for(normalized: u8) -> i32 {
189 let n = clamp31(normalized) as i32;
190 if n == 0 {
191 return 19;
192 }
193 // Slope ≈ -1.25 nice/step around the normal anchor (n=12 → 0).
194 let nice = (-5 * (n - 12)) / 4;
195 if nice > 19 {
196 19
197 } else if nice < -20 {
198 -20
199 } else {
200 nice
201 }
202}
203
204// ============================================================================
205// Issue 0636 option 3 — the tier spin loop's scheduled gap
206// ============================================================================
207//
208// [`boot_tier_index`] stopped the starvation by making the session owner the
209// tier that outranks nothing. That is correct and it is what fixed the issue,
210// but the guarantee it gives rests entirely on the priority ORDER being right:
211// any tier that outranks another and then spins without blocking can still hold
212// a uniprocessor forever, because under SCHED_FIFO a thread yields the CPU only
213// by blocking (`sched_yield` rotates within the caller's own priority queue and
214// never reaches a lower one).
215//
216// Whether a spin blocks is, today, a property of the TRANSPORT rather than of
217// the tier. `Executor::spin_once` takes the blocking arm only when nothing has
218// already woken it:
219//
220// if !was_woken && has_async_wake && node_wake => wake.wait_ms(timeout)
221// else => drive_io(timeout_ms)
222//
223// The `was_woken` arm drives I/O with a ZERO timeout — deliberately, because a
224// wake means there is data to drain. So under sustained arrival every iteration
225// takes the non-blocking arm, and the loop has no blocking point exactly when
226// the system is busiest. That is the "transport luck" this type removes.
227//
228// # The rule
229//
230// A tier loop may not run for longer than one INTERVAL without either blocking
231// in its own spin or taking a bounded gap. Both halves are derived from what
232// the author already declared, so there is no new knob:
233//
234// * **interval** = `max(spin_period_us, 10 ms)`. The gap costs 1 ms, so the
235// floor is what caps the worst-case overhead at 10 %; a tier that declares a
236// longer period pays proportionally less.
237// * **"it blocked"** = an iteration took at least half the declared spin
238// period. A spin that waited its timeout is near the period; a free-running
239// one is orders of magnitude shorter. Half is the midpoint between them, not
240// a tuned constant.
241// * **the gap** = `nros_platform_sleep_ms(1)`. Deliberately NOT `sleep_us`:
242// that one's ABI contract says it may SPIN when the platform has no
243// sub-millisecond timer, and a spin is not a scheduling point — it would
244// satisfy the code and not the requirement. 1 ms is the smallest sleep the
245// ABI guarantees actually blocks.
246//
247// Cost is zero on the blocking path: a loop whose spins wait never opens a
248// window without a block in it, so it never sleeps here.
249//
250// # Why the state is one `u64`
251//
252// The C tier runners (`*_run_tiers.c`) and the Rust ones must share ONE
253// implementation — a second spelling per language is how the tier-priority
254// marker drifted in the first place (see the module docs above). A shared
255// STRUCT would be a hand-mirrored FFI layout, which this repo has been bitten
256// by three times, so the state is a single opaque `u64` that C keeps and passes
257// back: the window's start timestamp with bit 0 carrying "something blocked in
258// this window". One nanosecond of timestamp resolution is the whole cost.
259
260/// Overhead ceiling for the gap: 1 ms of sleep per 10 ms of free-running spin.
261const GAP_INTERVAL_FLOOR_US: u64 = 10_000;
262
263/// The gap itself. The smallest sleep the platform ABI guarantees will BLOCK
264/// rather than spin — see the module note on `sleep_us`.
265const GAP_MS: usize = 1;
266
267/// Gap window length for a tier that declared `spin_period_us`.
268#[must_use]
269pub const fn gap_interval_us(spin_period_us: u64) -> u64 {
270 let p = spin_period_us;
271 if p > GAP_INTERVAL_FLOOR_US {
272 p
273 } else {
274 GAP_INTERVAL_FLOOR_US
275 }
276}
277
278/// Did this iteration BLOCK? True when it lasted at least half the declared
279/// spin period — see the module note for why half.
280#[must_use]
281pub const fn iteration_blocked(iter_ns: u64, spin_period_us: u64) -> bool {
282 let half_us = spin_period_us / 2;
283 // A tier declaring a period under 2 us has no meaningful "half"; treat any
284 // measurable time as a block rather than gapping such a loop every window.
285 if half_us == 0 {
286 return iter_ns > 0;
287 }
288 iter_ns >= half_us.saturating_mul(1_000)
289}
290
291/// The whole decision, as a pure function of the clock — so it is testable on a
292/// host with no platform linked, which is the only way the arithmetic below
293/// gets exercised at all (every caller is an RTOS image).
294///
295/// Returns the next state and whether the caller must sleep. `state` is the
296/// value returned by the previous call, or `0` to start a window.
297#[must_use]
298pub const fn gap_step(state: u64, iter_ns: u64, now_ns: u64, spin_period_us: u64) -> (u64, bool) {
299 let window_start_ns = state & !STATE_FLAGS;
300 let blocked_in_window =
301 (state & STATE_BLOCKED) != 0 || iteration_blocked(iter_ns, spin_period_us);
302
303 // First ever call: open a window at `now` and decide nothing yet.
304 //
305 // "Open" is its own BIT, not `state != 0`. The clock's epoch is
306 // platform-defined and a port may legitimately hand out 0 (or 1) on the
307 // first read — and with the timestamp alone as the state, such a port
308 // re-entered this branch on every iteration and the gap never fired at all.
309 // Found by the unit test below, not on a target.
310 if state & STATE_OPEN == 0 {
311 return (open_state(now_ns, blocked_in_window), false);
312 }
313
314 if now_ns.saturating_sub(window_start_ns) < gap_interval_us(spin_period_us) * 1_000 {
315 return (open_state(window_start_ns, blocked_in_window), false);
316 }
317
318 // Window closed. Gap only if nothing in it blocked. Either way the next
319 // window starts here; the caller re-stamps after sleeping (see `TierSpinGap`)
320 // so the sleep is not charged to the window it opens.
321 (open_state(now_ns, false), !blocked_in_window)
322}
323
324/// Window-open marker — see [`gap_step`] for why the timestamp alone will not do.
325const STATE_OPEN: u64 = 1;
326/// "Something blocked in this window."
327const STATE_BLOCKED: u64 = 2;
328const STATE_FLAGS: u64 = STATE_OPEN | STATE_BLOCKED;
329
330/// Pack a window start (2 ns of resolution given up to the two flag bits).
331#[must_use]
332const fn open_state(window_start_ns: u64, blocked: bool) -> u64 {
333 (window_start_ns & !STATE_FLAGS) | STATE_OPEN | if blocked { STATE_BLOCKED } else { 0 }
334}
335
336/// One tier spin loop's scheduled gap. Construct beside the loop, call
337/// [`TierSpinGap::after_spin`] at the bottom of every iteration.
338///
339/// ```ignore
340/// let mut gap = TierSpinGap::new(tier.spin_period_us);
341/// loop {
342/// let t0 = gap.mark();
343/// crt.spin_once(period);
344/// gap.after_spin(t0);
345/// }
346/// ```
347#[derive(Debug)]
348pub struct TierSpinGap {
349 state: u64,
350 spin_period_us: u64,
351 gaps: u64,
352}
353
354unsafe extern "C" {
355 fn nros_platform_clock_ns() -> u64;
356 fn nros_platform_sleep_ms(ms: usize);
357}
358
359impl TierSpinGap {
360 #[must_use]
361 pub const fn new(spin_period_us: u64) -> Self {
362 Self {
363 state: 0,
364 spin_period_us,
365 gaps: 0,
366 }
367 }
368
369 /// Timestamp for the start of an iteration.
370 #[must_use]
371 pub fn mark(&self) -> u64 {
372 // SAFETY: a bare monotonic read with no preconditions, defined by
373 // whichever platform port linked this image.
374 unsafe { nros_platform_clock_ns() }
375 }
376
377 /// Close out one iteration, sleeping if this one closed a window in which
378 /// nothing blocked.
379 pub fn after_spin(&mut self, iter_start_ns: u64) {
380 let now = self.mark();
381 let (state, sleep) = gap_step(
382 self.state,
383 now.saturating_sub(iter_start_ns),
384 now,
385 self.spin_period_us,
386 );
387 self.state = state;
388 if sleep {
389 // SAFETY: no preconditions; blocks the calling task for >= 1 ms.
390 unsafe { nros_platform_sleep_ms(GAP_MS) };
391 self.gaps = self.gaps.saturating_add(1);
392 // Re-stamp so the sleep itself is not charged to the new window.
393 self.state = open_state(self.mark(), false);
394 }
395 }
396
397 /// How many gaps this loop has taken — for the tier heartbeat, so a busy
398 /// image can say whether the guarantee is being exercised or is dead code.
399 #[must_use]
400 pub const fn gaps(&self) -> u64 {
401 self.gaps
402 }
403}
404
405/// The same decision for the C tier runners, which keep the `u64` themselves.
406///
407/// Pass `0` on the first call. The sleep happens HERE, so the two languages
408/// share one implementation of both halves of the rule.
409///
410/// # Safety
411/// None beyond the platform ABI being linked, which is true in any image that
412/// has tiers to run.
413#[unsafe(no_mangle)]
414pub extern "C" fn nros_tier_spin_gap_step(
415 state: u64,
416 iter_start_ns: u64,
417 now_ns: u64,
418 spin_period_us: u64,
419) -> u64 {
420 let (next, sleep) = gap_step(
421 state,
422 now_ns.saturating_sub(iter_start_ns),
423 now_ns,
424 spin_period_us,
425 );
426 if sleep {
427 // SAFETY: no preconditions; blocks the calling task for >= 1 ms.
428 unsafe { nros_platform_sleep_ms(GAP_MS) };
429 // SAFETY: bare monotonic read.
430 return open_state(unsafe { nros_platform_clock_ns() }, false);
431 }
432 next
433}
434
435const fn clamp31(n: u8) -> u8 {
436 if n > 31 { 31 } else { n }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn freertos_anchors_match_rfc0016() {
445 // RFC-0016 table column FreeRTOS(0–7).
446 assert_eq!(freertos_priority_for(0), 0); // idle
447 assert_eq!(freertos_priority_for(12), 3); // normal
448 assert_eq!(freertos_priority_for(20), 5); // high
449 assert_eq!(freertos_priority_for(31), 7); // critical
450 // Saturates above the scale.
451 assert_eq!(freertos_priority_for(200), 7);
452 }
453
454 #[test]
455 fn threadx_inverts_scale() {
456 assert_eq!(threadx_priority_for(0), 31); // idle → lowest
457 assert_eq!(threadx_priority_for(31), 0); // critical → highest
458 assert_eq!(threadx_priority_for(12), 19);
459 }
460
461 #[test]
462 fn posix_nice_anchors() {
463 assert_eq!(posix_nice_for(0), 19); // idle pinned to max nice
464 assert_eq!(posix_nice_for(12), 0); // normal
465 assert_eq!(posix_nice_for(31), -20); // critical (clamped)
466 assert!(posix_nice_for(20) < 0); // high → negative nice
467 }
468
469 #[test]
470 fn single_tier_is_wildcard() {
471 let t = TierSpec::single();
472 assert!(t.groups.is_empty());
473 assert_eq!(t.priority, 0);
474 }
475
476 fn spec(name: &'static str, priority: i64) -> TierSpec<'static> {
477 TierSpec {
478 name,
479 priority,
480 ..TierSpec::single()
481 }
482 }
483
484 /// issue 0636 — the owner must be the tier that outranks nothing, and
485 /// "nothing" depends on which end of the kernel's scale is urgent.
486 #[test]
487 fn boot_tier_is_the_least_urgent_on_either_direction() {
488 // As `resolve_tiers` hands them over: RAW number, descending.
489 let tiers = [spec("high", 110), spec("mid", 105), spec("low", 100)];
490
491 // Bigger wins (NuttX, FreeRTOS, POSIX): 100 is least urgent. This is
492 // the case that starved — the board used to take index 0 (110) and
493 // spin there.
494 assert_eq!(
495 boot_tier_index(&tiers, PriorityDirection::BiggerIsMoreUrgent),
496 2
497 );
498 // Smaller wins (Zephyr, ThreadX): 110 is least urgent, which is index
499 // 0 — the arrangement those boards already had, now stated rather than
500 // inherited from the sort direction.
501 assert_eq!(
502 boot_tier_index(&tiers, PriorityDirection::SmallerIsMoreUrgent),
503 0
504 );
505 }
506
507 /// A tier that declared nothing makes no claim, so it is the safest owner —
508 /// but only where 0 is out of the kernel's range.
509 #[test]
510 fn undeclared_is_least_urgent_only_where_zero_is_a_sentinel() {
511 let tiers = [spec("declared", 12), spec("undeclared", 0)];
512 assert_eq!(
513 boot_tier_index(&tiers, PriorityDirection::BiggerIsMoreUrgent),
514 1
515 );
516 // On ThreadX 0 is a REAL priority, and the most urgent one. Treating it
517 // as a sentinel would hand the session to the tier that must never own
518 // it — the exact inversion this function exists to prevent.
519 assert_eq!(
520 boot_tier_index(&tiers, PriorityDirection::SmallerIsMoreUrgent),
521 0
522 );
523 }
524
525 // ---- issue 0636 option 3 — the spin gap ----
526
527 /// What `TierSpinGap` does after sleeping: open a fresh window at `now`.
528 fn open_state_for_test(now_ns: u64) -> u64 {
529 super::open_state(now_ns, false)
530 }
531
532 /// The interval is the declared period, floored so the 1 ms gap can never
533 /// cost more than 10 %.
534 #[test]
535 fn gap_interval_floors_at_ten_ms() {
536 assert_eq!(gap_interval_us(200), 10_000); // 200 us tier: floored
537 assert_eq!(gap_interval_us(10_000), 10_000); // exactly the floor
538 assert_eq!(gap_interval_us(100_000), 100_000); // 100 ms tier: its own
539 }
540
541 /// "It blocked" is half the declared period — a waited spin lands near the
542 /// period, a free-running one is orders of magnitude below it.
543 #[test]
544 fn blocked_is_half_the_declared_period() {
545 // 10 ms tier: 5 ms counts, 4.9 ms does not.
546 assert!(iteration_blocked(5_000_000, 10_000));
547 assert!(!iteration_blocked(4_900_000, 10_000));
548 // A free-running iteration (~2 us) never counts.
549 assert!(!iteration_blocked(2_000, 10_000));
550 // Degenerate sub-2 us period: any measurable time counts, so such a
551 // loop is not gapped every window.
552 assert!(iteration_blocked(1, 1));
553 assert!(!iteration_blocked(0, 1));
554 }
555
556 /// A loop whose spins BLOCK never sleeps here — the whole point of keeping
557 /// the cost off the healthy path.
558 #[test]
559 fn a_blocking_loop_never_gaps() {
560 let period_us = 10_000;
561 let mut state = 0u64;
562 let mut now = 1_000_000_000u64;
563 for _ in 0..500 {
564 // Each iteration takes its full declared period.
565 let iter_ns = period_us * 1_000;
566 now += iter_ns;
567 let (next, sleep) = gap_step(state, iter_ns, now, period_us);
568 assert!(!sleep, "a spin that waited its period must not be gapped");
569 state = next;
570 }
571 }
572
573 /// A free-running loop gaps once per interval, and not more.
574 #[test]
575 fn a_free_running_loop_gaps_once_per_interval() {
576 let period_us = 10_000; // interval = 10 ms
577 let mut state = 0u64;
578 let mut now = 42u64;
579 let mut sleeps = 0;
580 // 100 ms of 5 us iterations.
581 for _ in 0..20_000 {
582 let iter_ns = 5_000;
583 now += iter_ns;
584 let (next, sleep) = gap_step(state, iter_ns, now, period_us);
585 if sleep {
586 sleeps += 1;
587 // The caller re-stamps after sleeping; model that.
588 now += 1_000_000;
589 state = open_state_for_test(now);
590 } else {
591 state = next;
592 }
593 }
594 // 100 ms of spinning, 10 ms windows, 1 ms of sleep charged to none of
595 // them: 9 or 10 depending on where the first window opens.
596 assert!(
597 (9..=10).contains(&sleeps),
598 "expected ~one gap per 10 ms window, got {sleeps}"
599 );
600 }
601
602 /// ONE blocking iteration is enough to spare the whole window — the rule is
603 /// "the loop reached a scheduling point", not "every spin did".
604 #[test]
605 fn one_block_in_a_window_suppresses_its_gap() {
606 let period_us = 10_000;
607 let mut state = 0u64;
608 let mut now = 1_000u64;
609 let mut sleeps = 0;
610 for i in 0..4_000 {
611 // One blocking iteration every 1000 free-running ones, which is
612 // more than one per 10 ms window at 5 us per iteration.
613 let iter_ns = if i % 1_000 == 999 { 6_000_000 } else { 5_000 };
614 now += iter_ns;
615 let (next, sleep) = gap_step(state, iter_ns, now, period_us);
616 if sleep {
617 sleeps += 1;
618 }
619 state = next;
620 }
621 assert_eq!(sleeps, 0, "a window containing a real block must not gap");
622 }
623
624 /// The first call opens a window instead of deciding: a zero state cannot
625 /// be read as "window opened at time 0", because the clock epoch is the
626 /// platform's and a fresh image legitimately reads small values.
627 #[test]
628 fn first_call_opens_a_window_and_never_sleeps() {
629 for now in [0u64, 1, 5_000_000, u64::MAX / 2] {
630 let (state, sleep) = gap_step(0, 5_000, now, 10_000);
631 assert!(!sleep, "the first iteration must not be gapped");
632 assert_ne!(state, 0, "the window must be open after the first call");
633 }
634 }
635
636 /// A port whose clock epoch IS zero must still gap. With the timestamp
637 /// alone as the state, `now = 0` re-read as "not started yet" on every
638 /// iteration and the guarantee silently did not exist on that port.
639 #[test]
640 fn a_clock_that_starts_at_zero_still_gaps() {
641 let period_us = 10_000;
642 let mut state = 0u64;
643 let mut now = 0u64; // epoch, exactly
644 let mut sleeps = 0;
645 for _ in 0..10_000 {
646 now += 5_000; // 5 us of free-running spin
647 let (next, sleep) = gap_step(state, 5_000, now, period_us);
648 if sleep {
649 sleeps += 1;
650 now += 1_000_000;
651 state = open_state_for_test(now);
652 } else {
653 state = next;
654 }
655 }
656 assert!(sleeps > 0, "a zero-epoch clock must not disable the gap");
657 }
658
659 /// Ties keep the earliest index, so a table with one tier — or with every
660 /// tier equal — behaves exactly as `tiers[0]` did before issue 0636.
661 #[test]
662 fn ties_and_single_tier_keep_index_zero() {
663 let same = [spec("a", 7), spec("b", 7), spec("c", 7)];
664 for dir in [
665 PriorityDirection::BiggerIsMoreUrgent,
666 PriorityDirection::SmallerIsMoreUrgent,
667 ] {
668 assert_eq!(boot_tier_index(&same, dir), 0);
669 assert_eq!(boot_tier_index(&same[..1], dir), 0);
670 }
671 }
672}