Skip to main content

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}