Skip to main content

spate_core/
clock.rs

1//! Injectable time, so a test controls the clock that time-dependent code
2//! reads.
3//!
4//! [`Clock`] reads [`std::time::Instant`] for synchronous code. Code whose
5//! deadlines run on the tokio timer uses [`tokio`].
6
7pub mod tokio;
8
9use std::time::Instant;
10#[cfg(any(test, feature = "testing"))]
11use std::{
12    sync::{
13        Arc,
14        atomic::{AtomicU64, Ordering},
15    },
16    time::Duration,
17};
18
19/// A monotonic time source.
20pub trait Clock {
21    /// Current monotonic instant.
22    fn now(&self) -> Instant;
23}
24
25/// The production clock, over [`Instant::now`].
26#[derive(Clone, Copy, Debug, Default)]
27pub struct SystemClock;
28
29impl Clock for SystemClock {
30    #[inline]
31    fn now(&self) -> Instant {
32        Instant::now()
33    }
34}
35
36/// A clock that only moves when a test moves it.
37#[cfg(any(test, feature = "testing"))]
38#[doc(hidden)]
39#[derive(Debug)]
40pub struct TestClock {
41    base: Instant,
42    offset_nanos: AtomicU64,
43}
44
45#[cfg(any(test, feature = "testing"))]
46impl TestClock {
47    /// A clock frozen at construction.
48    #[must_use]
49    pub fn frozen() -> Arc<TestClock> {
50        Arc::new(TestClock {
51            base: Instant::now(),
52            offset_nanos: AtomicU64::new(0),
53        })
54    }
55
56    /// Move the clock forward.
57    pub fn advance(&self, by: Duration) {
58        let nanos = u64::try_from(by.as_nanos()).expect("test clock advance fits in u64 nanos");
59        self.offset_nanos.fetch_add(nanos, Ordering::Relaxed);
60    }
61
62    /// Advance by `total` in `step`-sized increments, running `between`
63    /// after each one.
64    ///
65    /// `between` is where the test lets the code under test react before
66    /// the next step. Panics if `step` is zero.
67    pub fn advance_stepped(&self, total: Duration, step: Duration, mut between: impl FnMut()) {
68        assert!(!step.is_zero(), "advance_stepped needs a non-zero step");
69        let mut moved = Duration::ZERO;
70        while moved < total {
71            let chunk = step.min(total - moved);
72            self.advance(chunk);
73            moved += chunk;
74            between();
75        }
76    }
77}
78
79#[cfg(any(test, feature = "testing"))]
80impl Clock for TestClock {
81    fn now(&self) -> Instant {
82        self.base + Duration::from_nanos(self.offset_nanos.load(Ordering::Relaxed))
83    }
84}
85
86#[cfg(any(test, feature = "testing"))]
87impl Clock for &TestClock {
88    fn now(&self) -> Instant {
89        (**self).now()
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    /// `advance_stepped` lands exactly on `total`, with a short last step,
98    /// and runs `between` once per step.
99    #[test]
100    fn advance_stepped_reaches_total_in_steps() {
101        let clock = TestClock::frozen();
102        let start = clock.now();
103        let mut seen = Vec::new();
104        clock.advance_stepped(Duration::from_millis(25), Duration::from_millis(10), || {
105            seen.push(clock.now() - start);
106        });
107        assert_eq!(
108            seen,
109            [10, 20, 25].map(Duration::from_millis),
110            "one reading per step, the last one short"
111        );
112    }
113
114    #[test]
115    #[should_panic(expected = "non-zero step")]
116    fn advance_stepped_rejects_a_zero_step() {
117        TestClock::frozen().advance_stepped(Duration::from_secs(1), Duration::ZERO, || {});
118    }
119}