moqtap_proxy/release_timer.rs
1//! A process-wide release wheel: one dedicated OS thread, one min-heap of
2//! deadlines, one `CancellationToken` per armed deadline.
3//!
4//! `tokio::time::sleep` cannot be used for release timing. Tokio parks its
5//! runtime on a wait with a millisecond timeout, and on Windows every
6//! *interruptible* wait is quantised to the ~15.6 ms system tick: measured
7//! on Windows 11, `tokio::time::sleep(1 ms)` returns after 15.96 ms and
8//! `Condvar::wait_timeout(1 ms)` is 14.6 ms late. `std::thread::sleep` is
9//! the exception — std implements it with a high-resolution waitable timer,
10//! so it lands within ~0.3 ms — but it cannot be woken early.
11//!
12//! This module recovers *accurate *and* interruptible* in plain safe code: the
13//! thread sleeps in [`SLICE`]-bounded steps and re-reads the heap between them,
14//! and parks on an untimed `Condvar` — which *is* woken promptly — when there
15//! is nothing to wait for. End to end (the thread wakes, cancels a token, a
16//! tokio task in a `select!` resumes) the measured lateness on Windows 11 is
17//! p50 0.11-0.17 ms and p95 0.52-0.57 ms, against 11-13 ms for
18//! `tokio::time::sleep`.
19//!
20//! Nothing here is `pub`, the whole module is safe code, and there is no
21//! `#[cfg]` outside its test module: one implementation runs on every
22//! platform. That is what lets this file move into `quinn-netem`, give
23//! each impaired socket its own wheel, or be replaced wholesale, without
24//! any of it being a breaking change.
25
26use std::cmp::{Ordering, Reverse};
27use std::collections::BinaryHeap;
28use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak};
29use std::thread::JoinHandle;
30use std::time::{Duration, Instant};
31use tokio_util::sync::CancellationToken;
32
33use crate::instrument::TimerBackend;
34
35/// How long the release thread may sleep without re-reading the heap.
36///
37/// This is a responsiveness knob, not an accuracy one: it sets how fast a
38/// newly armed *nearer* deadline is noticed, never the accuracy of one the
39/// wheel already holds. The last sleep before a deadline is
40/// `min(remaining, SLICE) == remaining`, so accuracy comes from
41/// `std::thread::sleep` whatever this constant is.
42const SLICE: Duration = Duration::from_micros(500);
43/// Heap length below which the sweep never runs.
44///
45/// Below this many entries the residue is bounded and cheap (~40 bytes a
46/// slot), and rebuilding a short heap on a timer would trade that bounded
47/// residue for an unbounded `retain` rate.
48const SWEEP_MIN_LEN: usize = 64;
49/// Minimum interval between two sweeps.
50const SWEEP_INTERVAL: Duration = Duration::from_millis(250);
51
52/// A registered deadline. Cheap to clone (one `Arc` + one token clone).
53///
54/// Dropping every clone abandons the deadline: the wheel drops the slot at
55/// its own instant, or at the next sweep, whichever comes first. There is
56/// no unregister call.
57#[derive(Clone, Debug)]
58pub(crate) struct Deadline {
59 entry: Arc<Entry>,
60}
61
62impl Deadline {
63 /// The token the wheel cancels once `Instant::now() >= at`.
64 ///
65 /// Level-triggered: awaiting it after the wheel has fired resolves
66 /// immediately, so the engine may create and drop `token().cancelled()`
67 /// on every `select!` iteration without a lost-wakeup race and without
68 /// re-registering with the wheel.
69 pub(crate) fn token(&self) -> &CancellationToken {
70 &self.entry.token
71 }
72
73 /// A deadline that has already passed. Allocates one token, never
74 /// touches the wheel, never starts the release thread.
75 fn already_due() -> Self {
76 let token = CancellationToken::new();
77 token.cancel();
78 Self { entry: Arc::new(Entry { token }) }
79 }
80}
81
82/// Register `at` with the process-wide wheel.
83///
84/// **The sole lazy-initialisation trigger for the release thread.** When
85/// `at <= Instant::now()` it returns an already-cancelled [`Deadline`]
86/// without constructing the wheel, so `Delay { by: 0 }`, a delay swallowed
87/// by the monotonic clamp, and every session that never delays all leave
88/// [`crate::instrument::release_timer_started`] `false`.
89pub(crate) fn arm_at(at: Instant) -> Deadline {
90 if at <= Instant::now() {
91 return Deadline::already_due();
92 }
93 shared().arm(at)
94}
95
96/// The process-wide wheel, constructed on first use. Never dropped: it lives in
97/// a `static OnceLock<ReleaseTimer>`, so its thread is never joined and the
98/// *atexit joins a thread that holds a lock* hazard cannot arise.
99fn shared() -> &'static ReleaseTimer {
100 static SHARED: OnceLock<ReleaseTimer> = OnceLock::new();
101 SHARED.get_or_init(|| {
102 let timer = ReleaseTimer::new();
103 // Published here and not in `ReleaseTimer::new`, which also runs
104 // for owned wheels (this module's tests, any per-socket owner)
105 // that are not the process-wide one.
106 crate::instrument::note_release_timer_started(timer.backend());
107 timer
108 })
109}
110
111/// Whether the process-wide wheel has been constructed.
112///
113/// Read by this module's tests; every non-test caller in the crate wants
114/// [`backend`], which answers the same question and says which one.
115#[allow(dead_code)]
116pub(crate) fn started() -> bool {
117 crate::instrument::release_timer_started()
118}
119
120/// Which primitive the process-wide wheel's thread waits on, or `None` if
121/// it was never constructed. This is what
122/// [`crate::instrument::release_timer_backend`] returns.
123pub(crate) fn backend() -> Option<TimerBackend> {
124 crate::instrument::release_timer_backend()
125}
126
127/// An owned release wheel: one OS thread, one heap, one parker.
128///
129/// The process-wide wheel is one of these in a `OnceLock`. Instances
130/// created directly (this module's tests today, per-socket owners later)
131/// join their thread on drop — unless the drop is itself running on that
132/// thread, which the `Drop` impl below explains.
133pub(crate) struct ReleaseTimer {
134 inner: Arc<Inner>,
135 thread: Option<JoinHandle<()>>,
136}
137
138impl ReleaseTimer {
139 /// Resolve a backend and start the release thread.
140 ///
141 /// Honours `MOQTAP_RELEASE_TIMER` (`slice` | `condvar`), read once,
142 /// here. Unset or unrecognised means `slice`. **Cannot fail**, and
143 /// that is a property of the design rather than an omission: there is
144 /// no OS object to fail to create, so there is no fallback ladder and
145 /// no error path to test.
146 pub(crate) fn new() -> Self {
147 let parker = match std::env::var("MOQTAP_RELEASE_TIMER").as_deref() {
148 Ok("condvar") => Parker::Condvar,
149 // An unrecognised value resolves to the sliced sleeper rather
150 // than panicking: this is a diagnostic knob read inside a
151 // forwarding task, and `backend()` reports what was actually
152 // chosen, so a typo is observable without being fatal.
153 _ => Parker::SleepSlice,
154 };
155 let inner = Arc::new(Inner {
156 state: Mutex::new(State {
157 heap: BinaryHeap::new(),
158 seq: 0,
159 stopping: false,
160 wakes: 0,
161 last_sweep: Instant::now(),
162 }),
163 cv: Condvar::new(),
164 parker,
165 });
166 let thread_inner = Arc::clone(&inner);
167 let thread = std::thread::Builder::new()
168 .name("moqtap-release".to_owned())
169 .spawn(move || run(thread_inner))
170 .expect("release timer thread");
171 Self { inner, thread: Some(thread) }
172 }
173
174 /// Register `at`. `at <= now` yields an already-cancelled [`Deadline`]
175 /// without touching the heap or waking the thread.
176 pub(crate) fn arm(&self, at: Instant) -> Deadline {
177 if at <= Instant::now() {
178 return Deadline::already_due();
179 }
180 let entry = Arc::new(Entry { token: CancellationToken::new() });
181 {
182 let mut g = self.inner.state.lock().expect("release wheel state");
183 g.seq += 1;
184 let seq = g.seq;
185 g.heap.push(Reverse(Slot { at, seq, entry: Arc::downgrade(&entry) }));
186 } // guard released before notify
187 self.inner.cv.notify_one(); // a no-op when nobody waits
188 Deadline { entry }
189 }
190
191 /// Which primitive this wheel's thread waits on.
192 pub(crate) fn backend(&self) -> TimerBackend {
193 match self.inner.parker {
194 Parker::SleepSlice => TimerBackend::SleepSlice,
195 Parker::Condvar => TimerBackend::Condvar,
196 }
197 }
198
199 /// Heap length, including slots whose [`Deadline`] was dropped and
200 /// that have not been popped or swept yet.
201 #[cfg(test)]
202 pub(crate) fn debug_len(&self) -> usize {
203 self.inner.state.lock().expect("release wheel state").heap.len()
204 }
205
206 /// Loop iterations the release thread has completed — one per wake.
207 /// Lets a test assert that an empty wheel costs nothing: the count must
208 /// not move across an idle window.
209 #[cfg(test)]
210 pub(crate) fn debug_wakes(&self) -> u64 {
211 self.inner.state.lock().expect("release wheel state").wakes
212 }
213}
214
215/// Sets `stopping`, wakes the thread, and joins it — except when the drop
216/// is itself running *on* that thread, where it detaches instead. Pending
217/// deadlines are abandoned, not fired: a dropped wheel has no listeners
218/// left.
219///
220/// # Why the join is conditional
221///
222/// The join is cheap only from *another* thread. Measured 0.097 ms with a
223/// 10 s deadline armed, because the thread never waits longer than one
224/// [`SLICE`] and so reads `stopping` promptly — but that measurement was
225/// taken with the wheel dropped from a thread other than its own release
226/// thread, and it licenses nothing about the other case. It was the only
227/// case anything could reach while the never-dropped process-wide wheel
228/// was the sole non-test owner; a wheel owned per socket is droppable
229/// from anywhere.
230///
231/// From the release thread the join is not a slow path, it is a permanent
232/// deadlock. Step 3 of [`run`] cancels tokens *outside* the lock and *on*
233/// the release thread, so every waker registered on a deadline — which is
234/// what awaiting `token().cancelled()` installs — runs there too. A waker
235/// that drops the last handle to the wheel enters this function on the
236/// release thread, and `join()` then waits for the thread executing it.
237/// `stopping` and `notify_all` do not rescue it: the thread is inside the
238/// cancel loop and can never return to the top of `run` to read the flag.
239/// Measured that way, the drop had not returned after 20 s against a
240/// 500 µs [`SLICE`], on every run.
241///
242/// Detaching there is not a compromise, it is the only outcome that
243/// terminates. `stopping` is set and `notify_all` sent before the branch,
244/// so the detached thread finishes the cancel loop it is in, sees the flag
245/// on its next iteration and returns on its own: a bounded, self-clearing
246/// thread rather than an unbounded hang. The handle already carries the
247/// id `spawn` gave the thread, so no separate field is needed to
248/// recognise it.
249impl Drop for ReleaseTimer {
250 fn drop(&mut self) {
251 {
252 let mut g = self.inner.state.lock().expect("release wheel state");
253 g.stopping = true;
254 }
255 self.inner.cv.notify_all();
256 if let Some(handle) = self.thread.take() {
257 if handle.thread().id() == std::thread::current().id() {
258 return; // detach: joining here would join this thread to itself
259 }
260 let _ = handle.join();
261 }
262 }
263}
264
265// ── internals ──────────────────────────────────────────────────────────
266
267/// What a [`Deadline`] and its heap slot share.
268///
269/// The frozen sketch also carried an `at: Instant` here. It is dropped
270/// rather than carried: nothing reads it (the ordering copy lives in
271/// [`Slot`]), and a never-read field is `dead_code`, which this crate
272/// builds with `-D warnings`.
273#[derive(Debug)]
274struct Entry {
275 token: CancellationToken,
276}
277
278/// A heap slot. `Weak`, so a dropped [`Deadline`] costs a skipped pop
279/// rather than a token nobody holds.
280struct Slot {
281 at: Instant,
282 seq: u64,
283 entry: Weak<Entry>,
284}
285
286// `Weak` is neither `Ord` nor `Eq`, so these cannot be derived. Order is
287// `(at, seq)`, `seq` monotonic, so equal instants pop in registration
288// order. The heap is a `BinaryHeap<Reverse<Slot>>`, i.e. a *min*-heap:
289// inverting either half of this pair turns the wheel into a LIFO that
290// serves the farthest deadline first, and nothing but a wrong-looking
291// timing test would say so — which is why
292// `the_heap_is_a_min_heap_and_ties_break_by_registration` below tests it
293// purely.
294impl PartialEq for Slot {
295 fn eq(&self, o: &Self) -> bool {
296 (self.at, self.seq) == (o.at, o.seq)
297 }
298}
299impl Eq for Slot {}
300impl Ord for Slot {
301 fn cmp(&self, o: &Self) -> Ordering {
302 (self.at, self.seq).cmp(&(o.at, o.seq))
303 }
304}
305impl PartialOrd for Slot {
306 fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
307 Some(self.cmp(o))
308 }
309}
310
311/// Everything the release thread and its registrars share under one lock.
312struct State {
313 /// A *min*-heap of armed deadlines.
314 heap: BinaryHeap<Reverse<Slot>>,
315 /// Monotonic registration counter, the tie-break in [`Slot`]'s `Ord`.
316 seq: u64,
317 /// Set by [`ReleaseTimer`]'s `Drop`; the thread returns on its next
318 /// wake.
319 stopping: bool,
320 /// Loop iterations. Always compiled — it is one `u64` increment under
321 /// a lock the thread already holds — and read only by `debug_wakes`.
322 /// The `+=` counts as a read, so `dead_code` does not fire on it in a
323 /// non-test build.
324 wakes: u64,
325 /// When the heap was last rebuilt to drop abandoned slots.
326 last_sweep: Instant,
327}
328
329/// The shared half of a wheel: what the thread and its registrars both
330/// hold an `Arc` to.
331struct Inner {
332 state: Mutex<State>,
333 cv: Condvar,
334 parker: Parker,
335}
336
337/// How the thread waits when a deadline is armed.
338///
339/// **Exactly one implementation ships.** `SleepSlice` is the backend on
340/// every platform. `Condvar` exists because [`TimerBackend::Condvar`] and
341/// `ImpairmentKind::CoarseReleaseTimer` are public API and must be
342/// reachable by a test: forcing it on Windows produces a genuinely
343/// tick-bound wheel — measured p50 lateness 12.256 ms for a 3 ms deadline
344/// — which is exactly what that impairment reports, so the fault
345/// injection is not a simulation. **No `#[cfg]`: both arms compile on
346/// every platform**, and the choice is made once, at construction, from
347/// `MOQTAP_RELEASE_TIMER`. This enum is the seam for adding a platform
348/// timer without touching the wheel.
349enum Parker {
350 /// `std::thread::sleep(min(remaining, SLICE))`, re-reading the heap
351 /// between slices.
352 SleepSlice,
353 /// `Condvar::wait_timeout` for the whole remainder: interruptible
354 /// everywhere, high-resolution only on Unix.
355 Condvar,
356}
357
358/// What the thread should do next. **Pure** — no clock read, no lock — so
359/// `the_wait_plan_never_overshoots` pins the never-overshoot rule without
360/// timing anything.
361#[derive(Debug, PartialEq, Eq)]
362enum Wait {
363 /// The head is due now; fire it before waiting again.
364 Fire,
365 /// Nothing is armed; park on the condvar with no timeout.
366 Idle,
367 /// Sleep this long, then re-read the heap.
368 Sleep(Duration),
369}
370
371/// Decide the next wait from a clock reading and the head of the heap.
372///
373/// Never returns a `Sleep` that runs past `next`: that, plus the
374/// `slot.at <= now` re-check in [`run`], is the whole never-fire-early
375/// guarantee.
376fn plan(now: Instant, next: Option<Instant>, slice: Duration) -> Wait {
377 match next {
378 None => Wait::Idle,
379 Some(at) => match at.checked_duration_since(now) {
380 None => Wait::Fire, // overdue
381 Some(d) if d.is_zero() => Wait::Fire, // due exactly now
382 Some(d) => Wait::Sleep(d.min(slice)), // never sleeps past `at`
383 },
384 }
385}
386
387/// The release thread's body.
388fn run(inner: Arc<Inner>) {
389 let mut g = inner.state.lock().expect("release wheel state");
390 loop {
391 g.wakes += 1;
392 if g.stopping {
393 return;
394 }
395 let now = Instant::now();
396
397 // 1. Collect everything due. `s.at <= now` is the whole
398 // never-fire-early guarantee; nothing else enforces it.
399 let mut fired: Vec<Arc<Entry>> = Vec::new();
400 while g.heap.peek().is_some_and(|Reverse(s)| s.at <= now) {
401 let Reverse(slot) = g.heap.pop().expect("peeked a due slot");
402 if let Some(e) = slot.entry.upgrade() {
403 fired.push(e);
404 }
405 }
406
407 // 2. Sweep abandoned slots. `strong_count()` does not create an
408 // `Arc`, so nothing can be dropped under the lock here.
409 if g.heap.len() > SWEEP_MIN_LEN
410 && now.saturating_duration_since(g.last_sweep) >= SWEEP_INTERVAL
411 {
412 g.heap.retain(|Reverse(s)| s.entry.strong_count() > 0);
413 g.last_sweep = now;
414 }
415
416 let next = g.heap.peek().map(|Reverse(s)| s.at);
417
418 // 3. Cancel *outside* the lock, then restart the iteration with a
419 // fresh clock. `cancel()` runs registered wakers; running them
420 // under the process-global wheel mutex would put scheduler code
421 // inside it, and a waker that re-armed would deadlock. The
422 // `Arc<Entry>`s are dropped out here too, for the same reason.
423 if !fired.is_empty() {
424 drop(g);
425 for e in fired {
426 e.token.cancel();
427 }
428 g = inner.state.lock().expect("release wheel state");
429 continue; // `next` is now stale
430 }
431
432 // 4. Wait. The guard was NOT released above, so `next` is current
433 // and no arm can have slipped in between.
434 match plan(Instant::now(), next, SLICE) {
435 Wait::Fire => continue,
436 Wait::Idle => {
437 g = inner.cv.wait(g).expect("release wheel state");
438 }
439 Wait::Sleep(d) => match inner.parker {
440 // A `notify_one` that lands here is lost on purpose: the
441 // thread is not waiting, and it re-reads the heap within
442 // `d <= SLICE` anyway.
443 Parker::SleepSlice => {
444 drop(g);
445 std::thread::sleep(d);
446 g = inner.state.lock().expect("release wheel state");
447 }
448 // Forced-coarse: interruptible but tick-bound on Windows.
449 // It waits the *whole* remainder rather than a slice —
450 // slicing a `wait_timeout` would cost ~15 ms per slice.
451 Parker::Condvar => {
452 let rest = next
453 .expect("Wait::Sleep implies an armed head")
454 .saturating_duration_since(Instant::now());
455 g = inner.cv.wait_timeout(g, rest).expect("release wheel state").0;
456 }
457 },
458 }
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::future::Future;
466 // Aliased: `Ordering` in this module is `std::cmp::Ordering`, which
467 // `Slot` needs.
468 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
469 use std::task::{Context, Poll, Wake, Waker};
470
471 // Every test here owns its wheel: it constructs `ReleaseTimer::new()`,
472 // arms against that instance and drops it. None calls `arm_at`,
473 // `shared()` or `started()` in a way that constructs the process-wide
474 // wheel. `cargo test` runs these concurrently with the other modules'
475 // unit tests in one process, so a shared heap would make `debug_len()`
476 // a race — and an owned wheel is torn down through `Drop`, so every run
477 // also exercises the instance API per-socket owners inherit, on both of
478 // that impl's branches.
479
480 /// Nearest-rank percentile of an already-sorted slice.
481 fn pct(sorted: &[Duration], q: usize) -> Duration {
482 assert!(!sorted.is_empty(), "no samples");
483 let rank = (sorted.len() * q).div_ceil(100);
484 sorted[rank.saturating_sub(1).min(sorted.len() - 1)]
485 }
486
487 /// Sort in place and return `(p50, p95, p99, max)`.
488 fn quantiles(samples: &mut [Duration]) -> (Duration, Duration, Duration, Duration) {
489 samples.sort_unstable();
490 (pct(samples, 50), pct(samples, 95), pct(samples, 99), pct(samples, 100))
491 }
492
493 /// The invariant every other release guarantee rests on: a
494 /// wheel that fires early is a wheel that reorders bytes. Structural,
495 /// no timing budget, cannot flake.
496 ///
497 /// Arming one at a time also makes this the coverage of the
498 /// empty-wheel `Condvar::wait` -> `notify_one` path, since the wheel
499 /// is idle before every sample.
500 ///
501 /// *Ablation:* fire the head after any wake instead of testing
502 /// `slot.at <= now` in step 1 of `run`; measured 300 of 300 fired
503 /// early, by up to 4.978 ms.
504 #[tokio::test]
505 async fn a_deadline_never_fires_early() {
506 let timer = ReleaseTimer::new();
507 for i in 0..300u64 {
508 // Deterministic spacings over 0.2-5 ms; 1601 is coprime with
509 // 4801, so 300 samples are 300 distinct durations.
510 let micros = 200 + (i * 1601) % 4801;
511 let at = Instant::now() + Duration::from_micros(micros);
512 let d = timer.arm(at);
513 d.token().cancelled().await;
514 let observed = Instant::now();
515 assert!(
516 observed >= at,
517 "sample {i} ({micros} us) fired {:?} early",
518 at.saturating_duration_since(observed)
519 );
520 }
521 }
522
523 /// Block until the release thread has completed `n` more iterations.
524 ///
525 /// Bounded, and it returns rather than panicking when it gives up: a
526 /// wheel that stops waking is what the callers' own assertions are
527 /// about, and each of them says more about it than this could.
528 fn wait_for_wakes(timer: &ReleaseTimer, n: u64) {
529 let want = timer.debug_wakes() + n;
530 let give_up = Instant::now() + Duration::from_secs(1);
531 while timer.debug_wakes() < want && Instant::now() < give_up {
532 std::thread::sleep(Duration::from_micros(200));
533 }
534 }
535
536 /// The property `SLICE` exists to provide: a nearer deadline
537 /// armed while the thread is already waiting on a far one is noticed
538 /// within one slice.
539 ///
540 /// *Ablation:* sleep the whole `remaining` instead of
541 /// `min(remaining, SLICE)`; measured 1975 ms late, and the far
542 /// deadline fires first. Re-run once the settle became a wake count:
543 /// `the +2 s deadline fired before the +5 ms one`, reached after
544 /// [`wait_for_wakes`] spends its whole bound, because under this
545 /// ablation the second wake it is counting never comes.
546 #[tokio::test]
547 async fn an_earlier_deadline_registered_later_preempts_the_current_wait() {
548 let timer = ReleaseTimer::new();
549 let far = timer.arm(Instant::now() + Duration::from_secs(2));
550 // The near deadline only arrives mid-wait if the thread is already
551 // in its slice loop, and two completed iterations say it is: the
552 // first is the wake that saw `far` armed, and a second can only
553 // follow a slice that ran out. This was a 2 ms sleep, which is the
554 // same claim with nothing behind it.
555 wait_for_wakes(&timer, 2);
556
557 let start = Instant::now();
558 let near_at = start + Duration::from_millis(5);
559 let near =
560 std::thread::scope(|s| s.spawn(|| timer.arm(near_at)).join().expect("arming thread"));
561 near.token().cancelled().await;
562 let elapsed = start.elapsed();
563
564 assert!(!far.token().is_cancelled(), "the +2 s deadline fired before the +5 ms one");
565 assert!(
566 elapsed < Duration::from_millis(500),
567 "the nearer deadline took {elapsed:?} to be noticed"
568 );
569 }
570
571 /// An empty wheel costs nothing: the thread parks on an
572 /// untimed `Condvar::wait`, not on a polling timeout.
573 ///
574 /// The one wake it allows is the post-fire iteration — the `continue`
575 /// in step 3 of `run`, which cancels the fired tokens and then goes
576 /// round once more to find the heap empty and park. So the last token
577 /// can resolve one iteration before the thread is asleep, and it is
578 /// exactly one iteration, because that one finds nothing armed and
579 /// takes the untimed `wait`.
580 ///
581 /// That wake used to be excluded by sleeping 20 ms first, on the
582 /// reasoning that one iteration cannot take longer than that. It
583 /// usually cannot — the probe measured deltas of 0, 0 and then 1
584 /// across three runs of the same binary — but a settle is a guess at
585 /// somebody else's scheduling, and a wrong guess fails a wheel that is
586 /// working. Counting the wake that is known to be coming needs no
587 /// guess, and 1 against the ~13 the ablation produces is not a margin
588 /// worth buying.
589 ///
590 /// *Ablation:* replace the empty-wheel `cv.wait(g)` with
591 /// `cv.wait_timeout(g, SLICE)`; on Windows each such wait returns
592 /// after ~15.6 ms, so the counter gains one per tick over the idle
593 /// window: `the thread woke 14 times over an idle 200ms window; at
594 /// most one is the post-fire iteration parking`.
595 #[tokio::test]
596 async fn the_release_thread_parks_when_the_wheel_is_empty() {
597 let timer = ReleaseTimer::new();
598 let at = Instant::now() + Duration::from_millis(5);
599 let armed: Vec<Deadline> = (0..100).map(|_| timer.arm(at)).collect();
600 for d in &armed {
601 d.token().cancelled().await;
602 }
603
604 /// The window the negative claim is made over. A window is the
605 /// only evidence there is for "nothing happened", so this one is
606 /// a duration and stays one.
607 const IDLE: Duration = Duration::from_millis(200);
608
609 let before = timer.debug_wakes();
610 tokio::time::sleep(IDLE).await;
611 let after = timer.debug_wakes();
612
613 assert!(
614 after - before <= 1,
615 "the thread woke {} times over an idle {IDLE:?} window; at most one is the \
616 post-fire iteration parking",
617 after - before
618 );
619 assert_eq!(timer.debug_len(), 0, "fired slots stayed in the heap");
620 }
621
622 /// Abandoned slots are swept rather than carried to their own
623 /// instants.
624 ///
625 /// The deadlines are far-dated on purpose: an earlier revision armed
626 /// them at +5 ms and asserted an empty heap 300 ms later, at which
627 /// point every slot was 295 ms overdue and the ordinary pop loop
628 /// emptied the heap whether the sweep existed or not — the test could
629 /// not fail. 30 s is also the case the sweep exists for: it is
630 /// `max_hold`'s default, and a torn-down `Hold` is exactly how a dead
631 /// slot survives its own instant.
632 ///
633 /// *Ablation:* disable the `retain` rebuild; measured 10 000 slots
634 /// still present after a second.
635 ///
636 /// This assumes the shipping [`Parker::SleepSlice`]. Under a forced
637 /// `MOQTAP_RELEASE_TIMER=condvar` the thread waits out the *whole*
638 /// remainder, so with only a +30 s head it does not wake to sweep at
639 /// all and this test fails by design — which is one more reason no
640 /// test in this binary may set that variable.
641 #[tokio::test]
642 async fn abandoned_far_deadlines_are_swept() {
643 let timer = ReleaseTimer::new();
644 let far = Instant::now() + Duration::from_secs(30);
645 for _ in 0..10_000 {
646 drop(timer.arm(far));
647 }
648 assert!(timer.debug_len() >= SWEEP_MIN_LEN, "the abandoned slots never reached the heap");
649
650 // Give the thread a reason to wake.
651 let wake = timer.arm(Instant::now() + Duration::from_millis(5));
652 wake.token().cancelled().await;
653
654 let give_up = Instant::now() + Duration::from_secs(1);
655 loop {
656 let len = timer.debug_len();
657 if len < SWEEP_MIN_LEN {
658 break;
659 }
660 assert!(Instant::now() < give_up, "{len} abandoned slots still in the heap after 1 s");
661 tokio::time::sleep(Duration::from_millis(10)).await;
662 }
663 }
664
665 /// The instance API per-socket owners inherit actually stops, rather
666 /// than waiting out its deadlines.
667 ///
668 /// *Ablation:* drop the `stopping` write in `Drop`; the drop had not
669 /// returned after 500 ms and the test hangs.
670 #[test]
671 fn an_instance_timer_joins_its_thread_on_drop() {
672 let timer = ReleaseTimer::new();
673 let held = timer.arm(Instant::now() + Duration::from_secs(10));
674 let t0 = Instant::now();
675 drop(timer);
676 let elapsed = t0.elapsed();
677 drop(held);
678 assert!(
679 elapsed < Duration::from_millis(250),
680 "dropping a wheel with a +10 s deadline took {elapsed:?}"
681 );
682 }
683
684 /// A waker that tears the wheel down when it fires: it owns the only
685 /// [`ReleaseTimer`] handle and drops it from inside `wake`.
686 /// Records where it ran and whether the drop returned, so the test can tell
687 /// "the drop is stuck" from *the waker never fired*.
688 struct DropTheWheelOnWake {
689 wheel: Mutex<Option<ReleaseTimer>>,
690 fired_on: Mutex<Option<String>>,
691 returned: AtomicBool,
692 }
693
694 impl Wake for DropTheWheelOnWake {
695 fn wake(self: Arc<Self>) {
696 self.wake_by_ref();
697 }
698 fn wake_by_ref(self: &Arc<Self>) {
699 *self.fired_on.lock().expect("waker state") =
700 Some(std::thread::current().name().unwrap_or("<unnamed>").to_owned());
701 // The lock guard is a temporary and is released at the end of
702 // this statement, so the wheel is dropped without it held.
703 let wheel = self.wheel.lock().expect("waker state").take();
704 drop(wheel); // the last handle: this runs `ReleaseTimer::drop`
705 self.returned.store(true, AtomicOrdering::SeqCst);
706 }
707 }
708
709 /// Tearing the wheel down from inside a deadline's own completion path
710 /// returns instead of deadlocking.
711 ///
712 /// Step 3 of [`run`] cancels tokens on the release thread, so a waker
713 /// registered on a deadline runs *there*, and a waker that drops the
714 /// last handle to the wheel therefore enters `Drop` on the release
715 /// thread. An unconditional `handle.join()` would then wait for the
716 /// very thread executing it, forever: `stopping` is already set and
717 /// `notify_all` already sent, and neither helps, because the thread is
718 /// inside the cancel loop and cannot reach the top of `run` to read
719 /// the flag.
720 ///
721 /// The waker is an ordinary [`Wake`] implementation registered by
722 /// polling the same `cancelled()` future any awaiting task would, so
723 /// nothing here is a shape only a test produces. Two assertions keep
724 /// it honest: the first poll must return `Pending`, or no waker was
725 /// registered and the drop never ran from a wake at all; and the wake
726 /// must have landed on the release thread, or the self-join was not
727 /// exercised even though the timing passed.
728 ///
729 /// The bound is a liveness limit, not an accuracy bound — a self-join
730 /// never returns at all, so any window catches it and a generous one
731 /// cannot redden because the box was busy. The joining branch is the
732 /// one `an_instance_timer_joins_its_thread_on_drop` covers, and it
733 /// still holds a 250 ms bound.
734 /// *Ablation:* make the join unconditional again. The drop had not returned
735 /// after 20 s against a 500 µs [`SLICE`], so this test spends its whole
736 /// window and fails with *did not return within 10s*.
737 #[test]
738 fn dropping_the_wheel_from_inside_its_own_release_callback_returns() {
739 /// Liveness limit. The fixed drop returns in microseconds; a
740 /// self-joined one never does.
741 const GIVE_UP: Duration = Duration::from_secs(10);
742
743 let timer = ReleaseTimer::new();
744 let deadline = timer.arm(Instant::now() + Duration::from_millis(50));
745 let state = Arc::new(DropTheWheelOnWake {
746 // The waker is now the only owner of the wheel.
747 wheel: Mutex::new(Some(timer)),
748 fired_on: Mutex::new(None),
749 returned: AtomicBool::new(false),
750 });
751
752 let waker = Waker::from(Arc::clone(&state));
753 let mut cx = Context::from_waker(&waker);
754 let mut cancelled = Box::pin(deadline.token().cancelled());
755 assert_eq!(
756 cancelled.as_mut().poll(&mut cx),
757 Poll::Pending,
758 "the deadline resolved on the first poll, so no waker was registered and the \
759 wheel would never be dropped from one",
760 );
761
762 let give_up = Instant::now() + GIVE_UP;
763 while !state.returned.load(AtomicOrdering::SeqCst) {
764 assert!(
765 Instant::now() < give_up,
766 "`ReleaseTimer::drop` did not return within {GIVE_UP:?}: {}",
767 match state.fired_on.lock().expect("waker state").as_deref() {
768 Some(t) => format!("it is joining thread {t:?} from thread {t:?}"),
769 None => "the waker never fired at all".to_owned(),
770 },
771 );
772 std::thread::sleep(Duration::from_millis(1));
773 }
774
775 assert_eq!(
776 state.fired_on.lock().expect("waker state").as_deref(),
777 Some("moqtap-release"),
778 "the wake did not run on the release thread, so this test dropped the wheel from \
779 somewhere the join was always safe and proves nothing",
780 );
781 }
782
783 /// The test that makes this module's reason for existing falsifiable.
784 /// Without it, "a dedicated wheel beats `tokio::time::sleep`" — the
785 /// claim the whole file rests on — is never checked at all.
786 ///
787 /// The sampled quantity is `lateness = observed - deadline`: the
788 /// requested 2 ms is subtracted from *both* arms before the percentile
789 /// is taken. On the lateness reading the measured ratio is 31.8x
790 /// (0.351 ms against 11.170 ms), so `x4` leaves 7.9x of headroom; on
791 /// the elapsed reading it would be 1.4x, which is not an assertion.
792 ///
793 /// The assertion is on a *median* because the tokio arm's p50 is
794 /// bounded below by the ~15.6 ms tick as a property of the timer, not
795 /// of the machine, while the wheel arm's p95 does degrade under load.
796 /// If a runner ever deschedules the release thread past 2.79 ms more
797 /// than half the time, weaken the factor to `x2` and record the runner
798 /// here — the ablation still fails at `x2`.
799 ///
800 /// # Why this one runs by default when the other two do not
801 ///
802 /// The two accuracy measurements at the bottom of this module are
803 /// `#[ignore]`d because a saturated box invalidates them, and *this
804 /// test is the same class of claim* — so it was measured rather than
805 /// assumed either way. Under 16 busy-loop processes on 16 logical
806 /// cores: **0 red in 10 runs**. Under 32, i.e. 2x oversubscription:
807 /// **0 red in 23 runs**. It holds where they do not because it samples
808 /// the wheel alone on a four-worker runtime — one arm, one token, one
809 /// wake — while the session-level pairing it resembles measures the
810 /// whole read-hook-queue-release-write pipeline on a current-thread
811 /// runtime that the same load starves. This test must never be
812 /// `#[ignore]`d: it is the one the multi-threaded-runtime requirement
813 /// rests on. If a runner does break it, the remedy is the `x2`
814 /// weakening above, which the ablation still fails — not deletion, and not an
815 /// `#[ignore]`.
816 ///
817 /// *Ablation:* point the wheel arm at a `tokio::time::sleep` task.
818 #[cfg(windows)]
819 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
820 async fn the_wheel_beats_the_tokio_timer_on_lateness() {
821 let timer = ReleaseTimer::new();
822 let requested = Duration::from_millis(2);
823 let mut wheel: Vec<Duration> = Vec::with_capacity(100);
824 let mut tokio_timer: Vec<Duration> = Vec::with_capacity(100);
825
826 for _ in 0..100 {
827 let at = Instant::now() + requested;
828 let d = timer.arm(at);
829 d.token().cancelled().await;
830 wheel.push(Instant::now().saturating_duration_since(at));
831
832 let at = Instant::now() + requested;
833 tokio::time::sleep(requested).await;
834 tokio_timer.push(Instant::now().saturating_duration_since(at));
835 }
836
837 let (wheel_p50, ..) = quantiles(&mut wheel);
838 let (tokio_p50, ..) = quantiles(&mut tokio_timer);
839 assert!(
840 wheel_p50 * 4 < tokio_p50,
841 "wheel p50 lateness {wheel_p50:?}, tokio p50 lateness {tokio_p50:?}"
842 );
843 }
844
845 /// Pure: no clock read inside the assertions, no thread.
846 ///
847 /// `t0 + 1 ms` is written on the *now* side rather than `t0 - 1 ms` on
848 /// the deadline side because `Instant - Duration` panics near the
849 /// clock's origin.
850 ///
851 /// *Ablation:* change `d.min(slice)` to `slice`; the fourth case
852 /// fails, and it is the case that would otherwise sleep past a
853 /// deadline.
854 #[test]
855 fn the_wait_plan_never_overshoots() {
856 let t0 = Instant::now();
857 assert_eq!(plan(t0, None, SLICE), Wait::Idle);
858 assert_eq!(plan(t0, Some(t0), SLICE), Wait::Fire);
859 assert_eq!(plan(t0 + Duration::from_millis(1), Some(t0), SLICE), Wait::Fire,);
860 // The *nearest deadline is already inside one slice* case, served by
861 // the `min` and not by a branch.
862 assert_eq!(
863 plan(t0, Some(t0 + Duration::from_micros(100)), SLICE),
864 Wait::Sleep(Duration::from_micros(100)),
865 );
866 assert_eq!(plan(t0, Some(t0 + Duration::from_millis(5)), SLICE), Wait::Sleep(SLICE),);
867 }
868
869 /// `Slot`'s `Ord` is hand-written (`Weak` is neither `Ord` nor
870 /// `Eq`) and wrapped in `Reverse`, so one inverted comparison turns
871 /// the wheel into a LIFO that serves the farthest deadline first — a
872 /// defect that would otherwise show up only as a confusing timing
873 /// failure.
874 ///
875 /// *Ablation:* swap the operands in `Ord::cmp`.
876 #[test]
877 fn the_heap_is_a_min_heap_and_ties_break_by_registration() {
878 let t0 = Instant::now();
879 let ms = |n| t0 + Duration::from_millis(n);
880
881 let mut heap: BinaryHeap<Reverse<Slot>> = BinaryHeap::new();
882 for (at, seq) in [(ms(3), 1u64), (ms(1), 2), (ms(2), 3), (ms(1), 4), (ms(1), 5)] {
883 heap.push(Reverse(Slot { at, seq, entry: Weak::new() }));
884 }
885
886 let mut popped: Vec<(Instant, u64)> = Vec::new();
887 while let Some(Reverse(s)) = heap.pop() {
888 popped.push((s.at, s.seq));
889 }
890
891 assert!(
892 popped.windows(2).all(|w| w[0].0 <= w[1].0),
893 "instants did not come out ascending: {popped:?}"
894 );
895 assert_eq!(
896 popped,
897 vec![(ms(1), 2), (ms(1), 4), (ms(1), 5), (ms(2), 3), (ms(3), 1)],
898 "ties did not break by registration order"
899 );
900 }
901
902 /// **Nothing armed is lost.** Every deadline the wheel
903 /// accepts is eventually released, none of them early, and the sweep
904 /// does not eat a live one on its way past.
905 /// This is the completeness half of what the wheel guarantees, and it is
906 /// stated the way it is *because* it has to survive a saturated box —
907 /// unlike the calibration measurements at the bottom of this module. A
908 /// descheduled release thread makes every sample **later**; it cannot make
909 /// one vanish, and it cannot make one early. So the two assertions here are
910 /// "it arrived at all" (with a give-up window 28x the deadline, which is a
911 /// liveness limit and not an accuracy bound) and *it did not arrive before
912 /// its instant*. Neither has a scheduler-dependent number in it.
913 ///
914 /// The shape is chosen so the sweep really runs **while live slots are
915 /// in the heap**, which is the case every other test here misses:
916 /// `a_deadline_never_fires_early` arms one at a time, so the heap never
917 /// reaches [`SWEEP_MIN_LEN`];
918 /// `the_release_thread_parks_when_the_wheel_is_empty`'s hundred slots
919 /// are due 5 ms after construction, long before [`SWEEP_INTERVAL`]
920 /// elapses; and `abandoned_far_deadlines_are_swept`'s ten thousand slots
921 /// are all abandoned, so a sweep that dropped live ones too would look
922 /// identical. Here 128 live and 128 abandoned slots share a heap, the
923 /// deadlines are ~700 ms out so `SWEEP_INTERVAL` passes twice first,
924 /// and the [`Parker::SleepSlice`] thread is awake every [`SLICE`]
925 /// throughout, so the rebuild is guaranteed to execute over a heap
926 /// that is half live.
927 ///
928 /// Arming runs farthest-first, so every later arm is *nearer* than the
929 /// current head and the awaits then run in deadline order.
930 ///
931 /// *Ablation:* in step 2 of [`run`], retain `strong_count() > 1`
932 /// instead of `> 0` — an off-by-one that reads like a tightening,
933 /// since the live `Arc` is held by the [`Deadline`] and not by the
934 /// heap. Measured: this test fails at the first await with all 128
935 /// live deadlines swept out at ~250 ms and never released, while the
936 /// three tests named above all still pass — which is what it is here
937 /// for.
938 #[tokio::test]
939 async fn every_armed_deadline_is_eventually_released() {
940 const LIVE: usize = 128;
941 /// Interleaved with the live ones so a sweep cannot pass over a
942 /// contiguous block of dead slots and miss the live ones.
943 const ABANDONED: usize = 128;
944 /// Far enough out that `SWEEP_INTERVAL` elapses twice before the
945 /// first slot is due.
946 const BASE: Duration = Duration::from_millis(700);
947 /// Spread, so the batch is many wakes rather than one.
948 const STEP: Duration = Duration::from_micros(200);
949 /// A liveness limit, not an accuracy bound: a lost slot never
950 /// arrives at all, so any window catches it and a generous one
951 /// cannot go red because the box was busy.
952 const GIVE_UP: Duration = Duration::from_secs(20);
953
954 let timer = ReleaseTimer::new();
955 let base = Instant::now() + BASE;
956
957 // Farthest first: every subsequent arm is nearer than the head.
958 let mut armed: Vec<(Instant, Deadline)> = Vec::with_capacity(LIVE);
959 for i in 0..LIVE {
960 let at = base + STEP * u32::try_from(LIVE - 1 - i).expect("LIVE fits in u32");
961 armed.push((at, timer.arm(at)));
962 if i < ABANDONED {
963 // Dropped immediately: a slot the wheel must reap, sitting
964 // between two it must not.
965 drop(timer.arm(at + STEP / 2));
966 }
967 }
968 assert!(
969 timer.debug_len() > SWEEP_MIN_LEN,
970 "the heap never reached the length that lets the sweep run, so this test would \
971 pass with or without one: {} slots",
972 timer.debug_len(),
973 );
974
975 // In deadline order, so each await really waits for its own slot.
976 for (i, (at, d)) in armed.iter().rev().enumerate() {
977 tokio::time::timeout(GIVE_UP, d.token().cancelled()).await.unwrap_or_else(|_| {
978 panic!(
979 "deadline {i} of {LIVE} was armed and never released: {GIVE_UP:?} after a \
980 deadline {BASE:?} out it is still pending, with {} slot(s) in the heap",
981 timer.debug_len(),
982 )
983 });
984 let observed = Instant::now();
985 assert!(
986 observed >= *at,
987 "deadline {i} of {LIVE} fired {:?} early",
988 at.saturating_duration_since(observed),
989 );
990 }
991
992 assert_eq!(
993 timer.debug_len(),
994 0,
995 "every slot was popped or swept, so the heap must be empty",
996 );
997 }
998
999 /// Keeps the two process-wide read-backs compiled and exercised.
1000 ///
1001 /// Not a property test like the others here: `started()` and
1002 /// `backend()` are thin readers
1003 /// over `instrument`'s single monotonic atomic, and until `egress.rs`
1004 /// and `session.rs` call them they would otherwise be `dead_code`
1005 /// under `-D warnings`. Asserted in the monotone-safe direction only
1006 /// (`Some` backend implies started), because nothing in this binary
1007 /// constructs the process-wide wheel and a future test that does must
1008 /// not turn this into a race.
1009 #[test]
1010 fn the_process_wide_read_backs_agree() {
1011 if backend().is_some() {
1012 assert!(started(), "a backend was published without `started`");
1013 }
1014 }
1015
1016 // ── calibration, and why it is the one thing here that is opt-in ────
1017 //
1018 // The tests above are *correctness* properties: never early, never
1019 // lost, never reordered, never left parked. Every one of them holds on
1020 // a box whose scheduler is doing something else, because a starved
1021 // release thread can only make a release **later** — and "later" is not
1022 // what any of them assert. (`the_wheel_beats_the_tokio_timer_on_lateness`
1023 // is the one accuracy claim that still runs by default, and its own
1024 // rustdoc gives the loaded measurement that earned it the exception.)
1025 //
1026 // What follows is the other kind of claim: **how late**. That is an
1027 // accuracy measurement, and this is the finding that moved it out of
1028 // `cargo test --workspace`:
1029 //
1030 // > Under real CPU saturation the wheel does not hold its sub-millisecond
1031 // > lateness. It degrades to the same tens of milliseconds
1032 // > `tokio::time::sleep` costs, so *no* formulation of the accuracy
1033 // > claim survives — not an absolute bound, and not a paired
1034 // > wheel-versus-tokio difference either, because both arms degrade
1035 // > together and the separation collapses to zero.
1036 //
1037 // Measured on this Windows 11 box, 16 busy-loop processes on 16 logical
1038 // cores, `release_error_p50_is_within_the_platform_budget` exactly as it
1039 // is written below: **7 red in 10 loaded runs** (p50 5.14 / 5.34 ms
1040 // against its 5 ms bound, p95 14-15 ms, max 63-67 ms), against 0 red in
1041 // 10 idle runs. The paired form was tried and is *worse*: at session
1042 // level the wheel-versus-tokio pairs came out 12.58/12.03, 10.49/11.12,
1043 // 12.58/12.87, 12.58/11.94, 12.58/12.09, 12.58/11.45 and 12.58/13.26 ms
1044 // — the wheel slower than its own control in four of seven — so the
1045 // difference the pairing was supposed to preserve was 0 ns against a
1046 // 3 ms margin.
1047 // So this is not *the bound was too tight*. A p50 that reads 0.1 ms idle
1048 // and 12 ms saturated is not measuring the wheel; it is measuring the box.
1049 // `cargo test --workspace` runs on three shared CI runners with 76 other
1050 // targets beside it, and a gate that reports the runner's load as a code
1051 // defect is a broken gate whichever number is in it.
1052 //
1053 // **This is not concealment, and the two reasons are checkable.**
1054 //
1055 // 1. The capability these tests measure is still gated, by the
1056 // correctness properties above — which is what an implementation
1057 // regression actually trips. The `tokio::time::sleep` implementation
1058 // this module exists to replace is rejected by
1059 // `an_earlier_deadline_registered_later_preempts_the_current_wait`
1060 // (a nearer deadline armed mid-wait is noticed within one slice:
1061 // measured 1975 ms late under the ablation) and by
1062 // `a_deadline_never_fires_early`, on every platform, with no timing
1063 // budget in either.
1064 // 2. The measurement this would gate on — schedule 10 000 releases at
1065 // 1 ms, 5 ms, 200 µs spacing and assert p95 release error under a
1066 // per-platform budget — needs those budgets *derived from the
1067 // runners*, which is a job for a dedicated quiet-machine run and not
1068 // for the unit-test gate.
1069 //
1070 // Run it, on an otherwise idle machine:
1071 //
1072 // ```text
1073 // cargo test -p moqtap-proxy --lib -- --ignored --nocapture calibration
1074 // ```
1075 //
1076 // Its session-level companion is
1077 // `actions_timing::calibration_a_five_millisecond_delay_is_measured_as_five_milliseconds`,
1078 // re-homed the same way and for the same reason. Both still assert, and
1079 // both still fail for a release path that regressed — that is the point
1080 // of leaving the numbers in rather than reducing them to `println!`s.
1081 // They are simply not part of the default test run.
1082
1083 /// **Calibration measurement, not a gate. Requires a quiet machine.**
1084 /// Ignored by default; run with `-- --ignored --nocapture`.
1085 ///
1086 /// The standing proxy for a full calibration run, and the first
1087 /// measurement of the Unix backends. 200 deadlines at 3 ms on one
1088 /// instance; `p50 <= 5 ms` when
1089 /// the backend is high-resolution, `<= 60 ms` otherwise.
1090 ///
1091 /// **Why it is opt-in**: see the banner above. Short version — this
1092 /// median is a property of the machine's scheduler at least as much as
1093 /// of the wheel, it went red 7 times in 10 runs under 16 busy-loop
1094 /// processes, and widening the bound past ~10 ms would admit the very
1095 /// `tokio::time::sleep` implementation it exists to reject. There is no
1096 /// number that is both meaningful and load-proof, so the measurement is
1097 /// taken deliberately instead of continuously.
1098 ///
1099 /// Reference numbers, so a future reader has something to compare
1100 /// against. Quiet Windows 11 box (i9-11900K, 16 logical cores),
1101 /// `SleepSlice`, debug profile, this test alone:
1102 ///
1103 /// | | p50 | p95 | p99 | max |
1104 /// |---|---|---|---|---|
1105 /// | idle, 6 consecutive runs | 0.206-0.408 ms | 0.58-0.68 ms | 0.67-0.81 ms | 0.75-1.22 ms |
1106 /// | idle, worst seen in 10 | 5.23 ms | 21.65 ms | 29.00 ms | 36.48 ms |
1107 /// | 16 busy-loop processes | 5.14-5.34 ms | 14.3-15.2 ms | 16-17 ms | 63-67 ms |
1108 ///
1109 /// The equivalent `--release` numbers are p50 0.116-0.168 ms for the
1110 /// same shape; the idle debug numbers above are the honest unoptimised
1111 /// equivalent. Note the idle *worst* row: even with nothing
1112 /// else running this occasionally breaches 5 ms, which is the second
1113 /// reason it cannot be a gate.
1114 ///
1115 /// *Ablation:* point `arm` at a `tokio::time::sleep` task; the
1116 /// high-resolution branch must fail.
1117 #[tokio::test]
1118 #[ignore = "calibration measurement: asserts timing accuracy, which is not \
1119 measurable on a loaded box — run with --ignored on a quiet machine"]
1120 async fn calibration_release_error_p50_is_within_the_platform_budget() {
1121 let timer = ReleaseTimer::new();
1122 let mut lateness: Vec<Duration> = Vec::with_capacity(200);
1123 for _ in 0..200 {
1124 let at = Instant::now() + Duration::from_millis(3);
1125 let d = timer.arm(at);
1126 d.token().cancelled().await;
1127 lateness.push(Instant::now().saturating_duration_since(at));
1128 }
1129 assert_eq!(lateness.len(), 200, "lost samples");
1130
1131 let bound = if timer.backend().is_high_resolution() {
1132 Duration::from_millis(5)
1133 } else {
1134 Duration::from_millis(60)
1135 };
1136 let (p50, p95, p99, max) = quantiles(&mut lateness);
1137 // Printed as well as asserted: a calibration run is worth reading
1138 // even when it is green, and `--nocapture` is how it is invoked.
1139 println!(
1140 "release lateness, backend {}, 200 deadlines at 3 ms: p50 {p50:?}, p95 {p95:?}, \
1141 p99 {p99:?}, max {max:?} (bound {bound:?})",
1142 timer.backend().as_str(),
1143 );
1144 assert!(
1145 p50 <= bound,
1146 "backend {} p50 {p50:?} exceeds {bound:?} (p95 {p95:?}, p99 {p99:?}, max {max:?})",
1147 timer.backend().as_str()
1148 );
1149 }
1150}