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}