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