use chrono::{DateTime, Duration as ChronoDuration, Utc};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(20);
const MAX_GAP_LOG: usize = 128;
struct GapRecord {
detected_at: DateTime<Utc>,
gap: ChronoDuration,
}
struct Checkpoint {
instant: Instant,
wall: DateTime<Utc>,
}
fn gap_since(
prev: &Checkpoint,
now_instant: Instant,
now_wall: DateTime<Utc>,
) -> Option<ChronoDuration> {
let monotonic_elapsed = now_instant.saturating_duration_since(prev.instant);
let wall_elapsed = (now_wall - prev.wall).to_std().unwrap_or_default();
let gap = wall_elapsed.saturating_sub(monotonic_elapsed);
if gap < SUSPEND_GAP_THRESHOLD {
return None;
}
Some(ChronoDuration::from_std(gap).unwrap_or_else(|_| ChronoDuration::zero()))
}
static LAST_CHECKPOINT: Mutex<Option<Checkpoint>> = Mutex::new(None);
static GAP_LOG: Mutex<Vec<GapRecord>> = Mutex::new(Vec::new());
pub fn checkpoint() -> Option<ChronoDuration> {
let now_instant = Instant::now();
let now_wall = Utc::now();
let prev = {
let mut guard = LAST_CHECKPOINT.lock().unwrap_or_else(|e| e.into_inner());
guard.replace(Checkpoint {
instant: now_instant,
wall: now_wall,
})
};
let prev = prev?;
let gap = gap_since(&prev, now_instant, now_wall)?;
let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
log.push(GapRecord {
detected_at: now_wall,
gap,
});
if log.len() > MAX_GAP_LOG {
let excess = log.len() - MAX_GAP_LOG;
log.drain(0..excess);
}
Some(gap)
}
pub struct SuspendDetector {
last: Option<Checkpoint>,
}
impl SuspendDetector {
pub fn new() -> Self {
Self { last: None }
}
pub fn check(&mut self) -> Option<ChronoDuration> {
let now_instant = Instant::now();
let now_wall = Utc::now();
let prev = self.last.replace(Checkpoint {
instant: now_instant,
wall: now_wall,
})?;
gap_since(&prev, now_instant, now_wall)
}
}
impl Default for SuspendDetector {
fn default() -> Self {
Self::new()
}
}
pub fn suspended_duration_since(since: DateTime<Utc>) -> ChronoDuration {
let log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
log.iter()
.filter(|r| r.detected_at > since)
.fold(ChronoDuration::zero(), |acc, r| acc + r.gap)
}
pub fn gap_detected_within(window: Duration) -> Option<ChronoDuration> {
let window = ChronoDuration::from_std(window).unwrap_or_else(|_| ChronoDuration::zero());
let cutoff = Utc::now() - window;
let log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
log.iter()
.rev()
.find(|r| r.detected_at >= cutoff)
.map(|r| r.gap)
}
#[cfg(test)]
pub(crate) fn inject_gap_for_test(detected_at: DateTime<Utc>, gap: ChronoDuration) {
let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
log.push(GapRecord { detected_at, gap });
}
#[cfg(test)]
pub(crate) fn clear_for_test() {
let mut log = GAP_LOG.lock().unwrap_or_else(|e| e.into_inner());
log.clear();
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial(suspend_state)]
fn suspended_duration_since_sums_only_later_gaps() {
clear_for_test();
let t0 = Utc::now() - ChronoDuration::minutes(10);
let t1 = Utc::now() - ChronoDuration::minutes(5);
inject_gap_for_test(t0, ChronoDuration::minutes(3));
inject_gap_for_test(t1, ChronoDuration::minutes(2));
let total = suspended_duration_since(t0 - ChronoDuration::seconds(1));
assert_eq!(total, ChronoDuration::minutes(5));
let total = suspended_duration_since(t0 + ChronoDuration::seconds(1));
assert_eq!(total, ChronoDuration::minutes(2));
let total = suspended_duration_since(t1 + ChronoDuration::seconds(1));
assert_eq!(total, ChronoDuration::zero());
clear_for_test();
}
#[test]
fn suspend_detector_first_check_has_no_baseline() {
let mut detector = SuspendDetector::new();
assert_eq!(detector.check(), None);
}
#[test]
fn suspend_detector_reports_no_gap_for_normal_elapsed_time() {
let mut detector = SuspendDetector::new();
detector.check();
std::thread::sleep(Duration::from_millis(10));
assert_eq!(detector.check(), None);
}
#[test]
#[serial(suspend_state)]
fn gap_detected_within_finds_recent_gap() {
clear_for_test();
inject_gap_for_test(Utc::now(), ChronoDuration::minutes(7));
let found = gap_detected_within(Duration::from_secs(60));
assert_eq!(found, Some(ChronoDuration::minutes(7)));
clear_for_test();
}
}