Skip to main content

de_mls/app/
phase_timer.rs

1//! App-side phase timer.
2//!
3//! Holds the wall-clock anchor (`started_at`). Phase-anchor durations live
4//! in [`crate::core::ConversationConfig`] (single source of truth);
5//! [`crate::app::SessionRunner`] reads durations off the handle's config
6//! and passes them to this timer's elapsed-checks.
7
8use std::time::{Duration, Instant};
9
10/// What a freeze-timeout poll returned.
11#[derive(Debug, PartialEq)]
12pub enum FreezeTimeoutStatus {
13    NotFreezing,
14    StillFreezing,
15    /// A candidate was selected and applied.
16    Applied,
17    /// Timeout elapsed without a valid candidate. `has_proposals = true`
18    /// means approved work existed at timeout (steward fault); `false` is
19    /// just an empty epoch.
20    TimedOut {
21        has_proposals: bool,
22    },
23}
24
25/// Wall-clock anchor for the active phase. Holds only the anchor
26/// `Instant`; queries take the relevant `Duration` as a parameter.
27/// [`crate::app::SessionRunner`] composes the timer with the state
28/// machine and [`crate::core::ConversationConfig`] durations.
29#[derive(Debug, Clone, Default)]
30pub struct PhaseTimer {
31    /// Meaning depends on the orchestrator's intent at start time:
32    /// - PendingJoin: time the join was initiated.
33    /// - Working: time the first approved proposal arrived
34    ///   (drives the steward-inactivity timer).
35    /// - Freezing: time the freeze window started.
36    /// - Other states: `None`.
37    started_at: Option<Instant>,
38}
39
40impl PhaseTimer {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Anchor the timer at "now". Called by the orchestrator when entering
46    /// a phase whose timeout matters (PendingJoin, Freezing, on first
47    /// approved proposal in Working).
48    pub fn start(&mut self) {
49        self.started_at = Some(Instant::now());
50    }
51
52    /// Drop the anchor. Called by the orchestrator when leaving a
53    /// time-bounded phase.
54    pub fn clear(&mut self) {
55        self.started_at = None;
56    }
57
58    pub fn started_at(&self) -> Option<Instant> {
59        self.started_at
60    }
61
62    /// `false` when no anchor is set. Caller is responsible for state
63    /// guarding and for choosing the right duration for the current phase.
64    pub fn elapsed_since_anchor(&self, duration: Duration) -> bool {
65        match self.started_at {
66            Some(t) => Instant::now() >= t + duration,
67            None => false,
68        }
69    }
70
71    /// Test-only: overwrite the anchor with an explicit `Instant`. Lets
72    /// timer-boundary tests synthesize an aged anchor without sleeping.
73    #[cfg(test)]
74    pub(crate) fn set_started_at_for_test(&mut self, anchor: Option<Instant>) {
75        self.started_at = anchor;
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::time::Duration;
83
84    #[test]
85    fn unset_never_elapsed() {
86        let pt = PhaseTimer::new();
87        assert!(!pt.elapsed_since_anchor(Duration::from_secs(1)));
88    }
89
90    #[test]
91    fn fresh_anchor_not_elapsed() {
92        let mut pt = PhaseTimer::new();
93        pt.start();
94        assert!(!pt.elapsed_since_anchor(Duration::from_secs(60)));
95    }
96
97    #[test]
98    fn elapsed_when_anchor_old_enough() {
99        let mut pt = PhaseTimer::new();
100        pt.started_at = Some(Instant::now() - Duration::from_secs(30));
101        assert!(pt.elapsed_since_anchor(Duration::from_secs(1)));
102    }
103
104    #[test]
105    fn clear_drops_anchor() {
106        let mut pt = PhaseTimer::new();
107        pt.start();
108        assert!(pt.started_at().is_some());
109        pt.clear();
110        assert!(pt.started_at().is_none());
111    }
112}