Skip to main content

sloop/
clock.rs

1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8
9pub trait Clock: Send + Sync {
10    fn now_ms(&self) -> i64;
11    fn local_minute(&self, timestamp_ms: i64) -> u16;
12    fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
13}
14
15#[derive(Debug, Default)]
16pub struct SystemClock;
17
18impl Clock for SystemClock {
19    fn now_ms(&self) -> i64 {
20        SystemTime::now()
21            .duration_since(UNIX_EPOCH)
22            .map(|elapsed| elapsed.as_millis() as i64)
23            .unwrap_or(0)
24    }
25
26    fn local_minute(&self, timestamp_ms: i64) -> u16 {
27        local_minute(timestamp_ms)
28    }
29
30    fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
31        Box::pin(async move {
32            loop {
33                let delay_ms = deadline_ms.saturating_sub(self.now_ms());
34                if delay_ms <= 0 {
35                    return;
36                }
37                // Recheck wall time so suspend and clock corrections cannot
38                // leave a long monotonic sleep armed past the local opening.
39                tokio::time::sleep(Duration::from_millis(delay_ms.min(30_000) as u64)).await;
40            }
41        })
42    }
43}
44
45/// File-backed clock used by integration tests that run the daemon as a child
46/// process. Advancing the integer timestamp in the file wakes pending timers.
47#[derive(Debug)]
48pub struct FileClock {
49    path: PathBuf,
50}
51
52impl FileClock {
53    pub fn new(path: PathBuf) -> Self {
54        Self { path }
55    }
56
57    fn read_now_ms(&self) -> i64 {
58        std::fs::read_to_string(&self.path)
59            .ok()
60            .and_then(|value| value.trim().parse().ok())
61            .unwrap_or(0)
62    }
63}
64
65impl Clock for FileClock {
66    fn now_ms(&self) -> i64 {
67        self.read_now_ms()
68    }
69
70    fn local_minute(&self, timestamp_ms: i64) -> u16 {
71        local_minute(timestamp_ms)
72    }
73
74    fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
75        Box::pin(async move {
76            while self.now_ms() < deadline_ms {
77                tokio::time::sleep(Duration::from_millis(10)).await;
78            }
79        })
80    }
81}
82
83pub fn format_timestamp(timestamp_ms: i64) -> Option<String> {
84    OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp_ms) * 1_000_000)
85        .ok()?
86        .format(&Rfc3339)
87        .ok()
88}
89
90/// Finds the next real instant whose local wall clock matches `minute`.
91/// Starting at the next whole minute makes the current minute mean its next
92/// occurrence, while scanning real instants handles DST transitions.
93pub fn next_local_minute_ms(clock: &dyn Clock, now_ms: i64, minute: u16) -> Option<i64> {
94    let mut candidate = now_ms
95        .div_euclid(60_000)
96        .checked_add(1)?
97        .checked_mul(60_000)?;
98    for _ in 0..=(49 * 60) {
99        if clock.local_minute(candidate) == minute {
100            return Some(candidate);
101        }
102        candidate = candidate.checked_add(60_000)?;
103    }
104    None
105}
106
107/// A timestamp's local wall-clock fields. History views render times the way
108/// an operator reads a clock, so they need the local date as well as the
109/// local time: a span that crosses midnight has to widen from `HH:MM` to a
110/// dated form, and only the calendar day can tell them that.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct LocalTime {
113    pub year: i32,
114    pub month: u8,
115    pub day: u8,
116    pub hour: u8,
117    pub minute: u8,
118}
119
120impl LocalTime {
121    /// `HH:MM` — the default form, used whenever the surrounding context
122    /// already fixes the day.
123    pub fn clock(&self) -> String {
124        format!("{:02}:{:02}", self.hour, self.minute)
125    }
126
127    /// `MM-DD HH:MM` — the widened form for a timestamp whose day the reader
128    /// cannot infer. Year is omitted; `sloop` history is read in the small.
129    pub fn dated(&self) -> String {
130        format!(
131            "{:02}-{:02} {:02}:{:02}",
132            self.month, self.day, self.hour, self.minute
133        )
134    }
135
136    /// Whether two instants fall on the same local calendar day, which is what
137    /// decides between `clock` and `dated`.
138    pub fn same_day(&self, other: &Self) -> bool {
139        (self.year, self.month, self.day) == (other.year, other.month, other.day)
140    }
141}
142
143/// Breaks a millisecond timestamp into local wall-clock fields. Uses the same
144/// `localtime_r` path as `local_minute` so scheduling and rendering agree on
145/// what "local" means, including across DST.
146pub fn local_time(timestamp_ms: i64) -> Option<LocalTime> {
147    let seconds = timestamp_ms.div_euclid(1_000) as libc::time_t;
148    let mut local = unsafe { std::mem::zeroed::<libc::tm>() };
149    let result = unsafe { libc::localtime_r(&seconds, &mut local) };
150    if result.is_null() {
151        return None;
152    }
153    Some(LocalTime {
154        year: local.tm_year + 1900,
155        month: (local.tm_mon + 1) as u8,
156        day: local.tm_mday as u8,
157        hour: local.tm_hour as u8,
158        minute: local.tm_min as u8,
159    })
160}
161
162fn local_minute(timestamp_ms: i64) -> u16 {
163    let seconds = timestamp_ms.div_euclid(1_000) as libc::time_t;
164    let mut local = unsafe { std::mem::zeroed::<libc::tm>() };
165    let result = unsafe { libc::localtime_r(&seconds, &mut local) };
166    if result.is_null() {
167        return 0;
168    }
169    (local.tm_hour as u16) * 60 + local.tm_min as u16
170}