1use chrono::{DateTime, Duration as ChronoDuration, Utc};
17use std::sync::Mutex;
18use std::sync::PoisonError;
19use std::time::{Duration, Instant};
20
21const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(20);
24
25const 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
41fn 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
62pub 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#[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 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
129pub 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
138pub 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#[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#[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 let total = suspended_duration_since(t0 - ChronoDuration::seconds(1));
186 assert_eq!(total, ChronoDuration::minutes(5));
187
188 let total = suspended_duration_since(t0 + ChronoDuration::seconds(1));
190 assert_eq!(total, ChronoDuration::minutes(2));
191
192 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}