Skip to main content

orch/engine/
suspend.rs

1//! Host suspend/resume detection.
2//!
3//! `Instant` is monotonic and does not advance while the host is asleep; wall-clock
4//! time does. Comparing the two across a checkpoint reveals suspend/resume gaps that
5//! would otherwise be misread by wall-clock-only comparisons as a stalled tick loop
6//! (the watchdog in `engine::mod`) or a hung agent (`stuck_task_timing_from_map` in
7//! `engine::tick`).
8//!
9//! `checkpoint()` is the primary detection point, called once per main tick loop
10//! iteration; `suspended_duration_since` pulls from its gap log instead of re-deriving
11//! suspend information from its own wall-clock deltas. Callers that run on an
12//! independent timer and can't wait for the main loop's checkpoint (e.g. the tick
13//! watchdog, which would otherwise race it on wake from suspend) should use their own
14//! `SuspendDetector` instance instead.
15
16use chrono::{DateTime, Duration as ChronoDuration, Utc};
17use std::sync::Mutex;
18use std::time::{Duration, Instant};
19
20/// Wall-clock-minus-monotonic gap large enough to be a host suspend rather than
21/// scheduling jitter or a slow tick.
22const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(20);
23
24/// Bound on retained gap history so a long-running process doesn't grow this
25/// unboundedly; suspend events are rare so this comfortably covers any realistic
26/// task age window.
27const MAX_GAP_LOG: usize = 128;
28
29struct GapRecord {
30    detected_at: DateTime<Utc>,
31    gap: ChronoDuration,
32}
33
34struct Checkpoint {
35    instant: Instant,
36    wall: DateTime<Utc>,
37}
38
39/// Compare a previous checkpoint against now and return the suspend gap, if the
40/// wall-clock-minus-monotonic delta clears `SUSPEND_GAP_THRESHOLD`. Shared by the
41/// global checkpoint log and by independent `SuspendDetector` instances so both use
42/// identical detection math.
43fn gap_since(
44    prev: &Checkpoint,
45    now_instant: Instant,
46    now_wall: DateTime<Utc>,
47) -> Option<ChronoDuration> {
48    let monotonic_elapsed = now_instant.saturating_duration_since(prev.instant);
49    let wall_elapsed = (now_wall - prev.wall).to_std().unwrap_or_default();
50    let gap = wall_elapsed.saturating_sub(monotonic_elapsed);
51    if gap < SUSPEND_GAP_THRESHOLD {
52        return None;
53    }
54    Some(ChronoDuration::from_std(gap).unwrap_or_else(|_| ChronoDuration::zero()))
55}
56
57static LAST_CHECKPOINT: Mutex<Option<Checkpoint>> = Mutex::new(None);
58static GAP_LOG: Mutex<Vec<GapRecord>> = Mutex::new(Vec::new());
59
60/// Record a checkpoint and return the suspend gap (if any) detected since the
61/// previous one. Call this once per main tick loop iteration.
62pub fn checkpoint() -> Option<ChronoDuration> {
63    let now_instant = Instant::now();
64    let now_wall = Utc::now();
65
66    let prev = {
67        let mut guard = LAST_CHECKPOINT.lock().unwrap_or_else(|e| e.into_inner());
68        guard.replace(Checkpoint {
69            instant: now_instant,
70            wall: now_wall,
71        })
72    };
73    let prev = prev?;
74    let gap = gap_since(&prev, now_instant, now_wall)?;
75
76    let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
77    log.push(GapRecord {
78        detected_at: now_wall,
79        gap,
80    });
81    if log.len() > MAX_GAP_LOG {
82        let excess = log.len() - MAX_GAP_LOG;
83        log.drain(0..excess);
84    }
85    Some(gap)
86}
87
88/// Independent suspend detector for callers that run on their own timer and can't
89/// rely on the shared `checkpoint()`/`GAP_LOG` pair, which is only written by the
90/// main tick loop. Two independently-scheduled tokio tasks that both freeze during a
91/// host suspend become "due" at roughly the same instant on wake with no ordering
92/// guarantee between them — a caller reading `GAP_LOG` right after wake can race the
93/// main loop's own `checkpoint()` call and observe no gap yet. A `SuspendDetector`
94/// avoids the race by tracking its own Instant/wall-clock baseline, so it detects the
95/// gap from its own frozen timer instead of waiting on another task to record it.
96pub struct SuspendDetector {
97    last: Option<Checkpoint>,
98}
99
100impl SuspendDetector {
101    pub fn new() -> Self {
102        Self { last: None }
103    }
104
105    /// Record a checkpoint on this detector and return the suspend gap (if any)
106    /// detected since the previous call.
107    pub fn check(&mut self) -> Option<ChronoDuration> {
108        let now_instant = Instant::now();
109        let now_wall = Utc::now();
110        let prev = self.last.replace(Checkpoint {
111            instant: now_instant,
112            wall: now_wall,
113        })?;
114        gap_since(&prev, now_instant, now_wall)
115    }
116}
117
118impl Default for SuspendDetector {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124/// Total suspend time detected since `since`. Subtract this from a wall-clock age
125/// computation to avoid mistaking suspend time for hung agent/tick runtime.
126pub fn suspended_duration_since(since: DateTime<Utc>) -> ChronoDuration {
127    let log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
128    log.iter()
129        .filter(|r| r.detected_at > since)
130        .fold(ChronoDuration::zero(), |acc, r| acc + r.gap)
131}
132
133/// Most recent suspend gap detected within the last `window`, if any. Lets the tick
134/// watchdog downgrade a stale-tick alert to informational when it is fully explained
135/// by a host suspend rather than an actual stall.
136pub fn gap_detected_within(window: Duration) -> Option<ChronoDuration> {
137    let window = ChronoDuration::from_std(window).unwrap_or_else(|_| ChronoDuration::zero());
138    let cutoff = Utc::now() - window;
139    let log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
140    log.iter()
141        .rev()
142        .find(|r| r.detected_at >= cutoff)
143        .map(|r| r.gap)
144}
145
146/// Test-only helper to inject a synthetic gap record without going through the real
147/// `Instant`/wall-clock comparison in `checkpoint()`, which can't be driven
148/// deterministically in tests.
149#[cfg(test)]
150pub(crate) fn inject_gap_for_test(detected_at: DateTime<Utc>, gap: ChronoDuration) {
151    let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
152    log.push(GapRecord { detected_at, gap });
153}
154
155/// Test-only helper to reset shared gap state between tests. Callers must run
156/// `#[serial(suspend_state)]` since `GAP_LOG` is a process-wide static.
157#[cfg(test)]
158pub(crate) fn clear_for_test() {
159    let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
160    log.clear();
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serial_test::serial;
167
168    #[test]
169    #[serial(suspend_state)]
170    fn suspended_duration_since_sums_only_later_gaps() {
171        clear_for_test();
172        let t0 = Utc::now() - ChronoDuration::minutes(10);
173        let t1 = Utc::now() - ChronoDuration::minutes(5);
174        inject_gap_for_test(t0, ChronoDuration::minutes(3));
175        inject_gap_for_test(t1, ChronoDuration::minutes(2));
176
177        // Since before t0: both gaps count.
178        let total = suspended_duration_since(t0 - ChronoDuration::seconds(1));
179        assert_eq!(total, ChronoDuration::minutes(5));
180
181        // Since between t0 and t1: only the later gap counts.
182        let total = suspended_duration_since(t0 + ChronoDuration::seconds(1));
183        assert_eq!(total, ChronoDuration::minutes(2));
184
185        // Since after t1: no gaps count.
186        let total = suspended_duration_since(t1 + ChronoDuration::seconds(1));
187        assert_eq!(total, ChronoDuration::zero());
188
189        clear_for_test();
190    }
191
192    #[test]
193    fn suspend_detector_first_check_has_no_baseline() {
194        let mut detector = SuspendDetector::new();
195        assert_eq!(detector.check(), None);
196    }
197
198    #[test]
199    fn suspend_detector_reports_no_gap_for_normal_elapsed_time() {
200        let mut detector = SuspendDetector::new();
201        detector.check();
202        std::thread::sleep(Duration::from_millis(10));
203        assert_eq!(detector.check(), None);
204    }
205
206    #[test]
207    #[serial(suspend_state)]
208    fn gap_detected_within_finds_recent_gap() {
209        clear_for_test();
210        inject_gap_for_test(Utc::now(), ChronoDuration::minutes(7));
211
212        let found = gap_detected_within(Duration::from_secs(60));
213        assert_eq!(found, Some(ChronoDuration::minutes(7)));
214
215        clear_for_test();
216    }
217}