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