use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde::Deserialize;
const HEARTBEAT_FILE: &str = "heartbeat.json";
const HEARTBEAT_TMP_FILE: &str = "heartbeat.tmp";
pub struct HeartbeatReader {
path: PathBuf,
last_heartbeat: Option<HeartbeatSnapshot>,
last_heartbeat_seq: Option<u64>,
last_heartbeat_seen_at: Option<Instant>,
last_activity_seq: Option<u64>,
last_activity_seen_at: Option<Instant>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeartbeatDecision {
PendingBoot(HeartbeatStatus),
Active(HeartbeatStatus),
Idle(HeartbeatStatus),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeartbeatStatus {
pub heartbeat_seq: Option<u64>,
pub activity_seq: Option<u64>,
pub active_exec_sessions: u32,
pub active_fs_streams: u32,
pub active_tcp_streams: u32,
pub heartbeat_stale_for: Option<Duration>,
pub idle_for: Option<Duration>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
struct HeartbeatSnapshot {
heartbeat_seq: u64,
activity_seq: u64,
active_exec_sessions: u32,
active_fs_streams: u32,
active_tcp_streams: u32,
}
impl HeartbeatReader {
pub fn new(runtime_dir: &Path) -> Self {
Self::new_at(runtime_dir)
}
fn new_at(runtime_dir: &Path) -> Self {
Self {
path: runtime_dir.join(HEARTBEAT_FILE),
last_heartbeat: None,
last_heartbeat_seq: None,
last_heartbeat_seen_at: None,
last_activity_seq: None,
last_activity_seen_at: None,
}
}
fn read(&self) -> Option<HeartbeatSnapshot> {
let content = std::fs::read(&self.path).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn check(&mut self, idle_timeout: Option<Duration>) -> HeartbeatDecision {
self.check_at(Instant::now(), idle_timeout)
}
fn check_at(&mut self, now: Instant, idle_timeout: Option<Duration>) -> HeartbeatDecision {
if let Some(heartbeat) = self.read() {
self.observe(heartbeat, now);
}
let status = self.status(now);
if status.heartbeat_seq.is_none() {
return HeartbeatDecision::PendingBoot(status);
}
if status.active_exec_sessions > 0 {
return HeartbeatDecision::Active(status);
}
match (idle_timeout, status.idle_for) {
(Some(idle_timeout), Some(idle_for)) if idle_for >= idle_timeout => {
HeartbeatDecision::Idle(status)
}
_ => HeartbeatDecision::Active(status),
}
}
fn observe(&mut self, heartbeat: HeartbeatSnapshot, now: Instant) {
if self.last_heartbeat_seq != Some(heartbeat.heartbeat_seq) {
self.last_heartbeat_seq = Some(heartbeat.heartbeat_seq);
self.last_heartbeat_seen_at = Some(now);
}
if self.last_activity_seq != Some(heartbeat.activity_seq) {
self.last_activity_seq = Some(heartbeat.activity_seq);
self.last_activity_seen_at = Some(now);
}
self.last_heartbeat = Some(heartbeat);
}
fn status(&self, now: Instant) -> HeartbeatStatus {
let heartbeat = self.last_heartbeat.as_ref();
HeartbeatStatus {
heartbeat_seq: self.last_heartbeat_seq,
activity_seq: self.last_activity_seq,
active_exec_sessions: heartbeat.map_or(0, |hb| hb.active_exec_sessions),
active_fs_streams: heartbeat.map_or(0, |hb| hb.active_fs_streams),
active_tcp_streams: heartbeat.map_or(0, |hb| hb.active_tcp_streams),
heartbeat_stale_for: self
.last_heartbeat_seen_at
.map(|seen_at| now.duration_since(seen_at)),
idle_for: self
.last_activity_seen_at
.map(|seen_at| now.duration_since(seen_at)),
}
}
}
pub fn clear_stale(runtime_dir: &Path) -> std::io::Result<()> {
remove_file_if_exists(&runtime_dir.join(HEARTBEAT_FILE))?;
remove_file_if_exists(&runtime_dir.join(HEARTBEAT_TMP_FILE))?;
Ok(())
}
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use chrono::Utc;
use microsandbox_protocol::heartbeat::{ActivityCounters, Heartbeat};
use super::*;
#[test]
fn clear_stale_removes_previous_run_heartbeat_files() {
let dir = tempfile::tempdir().unwrap();
let heartbeat_path = dir.path().join(HEARTBEAT_FILE);
let tmp_path = dir.path().join(HEARTBEAT_TMP_FILE);
write_heartbeat_file(&heartbeat_path, heartbeat(1, 1, 0));
std::fs::write(&tmp_path, b"stale").unwrap();
clear_stale(dir.path()).unwrap();
assert!(!heartbeat_path.exists());
assert!(!tmp_path.exists());
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
assert!(matches!(
reader.check_at(
start + Duration::from_secs(1),
Some(Duration::from_secs(60))
),
HeartbeatDecision::PendingBoot(_)
));
}
#[test]
fn running_exec_prevents_idle_despite_stale_activity_sequence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(HEARTBEAT_FILE);
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
write_heartbeat_file(&path, heartbeat(1, 1, 1));
assert!(matches!(
reader.check_at(start, Some(Duration::from_secs(60))),
HeartbeatDecision::Active(_)
));
write_heartbeat_file(&path, heartbeat(2, 1, 1));
assert!(matches!(
reader.check_at(
start + Duration::from_secs(120),
Some(Duration::from_secs(60))
),
HeartbeatDecision::Active(_)
));
}
#[test]
fn no_exec_is_idle_when_activity_sequence_is_stale() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(HEARTBEAT_FILE);
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
write_heartbeat_file(&path, heartbeat(1, 1, 0));
assert!(matches!(
reader.check_at(start, Some(Duration::from_secs(60))),
HeartbeatDecision::Active(_)
));
write_heartbeat_file(&path, heartbeat(2, 1, 0));
assert!(matches!(
reader.check_at(
start + Duration::from_secs(120),
Some(Duration::from_secs(60))
),
HeartbeatDecision::Idle(_)
));
}
#[test]
fn no_idle_timeout_keeps_fresh_inactive_sandbox_active() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(HEARTBEAT_FILE);
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
write_heartbeat_file(&path, heartbeat(1, 1, 0));
assert!(matches!(
reader.check_at(start, None),
HeartbeatDecision::Active(_)
));
write_heartbeat_file(&path, heartbeat(2, 1, 0));
assert!(matches!(
reader.check_at(start + Duration::from_secs(120), None),
HeartbeatDecision::Active(_)
));
}
#[test]
fn stale_heartbeat_with_running_exec_is_not_killed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(HEARTBEAT_FILE);
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
write_heartbeat_file(&path, heartbeat(1, 1, 1));
assert!(matches!(
reader.check_at(start, Some(Duration::from_secs(60))),
HeartbeatDecision::Active(_)
));
assert!(matches!(
reader.check_at(
start + Duration::from_secs(3600),
Some(Duration::from_secs(60))
),
HeartbeatDecision::Active(_)
));
}
#[test]
fn stale_heartbeat_without_idle_timeout_is_never_killed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(HEARTBEAT_FILE);
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
write_heartbeat_file(&path, heartbeat(1, 1, 0));
assert!(matches!(
reader.check_at(start, None),
HeartbeatDecision::Active(_)
));
assert!(matches!(
reader.check_at(start + Duration::from_secs(3600), None),
HeartbeatDecision::Active(_)
));
}
#[test]
fn missing_heartbeat_stays_pending_boot() {
let dir = tempfile::tempdir().unwrap();
let start = Instant::now();
let mut reader = HeartbeatReader::new_at(dir.path());
assert!(matches!(
reader.check_at(
start + Duration::from_secs(1),
Some(Duration::from_secs(60))
),
HeartbeatDecision::PendingBoot(_)
));
assert!(matches!(
reader.check_at(
start + Duration::from_secs(3600),
Some(Duration::from_secs(60))
),
HeartbeatDecision::PendingBoot(_)
));
}
fn heartbeat(heartbeat_seq: u64, activity_seq: u64, active_exec_sessions: u32) -> Heartbeat {
Heartbeat {
heartbeat_seq,
activity_seq,
timestamp: Utc::now(),
last_activity: Utc::now(),
active_exec_sessions,
active_fs_streams: 0,
active_tcp_streams: 0,
activity_counters: ActivityCounters::default(),
}
}
fn write_heartbeat_file(path: &Path, heartbeat: Heartbeat) {
std::fs::write(path, serde_json::to_vec(&heartbeat).unwrap()).unwrap();
}
}