Skip to main content

dev_chaos/
clock.rs

1//! Deterministic time-skew injection for testing time-sensitive code.
2//!
3//! Code that retries on timeout, expires sessions, schedules futures,
4//! or compares timestamps depends on a clock. [`Clock`] is a source
5//! of `Instant`-ish values that the caller controls explicitly: you
6//! advance it with [`Clock::advance`] or skew it with
7//! [`Clock::skew_by`] to validate that retry loops, expiry checks,
8//! and TTL logic behave correctly without `std::thread::sleep`.
9//!
10//! ## Determinism
11//!
12//! `Clock` is fully deterministic: the same sequence of `advance`
13//! and `now` calls produces the same sequence of values across runs
14//! and machines. No system calls, no thread sleeps.
15//!
16//! ## Pairing with `std::time::Instant`
17//!
18//! `Instant` is opaque and cannot be constructed directly, so this
19//! module uses an offset-from-anchor model. `Clock::now` returns a
20//! [`ClockTime`] (just a `Duration` from anchor); the caller adapts
21//! this to their domain. For callers that need an `Instant`, see
22//! [`Clock::anchor`] and add the offset.
23
24use std::sync::atomic::{AtomicI64, Ordering};
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28/// A virtual time value measured as a `Duration` since the clock's anchor.
29///
30/// Internally just a wrapper around `Duration`; cheap to copy.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct ClockTime(pub Duration);
33
34impl ClockTime {
35    /// The duration since the clock's anchor.
36    pub fn since_anchor(&self) -> Duration {
37        self.0
38    }
39}
40
41/// Deterministic, in-process clock for chaos testing.
42///
43/// `Clock` does **not** advance automatically. Callers explicitly
44/// move the clock forward via [`advance`](Clock::advance) and skew
45/// it via [`skew_by`](Clock::skew_by). This makes time-sensitive
46/// tests fully reproducible.
47///
48/// `Clock` is `Clone`-able and shares state with its clones via an
49/// internal `Arc<AtomicI64>` of nanoseconds-since-anchor; advancing
50/// or skewing one handle advances all. Safe to share across threads.
51///
52/// # Example
53///
54/// ```
55/// use dev_chaos::clock::Clock;
56/// use std::time::Duration;
57///
58/// let c = Clock::new();
59/// let t0 = c.now();
60/// c.advance(Duration::from_secs(5));
61/// let t1 = c.now();
62/// assert_eq!(t1.since_anchor() - t0.since_anchor(), Duration::from_secs(5));
63///
64/// // Skew negative to simulate clock going backward (e.g. NTP step).
65/// c.skew_by(-(Duration::from_secs(2).as_nanos() as i64));
66/// let t2 = c.now();
67/// assert!(t2 < t1);
68/// ```
69#[derive(Debug, Clone)]
70pub struct Clock {
71    /// Real wall-clock anchor when this clock was created. Used by
72    /// `anchor()` for callers that need to map back to `Instant`.
73    anchor: Instant,
74    /// Offset from the anchor in nanoseconds. Wrapped in `Arc` so
75    /// clones share state.
76    offset_ns: Arc<AtomicI64>,
77}
78
79impl Clock {
80    /// Build a new clock anchored at the current `Instant`.
81    pub fn new() -> Self {
82        Self {
83            anchor: Instant::now(),
84            offset_ns: Arc::new(AtomicI64::new(0)),
85        }
86    }
87
88    /// The real `Instant` this clock was anchored at.
89    ///
90    /// Callers that need to interoperate with code expecting `Instant`
91    /// can compute `clock.anchor() + clock.now().since_anchor()`.
92    pub fn anchor(&self) -> Instant {
93        self.anchor
94    }
95
96    /// Current virtual time, as offset from the anchor.
97    pub fn now(&self) -> ClockTime {
98        let ns = self.offset_ns.load(Ordering::Relaxed);
99        if ns >= 0 {
100            ClockTime(Duration::from_nanos(ns as u64))
101        } else {
102            // Negative offset: clamp to zero. Tests that observe a
103            // negative offset should use `now_signed` for the raw value.
104            ClockTime(Duration::ZERO)
105        }
106    }
107
108    /// Current virtual offset, signed (in nanoseconds from anchor).
109    ///
110    /// Useful when validating skew-backward scenarios where
111    /// `since_anchor()` would clamp.
112    pub fn now_signed_ns(&self) -> i64 {
113        self.offset_ns.load(Ordering::Relaxed)
114    }
115
116    /// Advance the clock by a non-negative `delta`.
117    ///
118    /// Equivalent to `skew_by(delta.as_nanos() as i64)` but rejects
119    /// negative durations at the type level.
120    pub fn advance(&self, delta: Duration) {
121        self.offset_ns
122            .fetch_add(delta.as_nanos() as i64, Ordering::Relaxed);
123    }
124
125    /// Skew the clock by `delta_ns`, which may be negative.
126    ///
127    /// Negative skew simulates clock-step events (e.g. NTP correction,
128    /// VM pause/resume backward jump). Tests can validate that retry
129    /// loops with timeout-based escape don't get stuck on negative
130    /// elapsed values.
131    pub fn skew_by(&self, delta_ns: i64) {
132        self.offset_ns.fetch_add(delta_ns, Ordering::Relaxed);
133    }
134
135    /// Reset the clock to anchor (offset zero).
136    pub fn reset(&self) {
137        self.offset_ns.store(0, Ordering::Relaxed);
138    }
139}
140
141impl Default for Clock {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn new_clock_starts_at_zero_offset() {
153        let c = Clock::new();
154        assert_eq!(c.now().since_anchor(), Duration::ZERO);
155        assert_eq!(c.now_signed_ns(), 0);
156    }
157
158    #[test]
159    fn advance_moves_forward() {
160        let c = Clock::new();
161        c.advance(Duration::from_millis(100));
162        c.advance(Duration::from_millis(50));
163        assert_eq!(c.now().since_anchor(), Duration::from_millis(150));
164    }
165
166    #[test]
167    fn skew_negative_clamps_now_to_zero() {
168        let c = Clock::new();
169        c.advance(Duration::from_millis(100));
170        c.skew_by(-(Duration::from_millis(200).as_nanos() as i64));
171        // After negative skew past zero, `now` clamps to zero.
172        assert_eq!(c.now().since_anchor(), Duration::ZERO);
173        // But the raw signed offset reflects the underlying value.
174        assert!(c.now_signed_ns() < 0);
175    }
176
177    #[test]
178    fn skew_negative_within_positive_offset_works() {
179        let c = Clock::new();
180        c.advance(Duration::from_millis(500));
181        c.skew_by(-(Duration::from_millis(100).as_nanos() as i64));
182        assert_eq!(c.now().since_anchor(), Duration::from_millis(400));
183    }
184
185    #[test]
186    fn cloned_clocks_share_state() {
187        let c = Clock::new();
188        let d = c.clone();
189        c.advance(Duration::from_secs(1));
190        assert_eq!(d.now().since_anchor(), Duration::from_secs(1));
191    }
192
193    #[test]
194    fn reset_returns_to_zero() {
195        let c = Clock::new();
196        c.advance(Duration::from_secs(10));
197        c.reset();
198        assert_eq!(c.now().since_anchor(), Duration::ZERO);
199    }
200
201    #[test]
202    fn anchor_is_preserved_across_clones() {
203        let c = Clock::new();
204        let d = c.clone();
205        assert_eq!(c.anchor(), d.anchor());
206    }
207
208    #[test]
209    fn deterministic_sequence_across_runs() {
210        // Same operations should produce same observable values, no
211        // matter how often we run.
212        let c = Clock::new();
213        c.advance(Duration::from_millis(100));
214        c.advance(Duration::from_millis(50));
215        c.skew_by(-(Duration::from_millis(20).as_nanos() as i64));
216        let observed = c.now().since_anchor();
217        // Run again with a fresh clock.
218        let c2 = Clock::new();
219        c2.advance(Duration::from_millis(100));
220        c2.advance(Duration::from_millis(50));
221        c2.skew_by(-(Duration::from_millis(20).as_nanos() as i64));
222        let observed2 = c2.now().since_anchor();
223        assert_eq!(observed, observed2);
224    }
225}