Skip to main content

clock_kit/
mock.rs

1//! Deterministic mock clock — REQ-CLK-005 (G3).
2//!
3//! [`MockClock`] never consults the OS. Time advances only when the test
4//! advances it, so property tests and replay are reproducible
5//! bit-for-bit. Wall and monotonic fields move independently:
6//! [`step_wall`](MockClock::step_wall) simulates NTP/PTP wall steps for
7//! skew tests without touching monotonic time.
8
9use crate::clock::Clock;
10use crate::quality::{ClockSource, SyncQuality};
11use crate::timestamp::{Timestamp, WallTime};
12#[cfg(not(target_has_atomic = "64"))]
13use core::cell::Cell;
14#[cfg(target_has_atomic = "64")]
15use core::sync::atomic::{AtomicI64, AtomicU64, Ordering};
16use core::time::Duration;
17#[cfg(not(target_has_atomic = "64"))]
18use critical_section::Mutex;
19
20/// Monotonic cell: saturating-add only — a mock cannot regress by
21/// construction. Atomic on 64-bit-atomic targets, critical-section cell
22/// otherwise (chronoshift pattern; impl provided by the final binary).
23#[derive(Debug)]
24struct MonoCell {
25    #[cfg(target_has_atomic = "64")]
26    ns: AtomicU64,
27    #[cfg(not(target_has_atomic = "64"))]
28    ns: Mutex<Cell<u64>>,
29}
30
31impl MonoCell {
32    const fn new(start_ns: u64) -> Self {
33        Self {
34            #[cfg(target_has_atomic = "64")]
35            ns: AtomicU64::new(start_ns),
36            #[cfg(not(target_has_atomic = "64"))]
37            ns: Mutex::new(Cell::new(start_ns)),
38        }
39    }
40
41    fn saturating_add(&self, dt: u64) {
42        #[cfg(target_has_atomic = "64")]
43        {
44            // CAS loop keeps the advance saturating instead of wrapping.
45            let mut cur = self.ns.load(Ordering::Relaxed);
46            loop {
47                let next = cur.saturating_add(dt);
48                match self
49                    .ns
50                    .compare_exchange_weak(cur, next, Ordering::AcqRel, Ordering::Relaxed)
51                {
52                    Ok(_) => return,
53                    Err(seen) => cur = seen,
54                }
55            }
56        }
57        #[cfg(not(target_has_atomic = "64"))]
58        {
59            critical_section::with(|cs| {
60                let c = self.ns.borrow(cs);
61                c.set(c.get().saturating_add(dt));
62            });
63        }
64    }
65
66    fn load(&self) -> u64 {
67        #[cfg(target_has_atomic = "64")]
68        {
69            self.ns.load(Ordering::Acquire)
70        }
71        #[cfg(not(target_has_atomic = "64"))]
72        {
73            critical_section::with(|cs| self.ns.borrow(cs).get())
74        }
75    }
76}
77
78/// Wall cell: signed, steps freely backward/forward (that is the point of
79/// [`MockClock::step_wall`]). Saturates at `i64` bounds.
80#[derive(Debug)]
81struct WallCell {
82    #[cfg(target_has_atomic = "64")]
83    ns: AtomicI64,
84    #[cfg(not(target_has_atomic = "64"))]
85    ns: Mutex<Cell<i64>>,
86}
87
88impl WallCell {
89    const fn new(start_ns: i64) -> Self {
90        Self {
91            #[cfg(target_has_atomic = "64")]
92            ns: AtomicI64::new(start_ns),
93            #[cfg(not(target_has_atomic = "64"))]
94            ns: Mutex::new(Cell::new(start_ns)),
95        }
96    }
97
98    fn saturating_add(&self, dt: i64) {
99        #[cfg(target_has_atomic = "64")]
100        {
101            let mut cur = self.ns.load(Ordering::Relaxed);
102            loop {
103                let next = cur.saturating_add(dt);
104                match self
105                    .ns
106                    .compare_exchange_weak(cur, next, Ordering::AcqRel, Ordering::Relaxed)
107                {
108                    Ok(_) => return,
109                    Err(seen) => cur = seen,
110                }
111            }
112        }
113        #[cfg(not(target_has_atomic = "64"))]
114        {
115            critical_section::with(|cs| {
116                let c = self.ns.borrow(cs);
117                c.set(c.get().saturating_add(dt));
118            });
119        }
120    }
121
122    fn load(&self) -> i64 {
123        #[cfg(target_has_atomic = "64")]
124        {
125            self.ns.load(Ordering::Acquire)
126        }
127        #[cfg(not(target_has_atomic = "64"))]
128        {
129            critical_section::with(|cs| self.ns.borrow(cs).get())
130        }
131    }
132}
133
134/// Deterministic test clock (REQ-CLK-005).
135///
136/// - Advances **only** by explicit calls — the OS is never consulted (G3).
137/// - `mono` is additive-only and can never go backward.
138/// - `wall` is a separate signed field; [`step_wall`](MockClock::step_wall)
139///   moves it without touching `mono`, simulating NTP slews and PTP steps.
140/// - Shared-handle use is safe: cells are atomic (or critical-section)
141///   backed, and single-threaded use — the spec's determinism contract — is
142///   exactly reproducible.
143///
144/// The process-wide monotonic floor is deliberately **not** applied here
145/// (see `crate::monotonic`): ambient real-clock reads elsewhere in a test
146/// binary must not perturb a fiction.
147///
148/// # Example
149///
150/// ```
151/// use clock_kit::{Clock, ClockSource, MockClock};
152/// use std::time::Duration;
153///
154/// let clock = MockClock::start_at(10, 1_000_000);
155/// assert_eq!(clock.now().mono, 10);
156/// clock.advance(Duration::from_nanos(90));
157/// assert_eq!(clock.now().mono, 100);
158/// clock.step_wall(-500_000); // wall steps, mono does not
159/// assert_eq!(clock.now().mono, 100);
160/// assert_eq!(clock.source(), ClockSource::Mock);
161/// ```
162#[derive(Debug)]
163pub struct MockClock {
164    mono: MonoCell,
165    wall: WallCell,
166}
167
168impl MockClock {
169    /// A mock starting at `mono_ns` / `wall_ns` (both nanos; wall is
170    /// signed-epoch, mono is process-local scale).
171    #[must_use]
172    pub fn start_at(mono_ns: u64, wall_ns: u64) -> Self {
173        Self {
174            mono: MonoCell::new(mono_ns),
175            wall: WallCell::new(clamp_i64(wall_ns)),
176        }
177    }
178
179    /// A mock starting everything at zero.
180    #[must_use]
181    pub fn new() -> Self {
182        Self::start_at(0, 0)
183    }
184
185    /// Advance mono (and mono only) by `dt`. Deterministic.
186    pub fn advance(&self, dt: Duration) {
187        let ns = u64::try_from(dt.as_nanos()).unwrap_or(u64::MAX);
188        self.mono.saturating_add(ns);
189    }
190
191    /// Advance mono by `ns` nanoseconds. Deterministic.
192    pub fn advance_nanos(&self, ns: u64) {
193        self.mono.saturating_add(ns);
194    }
195
196    /// Step the wall field by `offset_ns` (negative = backward, e.g. an
197    /// NTP correction) without touching mono. REQ-CLK-005 skew tests.
198    pub fn step_wall(&self, offset_ns: i64) {
199        self.wall.saturating_add(offset_ns);
200    }
201
202    /// Current wall reading, epoch nanos.
203    #[must_use]
204    pub fn wall_nanos(&self) -> u64 {
205        let ns = self.wall.load();
206        u64::try_from(ns).unwrap_or(0)
207    }
208
209    /// Current monotonic reading, nanos.
210    #[must_use]
211    pub fn mono_nanos(&self) -> u64 {
212        self.mono.load()
213    }
214}
215
216impl Default for MockClock {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222impl Clock for MockClock {
223    fn now(&self) -> Timestamp {
224        Timestamp {
225            wall: WallTime::from_unix_nanos(self.wall_nanos()),
226            mono: self.mono_nanos(),
227        }
228    }
229
230    fn quality(&self) -> SyncQuality {
231        // A fiction is not a synchronized source; fail-closed (G2).
232        SyncQuality::Fallback
233    }
234
235    fn source(&self) -> ClockSource {
236        ClockSource::Mock
237    }
238}
239
240/// Wall starts are `u64` per the API sketch; values above `i64::MAX`
241/// (year 2942 in wall nanos) saturate instead of wrapping.
242// The guarded cast below is wrap-safe by the branch.
243#[allow(clippy::cast_possible_wrap)]
244const fn clamp_i64(wall_ns: u64) -> i64 {
245    if wall_ns > i64::MAX as u64 {
246        i64::MAX
247    } else {
248        wall_ns as i64
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{MockClock, WallTime};
255    use crate::clock::Clock;
256    use crate::quality::{ClockSource, SyncQuality};
257    use core::time::Duration;
258
259    /// Deterministic fiction: identical operation sequences produce
260    /// identical timestamps (G3). Pure logic — runs under miri.
261    #[test]
262    fn mock_sequence_is_reproducible() {
263        let run = || {
264            let c = MockClock::start_at(1_000, 5_000);
265            c.advance(Duration::from_millis(2));
266            c.advance_nanos(500);
267            c.step_wall(-1_234);
268            c.now()
269        };
270        let a = run();
271        let b = run();
272        assert_eq!(a, b);
273        assert_eq!(a.mono, 2_000_500 + 1_000);
274        assert_eq!(a.wall, WallTime::from_unix_nanos(5_000 - 1_234));
275    }
276
277    /// `step_wall` moves wall without touching mono (NTP/PTP skew tests).
278    #[test]
279    fn mock_step_wall_independent_of_mono() {
280        let c = MockClock::start_at(0, 1_000_000_000);
281        c.advance_nanos(10);
282        c.step_wall(-2_000_000_000); // big backward step
283        let t = c.now();
284        assert_eq!(t.mono, 10);
285        assert_eq!(t.wall.unix_nanos(), 0); // clamped, never negative
286        assert_ne!(t.wall.unix_nanos(), t.mono);
287    }
288
289    /// Saturating bounds: a mock must not wrap or panic at extremes.
290    #[test]
291    fn mock_saturates_at_bounds() {
292        let c = MockClock::start_at(u64::MAX - 5, 0);
293        c.advance_nanos(100);
294        assert_eq!(c.mono_nanos(), u64::MAX);
295        c.step_wall(i64::MIN);
296        c.step_wall(i64::MIN);
297        assert_eq!(c.wall_nanos(), 0);
298    }
299
300    /// Clock trait surface: mock reports Mock/Fallback.
301    #[test]
302    fn mock_reports_source_and_quality() {
303        let c = MockClock::new();
304        assert_eq!(c.source(), ClockSource::Mock);
305        assert_eq!(c.quality(), SyncQuality::Fallback);
306        assert_eq!(c.now().mono, 0);
307    }
308}