use std::io;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct Ack {
epoch: Instant,
done_ms: Arc<AtomicU64>,
}
impl Ack {
pub fn done(&self) {
self.done_ms.store(
self.epoch.elapsed().as_millis() as u64 + 1,
Ordering::Release,
);
}
}
#[derive(Clone, Copy, Debug)]
pub struct StallConfig {
pub poll: Duration,
pub threshold: Duration,
pub observer_gap: Duration,
}
impl Default for StallConfig {
fn default() -> Self {
Self {
poll: Duration::from_millis(50),
threshold: Duration::from_millis(200),
observer_gap: Duration::from_secs(1),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StallEvent {
Stalled {
episode: u64,
gap: Duration,
recovered: bool,
},
Recovered { episode: u64, gap: Duration },
ObserverGap { gap: Duration },
Stopped { reason: String },
}
#[derive(Debug, Default)]
struct Episode {
reported: bool,
}
impl Episode {
fn detect(&mut self, gap: Duration, threshold: Duration) -> bool {
if gap < threshold || self.reported {
return false;
}
self.reported = true;
true
}
}
#[derive(Debug, Clone, Copy)]
pub struct Cooldown {
every: Duration,
last: Option<Duration>,
}
impl Cooldown {
pub fn new(every: Duration) -> Self {
Self { every, last: None }
}
pub fn reserve(&mut self, now: Duration) -> bool {
if self
.last
.is_some_and(|l| now.saturating_sub(l) < self.every)
{
return false;
}
self.last = Some(now);
true
}
}
pub struct StallWatchdog {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl StallWatchdog {
pub fn spawn(
name: &str,
config: StallConfig,
ping: impl Fn(Ack) -> Result<(), String> + Send + 'static,
on_event: impl Fn(StallEvent) + Send + 'static,
) -> io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let handle = thread::Builder::new()
.name(name.to_string())
.spawn(move || run(config, flag, ping, on_event))?;
Ok(Self {
stop,
handle: Some(handle),
})
}
pub fn stop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for StallWatchdog {
fn drop(&mut self) {
self.stop();
}
}
fn run(
cfg: StallConfig,
stop: Arc<AtomicBool>,
ping: impl Fn(Ack) -> Result<(), String>,
on_event: impl Fn(StallEvent),
) {
let epoch = Instant::now();
let ms = |at: Instant| at.saturating_duration_since(epoch).as_millis() as u64 + 1;
let mut done = Arc::new(AtomicU64::new(0));
let mut pending: Option<u64> = None;
let mut episode = Episode::default();
let mut episode_no = 0u64;
let mut previous_poll = ms(Instant::now());
while !stop.load(Ordering::SeqCst) {
thread::sleep(cfg.poll);
if stop.load(Ordering::SeqCst) {
return;
}
let now = ms(Instant::now());
let observer = Duration::from_millis(now.saturating_sub(previous_poll));
previous_poll = now;
if observer > cfg.observer_gap {
on_event(StallEvent::ObserverGap { gap: observer });
if pending.is_some() {
pending = Some(now);
}
episode.reported = false;
}
if let Some(sent) = pending {
let acked = done.load(Ordering::Acquire);
let gap = Duration::from_millis(if acked != 0 {
acked.saturating_sub(sent)
} else {
now.saturating_sub(sent)
});
if episode.detect(gap, cfg.threshold) {
episode_no += 1;
on_event(StallEvent::Stalled {
episode: episode_no,
gap,
recovered: acked != 0,
});
}
if acked == 0 {
continue;
}
if episode.reported {
on_event(StallEvent::Recovered {
episode: episode_no,
gap,
});
}
episode.reported = false;
pending = None;
}
if pending.is_none() {
done = Arc::new(AtomicU64::new(0));
let ack = Ack {
epoch,
done_ms: Arc::clone(&done),
};
match ping(ack) {
Ok(()) => pending = Some(ms(Instant::now())),
Err(reason) => {
on_event(StallEvent::Stopped { reason });
return;
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct HeartbeatConfig {
pub path: std::path::PathBuf,
pub poll: Duration,
pub threshold: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HeartbeatEvent {
Stalled { episode: u64, age: Duration },
Recovered { episode: u64, age: Duration },
}
pub struct HeartbeatWatch {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl HeartbeatWatch {
pub fn spawn(
name: &str,
config: HeartbeatConfig,
on_event: impl Fn(HeartbeatEvent) + Send + 'static,
) -> io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let handle = thread::Builder::new()
.name(name.to_string())
.spawn(move || watch_heartbeat(config, flag, on_event))?;
Ok(Self {
stop,
handle: Some(handle),
})
}
pub fn stop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for HeartbeatWatch {
fn drop(&mut self) {
self.stop();
}
}
#[derive(Debug)]
struct HeartbeatState {
last_seen: std::time::SystemTime,
episode: Episode,
episode_no: u64,
}
impl HeartbeatState {
fn new(started: std::time::SystemTime) -> Self {
Self {
last_seen: started,
episode: Episode::default(),
episode_no: 0,
}
}
fn step(
&mut self,
modified: Option<std::time::SystemTime>,
now: std::time::SystemTime,
threshold: Duration,
) -> Option<HeartbeatEvent> {
if let Some(modified) = modified {
self.last_seen = modified;
}
let age = now.duration_since(self.last_seen).unwrap_or(Duration::ZERO);
if self.episode.detect(age, threshold) {
self.episode_no += 1;
Some(HeartbeatEvent::Stalled {
episode: self.episode_no,
age,
})
} else if age < threshold && self.episode.reported {
self.episode.reported = false;
Some(HeartbeatEvent::Recovered {
episode: self.episode_no,
age,
})
} else {
None
}
}
}
fn watch_heartbeat(
config: HeartbeatConfig,
stop: Arc<AtomicBool>,
on_event: impl Fn(HeartbeatEvent),
) {
let mut state = HeartbeatState::new(std::time::SystemTime::now());
while !stop.load(Ordering::SeqCst) {
thread::sleep(config.poll);
if stop.load(Ordering::SeqCst) {
return;
}
let modified = std::fs::metadata(&config.path)
.and_then(|m| m.modified())
.ok();
if let Some(event) = state.step(modified, std::time::SystemTime::now(), config.threshold) {
on_event(event);
}
}
}
#[cfg(test)]
mod heartbeat_tests {
use super::*;
use std::time::SystemTime;
#[test]
fn a_missing_heartbeat_is_reported_once_and_a_fresh_one_recovers() {
let threshold = Duration::from_millis(100);
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
let at = |ms: u64| t0 + Duration::from_millis(ms);
let mut state = HeartbeatState::new(t0);
assert_eq!(state.step(None, at(50), threshold), None);
assert_eq!(
state.step(None, at(100), threshold),
Some(HeartbeatEvent::Stalled {
episode: 1,
age: Duration::from_millis(100)
})
);
assert_eq!(state.step(None, at(300), threshold), None);
assert_eq!(state.step(None, at(400), threshold), None);
assert_eq!(
state.step(Some(at(400)), at(410), threshold),
Some(HeartbeatEvent::Recovered {
episode: 1,
age: Duration::from_millis(10)
})
);
assert_eq!(state.step(Some(at(430)), at(440), threshold), None);
assert_eq!(
state.step(Some(at(430)), at(530), threshold),
Some(HeartbeatEvent::Stalled {
episode: 2,
age: Duration::from_millis(100)
})
);
}
}