Skip to main content

moduvex_runtime/time/
interval.rs

1//! `Interval` — periodic timer that fires at a fixed rate.
2//!
3//! Each call to `tick()` returns a future that resolves at the next scheduled
4//! deadline. Missed ticks are tracked: if the executor falls behind, the next
5//! `tick()` returns immediately and reduces the missed-tick counter.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::task::{Context, Poll};
10use std::time::{Duration, Instant};
11
12use super::{with_timer_wheel, TimerId};
13
14/// Periodic timer created by [`interval`].
15pub struct Interval {
16    /// Fixed tick period.
17    period: Duration,
18    /// Deadline of the next scheduled tick.
19    next_deadline: Instant,
20    /// Number of ticks that have been missed (deadline passed without poll).
21    missed: u64,
22}
23
24impl Interval {
25    pub(crate) fn new(period: Duration) -> Self {
26        assert!(!period.is_zero(), "interval period must be non-zero");
27        Self {
28            period,
29            next_deadline: Instant::now() + period,
30            missed: 0,
31        }
32    }
33
34    /// Returns a future that resolves at the next tick deadline.
35    ///
36    /// If ticks were missed the future resolves immediately and returns the
37    /// deadline of the *missed* tick that is now being reported.
38    pub fn tick(&mut self) -> TickFuture<'_> {
39        TickFuture {
40            interval: self,
41            timer_id: None,
42        }
43    }
44}
45
46/// Future returned by [`Interval::tick`].
47pub struct TickFuture<'a> {
48    interval: &'a mut Interval,
49    timer_id: Option<TimerId>,
50}
51
52impl<'a> Future for TickFuture<'a> {
53    type Output = Instant;
54
55    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
56        let now = Instant::now();
57
58        // Check whether the next deadline has already passed (missed tick).
59        if now >= self.interval.next_deadline {
60            // Cancel any pending registration.
61            if let Some(id) = self.timer_id.take() {
62                with_timer_wheel(|w| {
63                    w.cancel(id);
64                });
65            }
66
67            let fired_at = self.interval.next_deadline;
68
69            // Advance past all missed ticks.
70            let elapsed = now.duration_since(fired_at);
71            let extra_ticks = (elapsed.as_nanos() / self.interval.period.as_nanos()) as u64;
72            self.interval.missed += extra_ticks;
73            self.interval.next_deadline =
74                fired_at + self.interval.period * (extra_ticks as u32 + 1);
75
76            return Poll::Ready(fired_at);
77        }
78
79        // Register (or refresh) the waker with the timer wheel.
80        if let Some(old_id) = self.timer_id.take() {
81            with_timer_wheel(|w| {
82                w.cancel(old_id);
83            });
84        }
85        let deadline = self.interval.next_deadline;
86        let id = with_timer_wheel(|w| w.insert(deadline, cx.waker().clone()));
87        self.timer_id = Some(id);
88
89        Poll::Pending
90    }
91}
92
93impl<'a> Drop for TickFuture<'a> {
94    fn drop(&mut self) {
95        if let Some(id) = self.timer_id.take() {
96            with_timer_wheel(|w| {
97                w.cancel(id);
98            });
99        }
100    }
101}
102
103/// Create a new `Interval` that fires every `period`.
104///
105/// The first tick fires after one full `period` from the call site.
106///
107/// # Panics
108/// Panics if `period` is zero.
109///
110/// # Example
111/// ```no_run
112/// use moduvex_runtime::time::interval;
113/// use std::time::Duration;
114///
115/// moduvex_runtime::block_on(async {
116///     let mut ticker = interval(Duration::from_millis(50));
117///     for _ in 0..3 {
118///         ticker.tick().await;
119///         println!("tick");
120///     }
121/// });
122/// ```
123pub fn interval(period: Duration) -> Interval {
124    Interval::new(period)
125}
126
127// ── Tests ─────────────────────────────────────────────────────────────────────
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::executor::block_on_with_spawn;
133
134    #[test]
135    fn interval_fires_multiple_times() {
136        block_on_with_spawn(async {
137            let mut ticker = interval(Duration::from_millis(50));
138            let before = Instant::now();
139
140            ticker.tick().await;
141            ticker.tick().await;
142            ticker.tick().await;
143
144            let elapsed = before.elapsed();
145            // 3 ticks × 50 ms = 150 ms minimum; allow generous upper bound.
146            assert!(
147                elapsed >= Duration::from_millis(120),
148                "interval fired too fast: {:?}",
149                elapsed
150            );
151            assert!(
152                elapsed < Duration::from_millis(1000),
153                "interval took too long: {:?}",
154                elapsed
155            );
156        });
157    }
158
159    #[test]
160    #[should_panic(expected = "non-zero")]
161    fn interval_zero_period_panics() {
162        let _ = interval(Duration::ZERO);
163    }
164
165    #[test]
166    fn interval_tracks_missed_ticks() {
167        // Create an interval then sleep past two periods before polling.
168        // The `missed` counter should reflect skipped ticks.
169        let period = Duration::from_millis(20);
170        let mut ticker = interval(period);
171
172        // Busy-wait past two periods without polling.
173        let wait_until = Instant::now() + period * 3;
174        while Instant::now() < wait_until {
175            std::hint::spin_loop();
176        }
177
178        // First tick() should return immediately (missed).
179        block_on_with_spawn(async move {
180            let now = Instant::now();
181            ticker.tick().await;
182            let elapsed = now.elapsed();
183            // Should fire immediately — no blocking.
184            assert!(
185                elapsed < Duration::from_millis(50),
186                "missed tick must resolve immediately, took {:?}",
187                elapsed
188            );
189        });
190    }
191}