Skip to main content

entracte_lib/scheduler/
pause.rs

1use std::path::Path;
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use log::error;
5use serde::Serialize;
6
7use crate::pause_store::{self, PauseSnapshot};
8
9/// Abstraction over the two clocks the pause module reads from. The
10/// snapshot/restore path samples both `Instant::now()` and
11/// `SystemTime::now()` to bridge monotonic deadlines and on-disk epoch
12/// timestamps. Tests can substitute a `FakeClock` that advances both in
13/// lockstep so assertions don't need ±slack.
14pub trait Clock {
15    fn instant_now(&self) -> Instant;
16    fn system_now(&self) -> SystemTime;
17}
18
19/// Production clock — both methods just call their `std` counterpart.
20pub struct SystemClock;
21
22impl Clock for SystemClock {
23    fn instant_now(&self) -> Instant {
24        Instant::now()
25    }
26    fn system_now(&self) -> SystemTime {
27        SystemTime::now()
28    }
29}
30
31/// Whether the scheduler is currently active, paused indefinitely, or
32/// paused until a specific `Instant`. The `Option<Instant>` in
33/// `PausedUntil` is `None` for indefinite pauses and `Some(deadline)`
34/// for time-bounded ones.
35#[derive(Debug, Clone)]
36pub enum PauseState {
37    Running,
38    PausedUntil(Option<Instant>),
39}
40
41/// Renderer-facing pause status. `remaining_secs` is `None` for an
42/// indefinite pause and `Some(seconds_left)` for a timed pause.
43#[derive(Debug, Clone, Serialize)]
44pub struct PauseInfo {
45    pub paused: bool,
46    pub remaining_secs: Option<u64>,
47}
48
49pub(super) fn now_epoch_secs() -> u64 {
50    now_epoch_secs_with(&SystemClock)
51}
52
53fn now_epoch_secs_with<C: Clock>(clock: &C) -> u64 {
54    clock
55        .system_now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_secs())
58        .unwrap_or(0)
59}
60
61fn snapshot_from_state_with<C: Clock>(state: &PauseState, clock: &C) -> PauseSnapshot {
62    match state {
63        PauseState::Running => PauseSnapshot {
64            paused: false,
65            until_epoch_secs: None,
66        },
67        PauseState::PausedUntil(None) => PauseSnapshot {
68            paused: true,
69            until_epoch_secs: None,
70        },
71        PauseState::PausedUntil(Some(deadline)) => {
72            let now = clock.instant_now();
73            let remaining = deadline.saturating_duration_since(now);
74            PauseSnapshot {
75                paused: true,
76                until_epoch_secs: Some(now_epoch_secs_with(clock) + remaining.as_secs()),
77            }
78        }
79    }
80}
81
82fn snapshot_from_state(state: &PauseState) -> PauseSnapshot {
83    snapshot_from_state_with(state, &SystemClock)
84}
85
86/// Reconstruct a `PauseState` from the on-disk snapshot, used at
87/// scheduler boot. Timed pauses whose deadline already passed are
88/// cleared back to `Running` and the snapshot is rewritten.
89pub fn restore_pause_state(path: &Path) -> PauseState {
90    restore_pause_state_with(path, &SystemClock)
91}
92
93fn restore_pause_state_with<C: Clock>(path: &Path, clock: &C) -> PauseState {
94    let snap = pause_store::load(path);
95    if !snap.paused {
96        return PauseState::Running;
97    }
98    match snap.until_epoch_secs {
99        None => PauseState::PausedUntil(None),
100        Some(deadline_epoch) => {
101            let now_epoch = now_epoch_secs_with(clock);
102            if deadline_epoch > now_epoch {
103                let remaining = Duration::from_secs(deadline_epoch - now_epoch);
104                PauseState::PausedUntil(Some(clock.instant_now() + remaining))
105            } else {
106                let cleared = PauseSnapshot::default();
107                if let Err(e) = pause_store::save(path, &cleared) {
108                    error!("pause_store: failed to save {}: {e}", path.display());
109                }
110                PauseState::Running
111            }
112        }
113    }
114}
115
116/// Atomically write the current pause state to disk. The deadline is
117/// stored as an absolute epoch timestamp so a paused-until-X is honoured
118/// across process restarts.
119pub fn persist_pause(path: &Path, state: &PauseState) {
120    let snap = snapshot_from_state(state);
121    if let Err(e) = pause_store::save(path, &snap) {
122        error!("pause_store: failed to save {}: {e}", path.display());
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::test_support::{temp_dir, TempDir};
130
131    /// Frozen clock that returns the same `Instant` and `SystemTime` each
132    /// call. Both anchors are captured at construction so they describe
133    /// the same instant in time — eliminating the prior tests' ±slack.
134    struct FakeClock {
135        instant: Instant,
136        system: SystemTime,
137    }
138
139    impl FakeClock {
140        fn now() -> Self {
141            Self {
142                instant: Instant::now(),
143                system: SystemTime::now(),
144            }
145        }
146    }
147
148    impl Clock for FakeClock {
149        fn instant_now(&self) -> Instant {
150            self.instant
151        }
152        fn system_now(&self) -> SystemTime {
153            self.system
154        }
155    }
156
157    fn temp_file() -> (TempDir, std::path::PathBuf) {
158        let dir = temp_dir();
159        let path = dir.path().join("pause.json");
160        (dir, path)
161    }
162
163    #[test]
164    fn restore_returns_running_when_file_missing() {
165        let dir = temp_dir();
166        let path = dir.path().join("does-not-exist.json");
167        assert!(matches!(restore_pause_state(&path), PauseState::Running));
168    }
169
170    #[test]
171    fn restore_returns_running_when_snapshot_not_paused() {
172        let (_dir, path) = temp_file();
173        pause_store::save(
174            &path,
175            &pause_store::PauseSnapshot {
176                paused: false,
177                until_epoch_secs: None,
178            },
179        )
180        .unwrap();
181        assert!(matches!(restore_pause_state(&path), PauseState::Running));
182    }
183
184    #[test]
185    fn restore_returns_indefinite_pause_when_until_is_none() {
186        let (_dir, path) = temp_file();
187        pause_store::save(
188            &path,
189            &pause_store::PauseSnapshot {
190                paused: true,
191                until_epoch_secs: None,
192            },
193        )
194        .unwrap();
195        assert!(matches!(
196            restore_pause_state(&path),
197            PauseState::PausedUntil(None)
198        ));
199    }
200
201    #[test]
202    fn restore_reconstructs_future_deadline_as_paused_until() {
203        // 1h in the future — restored deadline must be exactly 3600s
204        // from the fake clock's instant anchor.
205        let (_dir, path) = temp_file();
206        let clock = FakeClock::now();
207        let now_epoch = now_epoch_secs_with(&clock);
208        pause_store::save(
209            &path,
210            &pause_store::PauseSnapshot {
211                paused: true,
212                until_epoch_secs: Some(now_epoch + 3_600),
213            },
214        )
215        .unwrap();
216        let state = restore_pause_state_with(&path, &clock);
217        match state {
218            PauseState::PausedUntil(Some(deadline)) => {
219                let remaining = deadline.saturating_duration_since(clock.instant_now());
220                assert_eq!(remaining.as_secs(), 3_600);
221            }
222            other => panic!("expected PausedUntil(Some), got {other:?}"),
223        }
224    }
225
226    #[test]
227    fn restore_clears_expired_deadline_and_returns_running() {
228        // Deadline 1h in the past — should auto-resume AND rewrite the
229        // snapshot so the next launch doesn't re-do the work. This is
230        // the case that motivates the test: without the expiry check,
231        // a stale `until` could make the scheduler think it's still
232        // paused at boot and stay silent indefinitely.
233        let (_dir, path) = temp_file();
234        let clock = FakeClock::now();
235        let now_epoch = now_epoch_secs_with(&clock);
236        pause_store::save(
237            &path,
238            &pause_store::PauseSnapshot {
239                paused: true,
240                until_epoch_secs: Some(now_epoch.saturating_sub(3_600)),
241            },
242        )
243        .unwrap();
244        let state = restore_pause_state_with(&path, &clock);
245        assert!(
246            matches!(state, PauseState::Running),
247            "expired deadline must auto-resume"
248        );
249        // Disk should have been rewritten to a not-paused snapshot.
250        let reloaded = pause_store::load(&path);
251        assert!(!reloaded.paused, "expired snapshot must be cleared on disk");
252        assert!(reloaded.until_epoch_secs.is_none());
253    }
254
255    #[test]
256    fn snapshot_from_running_state_is_unpaused() {
257        let snap = snapshot_from_state(&PauseState::Running);
258        assert!(!snap.paused);
259        assert!(snap.until_epoch_secs.is_none());
260    }
261
262    #[test]
263    fn snapshot_from_indefinite_paused_state() {
264        let snap = snapshot_from_state(&PauseState::PausedUntil(None));
265        assert!(snap.paused);
266        assert!(snap.until_epoch_secs.is_none());
267    }
268
269    #[test]
270    fn snapshot_from_timed_pause_uses_absolute_epoch() {
271        // With a shared FakeClock anchoring both Instant and SystemTime,
272        // the snapshot's `until_epoch_secs` is exactly `now_epoch + 120`
273        // — no ±slack needed.
274        let clock = FakeClock::now();
275        let deadline = clock.instant_now() + Duration::from_secs(120);
276        let snap = snapshot_from_state_with(&PauseState::PausedUntil(Some(deadline)), &clock);
277        assert!(snap.paused);
278        let expected = now_epoch_secs_with(&clock) + 120;
279        assert_eq!(snap.until_epoch_secs, Some(expected));
280    }
281}