1use chrono::{DateTime, Duration as ChronoDuration, Utc};
17use std::sync::Mutex;
18use std::time::{Duration, Instant};
19
20const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(20);
23
24const MAX_GAP_LOG: usize = 128;
28
29struct GapRecord {
30 detected_at: DateTime<Utc>,
31 gap: ChronoDuration,
32}
33
34#[derive(Debug)]
35struct Checkpoint {
36 instant: Instant,
37 wall: DateTime<Utc>,
38}
39
40fn gap_since(
45 prev: &Checkpoint,
46 now_instant: Instant,
47 now_wall: DateTime<Utc>,
48) -> Option<ChronoDuration> {
49 let monotonic_elapsed = now_instant.saturating_duration_since(prev.instant);
50 let wall_elapsed = (now_wall - prev.wall).to_std().unwrap_or_default();
51 let gap = wall_elapsed.saturating_sub(monotonic_elapsed);
52 if gap < SUSPEND_GAP_THRESHOLD {
53 return None;
54 }
55 Some(ChronoDuration::from_std(gap).unwrap_or_else(|_| ChronoDuration::zero()))
56}
57
58static LAST_CHECKPOINT: Mutex<Option<Checkpoint>> = Mutex::new(None);
59static GAP_LOG: Mutex<Vec<GapRecord>> = Mutex::new(Vec::new());
60
61pub fn checkpoint() -> Option<ChronoDuration> {
64 let now_instant = Instant::now();
65 let now_wall = Utc::now();
66
67 let prev = {
68 let mut guard = LAST_CHECKPOINT
69 .lock()
70 .unwrap_or_else(std::sync::PoisonError::into_inner);
71 guard.replace(Checkpoint {
72 instant: now_instant,
73 wall: now_wall,
74 })
75 };
76 let prev = prev?;
77 let gap = gap_since(&prev, now_instant, now_wall)?;
78
79 let mut log = GAP_LOG
80 .lock()
81 .unwrap_or_else(std::sync::PoisonError::into_inner);
82 log.push(GapRecord {
83 detected_at: now_wall,
84 gap,
85 });
86 if log.len() > MAX_GAP_LOG {
87 let excess = log.len() - MAX_GAP_LOG;
88 log.drain(0..excess);
89 }
90 Some(gap)
91}
92
93#[derive(Debug)]
102pub struct SuspendDetector {
103 last: Option<Checkpoint>,
104}
105
106impl SuspendDetector {
107 pub fn new() -> Self {
108 Self { last: None }
109 }
110
111 pub fn check(&mut self) -> Option<ChronoDuration> {
114 let now_instant = Instant::now();
115 let now_wall = Utc::now();
116 let prev = self.last.replace(Checkpoint {
117 instant: now_instant,
118 wall: now_wall,
119 })?;
120 gap_since(&prev, now_instant, now_wall)
121 }
122}
123
124impl Default for SuspendDetector {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130pub fn suspended_duration_since(since: DateTime<Utc>) -> ChronoDuration {
133 let log = GAP_LOG
134 .lock()
135 .unwrap_or_else(std::sync::PoisonError::into_inner);
136 log.iter()
137 .filter(|r| r.detected_at > since)
138 .fold(ChronoDuration::zero(), |acc, r| acc + r.gap)
139}
140
141pub fn gap_detected_within(window: Duration) -> Option<ChronoDuration> {
145 let window = ChronoDuration::from_std(window).unwrap_or_else(|_| ChronoDuration::zero());
146 let cutoff = Utc::now() - window;
147 let log = GAP_LOG
148 .lock()
149 .unwrap_or_else(std::sync::PoisonError::into_inner);
150 log.iter()
151 .rev()
152 .find(|r| r.detected_at >= cutoff)
153 .map(|r| r.gap)
154}
155
156#[cfg(test)]
160pub(crate) fn inject_gap_for_test(detected_at: DateTime<Utc>, gap: ChronoDuration) {
161 let mut log = GAP_LOG
162 .lock()
163 .unwrap_or_else(std::sync::PoisonError::into_inner);
164 log.push(GapRecord { detected_at, gap });
165}
166
167#[cfg(test)]
170pub(crate) fn clear_for_test() {
171 let mut log = GAP_LOG
172 .lock()
173 .unwrap_or_else(std::sync::PoisonError::into_inner);
174 log.clear();
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use serial_test::serial;
181
182 #[test]
183 #[serial(suspend_state)]
184 fn suspended_duration_since_sums_only_later_gaps() {
185 clear_for_test();
186 let t0 = Utc::now() - ChronoDuration::minutes(10);
187 let t1 = Utc::now() - ChronoDuration::minutes(5);
188 inject_gap_for_test(t0, ChronoDuration::minutes(3));
189 inject_gap_for_test(t1, ChronoDuration::minutes(2));
190
191 let total = suspended_duration_since(t0 - ChronoDuration::seconds(1));
193 assert_eq!(total, ChronoDuration::minutes(5));
194
195 let total = suspended_duration_since(t0 + ChronoDuration::seconds(1));
197 assert_eq!(total, ChronoDuration::minutes(2));
198
199 let total = suspended_duration_since(t1 + ChronoDuration::seconds(1));
201 assert_eq!(total, ChronoDuration::zero());
202
203 clear_for_test();
204 }
205
206 #[test]
207 fn suspend_detector_first_check_has_no_baseline() {
208 let mut detector = SuspendDetector::new();
209 assert_eq!(detector.check(), None);
210 }
211
212 #[test]
213 fn suspend_detector_reports_no_gap_for_normal_elapsed_time() {
214 let mut detector = SuspendDetector::new();
215 detector.check();
216 std::thread::sleep(Duration::from_millis(10));
217 assert_eq!(detector.check(), None);
218 }
219
220 #[test]
221 #[serial(suspend_state)]
222 fn gap_detected_within_finds_recent_gap() {
223 clear_for_test();
224 inject_gap_for_test(Utc::now(), ChronoDuration::minutes(7));
225
226 let found = gap_detected_within(Duration::from_secs(60));
227 assert_eq!(found, Some(ChronoDuration::minutes(7)));
228
229 clear_for_test();
230 }
231}