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
34struct Checkpoint {
35 instant: Instant,
36 wall: DateTime<Utc>,
37}
38
39fn 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
60pub 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
88pub struct SuspendDetector {
97 last: Option<Checkpoint>,
98}
99
100impl SuspendDetector {
101 pub fn new() -> Self {
102 Self { last: None }
103 }
104
105 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
124pub 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
133pub 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#[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#[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 let total = suspended_duration_since(t0 - ChronoDuration::seconds(1));
179 assert_eq!(total, ChronoDuration::minutes(5));
180
181 let total = suspended_duration_since(t0 + ChronoDuration::seconds(1));
183 assert_eq!(total, ChronoDuration::minutes(2));
184
185 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}