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