Skip to main content

harn_vm/triggers/test_util/
clock.rs

1//! Single source of truth for clock mocking across the VM, built on the
2//! unified [`harn_clock::Clock`] trait.
3//!
4//! This module owns the thread-local "mock clock" stack consulted by
5//! every time-sensitive subsystem: stdlib `now_ms` / `monotonic_ms` /
6//! `sleep` builtins, the trigger dispatcher, the cron scheduler, and any
7//! Rust-side test that needs to pin or advance time. All of them route
8//! through [`active_mock_clock`] so that pushing one mock pins time
9//! everywhere it would otherwise be read.
10//!
11//! The lives-in-`triggers/` path is historical — the abstraction is now
12//! crate-wide and re-exported as `harn_vm::clock_mock` for callers
13//! outside the trigger subsystem. New runtime code that needs an
14//! injectable clock should accept `Arc<dyn harn_clock::Clock>` directly.
15
16use std::cell::RefCell;
17use std::sync::{Arc, OnceLock};
18use std::time::{Duration as StdDuration, Instant};
19
20use async_trait::async_trait;
21use harn_clock::Clock;
22use time::OffsetDateTime;
23
24thread_local! {
25    static MOCK_CLOCK_STACK: RefCell<Vec<Arc<MockClock>>> = const { RefCell::new(Vec::new()) };
26}
27
28fn process_start() -> &'static Instant {
29    static PROCESS_START: OnceLock<Instant> = OnceLock::new();
30    PROCESS_START.get_or_init(Instant::now)
31}
32
33/// Monotonic instant snapshot with millisecond resolution. Compares cheaply,
34/// serialises by value, and abstracts whether it came from a paused clock or
35/// the real OS monotonic source.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
37pub struct ClockInstant(StdDuration);
38
39impl ClockInstant {
40    pub fn duration_since(self, earlier: Self) -> StdDuration {
41        self.0.saturating_sub(earlier.0)
42    }
43
44    pub fn as_millis(self) -> u128 {
45        self.0.as_millis()
46    }
47}
48
49pub struct ClockOverrideGuard;
50
51impl Drop for ClockOverrideGuard {
52    fn drop(&mut self) {
53        // `try_with` handles the case where TLS is already being torn down
54        // at process exit, which causes a panic if accessed via `with`.
55        let _ = MOCK_CLOCK_STACK.try_with(|slot| {
56            slot.borrow_mut().pop();
57        });
58    }
59}
60
61/// Test-facing wrapper around [`harn_clock::PausedClock`].
62///
63/// Provides the sync ergonomics (`set_sync`, `advance_std_sync`) that
64/// stdlib builtins call without a runtime, plus async aliases (`set`,
65/// `advance`, `advance_std`, `advance_ticks`) that the trigger/cron tests
66/// were written against. Implements [`harn_clock::Clock`] so it can also
67/// be handed directly to consumers that take `Arc<dyn Clock>`.
68#[derive(Debug)]
69pub struct MockClock {
70    inner: Arc<harn_clock::PausedClock>,
71}
72
73impl MockClock {
74    pub fn new(now: OffsetDateTime) -> Arc<Self> {
75        Arc::new(Self {
76            inner: harn_clock::PausedClock::new(now),
77        })
78    }
79
80    /// Construct a clock pinned to `wall_ms` (UNIX epoch milliseconds).
81    pub fn at_wall_ms(wall_ms: i64) -> Arc<Self> {
82        let nanos = (wall_ms as i128).saturating_mul(1_000_000);
83        let now =
84            OffsetDateTime::from_unix_timestamp_nanos(nanos).unwrap_or(OffsetDateTime::UNIX_EPOCH);
85        Self::new(now)
86    }
87
88    pub fn monotonic_now(&self) -> ClockInstant {
89        ClockInstant(StdDuration::from_millis(
90            self.inner.monotonic_ms().max(0) as u64
91        ))
92    }
93
94    /// Wall clock in millis since `UNIX_EPOCH`.
95    pub fn now_wall_ms(&self) -> i64 {
96        self.now_utc().unix_timestamp_nanos() as i64 / 1_000_000
97    }
98
99    /// Monotonic millis since this clock was created.
100    pub fn now_monotonic_ms(&self) -> i64 {
101        self.inner.monotonic_ms()
102    }
103
104    /// Pin the wall clock to `now` (sync). Sleepers waiting for a deadline
105    /// that has now passed are released.
106    pub fn set_sync(&self, now: OffsetDateTime) {
107        self.inner.set(now);
108    }
109
110    /// Advance the clock by `duration` (sync).
111    pub fn advance_std_sync(&self, duration: StdDuration) {
112        self.inner.advance(duration);
113    }
114
115    /// Async alias for [`set_sync`]. Kept so existing trigger/cron tests
116    /// (which were written under the old async API) continue to compile.
117    pub async fn set(&self, now: OffsetDateTime) {
118        self.inner.set(now);
119    }
120
121    /// Advance by a `time::Duration`. Negative values are clamped to zero.
122    pub async fn advance(&self, duration: time::Duration) {
123        self.inner.advance_time(duration);
124    }
125
126    /// Advance by a `std::time::Duration`.
127    pub async fn advance_std(&self, duration: StdDuration) {
128        self.inner.advance(duration);
129    }
130
131    /// Step `ticks` times by `tick`, notifying sleepers between every step.
132    pub async fn advance_ticks(&self, ticks: u32, tick: StdDuration) {
133        self.inner.advance_ticks(ticks, tick);
134    }
135}
136
137#[async_trait]
138impl Clock for MockClock {
139    fn now_utc(&self) -> OffsetDateTime {
140        self.inner.now_utc()
141    }
142
143    fn monotonic_ms(&self) -> i64 {
144        self.inner.monotonic_ms()
145    }
146
147    async fn sleep(&self, duration: StdDuration) {
148        self.inner.sleep(duration).await;
149    }
150
151    async fn sleep_until_utc(&self, deadline: OffsetDateTime) {
152        self.inner.sleep_until_utc(deadline).await;
153    }
154}
155
156pub fn install_override(clock: Arc<MockClock>) -> ClockOverrideGuard {
157    MOCK_CLOCK_STACK.with(|slot| {
158        slot.borrow_mut().push(clock);
159    });
160    ClockOverrideGuard
161}
162
163pub fn active_mock_clock() -> Option<Arc<MockClock>> {
164    MOCK_CLOCK_STACK.with(|slot| slot.borrow().last().cloned())
165}
166
167pub fn is_mocked() -> bool {
168    MOCK_CLOCK_STACK.with(|slot| !slot.borrow().is_empty())
169}
170
171/// Clear the entire override stack. Called from the per-test reset hook
172/// so stray overrides (e.g. from a Harn pipeline that called
173/// `mock_time` without `unmock_time`) cannot leak between tests.
174pub fn clear_overrides() {
175    MOCK_CLOCK_STACK.with(|slot| {
176        slot.borrow_mut().clear();
177    });
178}
179
180pub fn now_utc() -> OffsetDateTime {
181    active_mock_clock()
182        .map(|clock| clock.now_utc())
183        .unwrap_or_else(OffsetDateTime::now_utc)
184}
185
186pub fn now_ms() -> i64 {
187    now_utc().unix_timestamp_nanos() as i64 / 1_000_000
188}
189
190pub fn instant_now() -> ClockInstant {
191    active_mock_clock()
192        .map(|clock| clock.monotonic_now())
193        .unwrap_or_else(|| ClockInstant(process_start().elapsed()))
194}
195
196/// Advance the topmost mock clock by `duration`. No-op if no clock is
197/// installed.
198pub fn advance(duration: StdDuration) {
199    if let Some(clock) = active_mock_clock() {
200        clock.advance_std_sync(duration);
201    }
202}
203
204/// Sleep for `duration`, honoring the unified mock clock.
205///
206/// When a `MockClock` override is installed the sleep advances the mock
207/// instantly and returns — the same semantics scripts get from
208/// `mock_time(...)` + `sleep(...)`. When no mock is installed the call
209/// falls through to `tokio::time::sleep`, which is itself virtualized
210/// inside a `#[tokio::test(start_paused = true)]` runtime.
211///
212/// Production runtime code that needs to back off (rate limiters,
213/// retry loops, the trigger dispatcher's flow-control window) should
214/// route through this helper so a single Rust test can pin time across
215/// both Harn and Rust layers.
216pub async fn sleep(duration: StdDuration) {
217    if duration.is_zero() {
218        return;
219    }
220    if let Some(mock) = active_mock_clock() {
221        mock.sleep(duration).await;
222        return;
223    }
224    tokio::time::sleep(duration).await;
225}