use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::record::ManagedSessionId;
pub const DEFAULT_FLAP_WINDOW_SECS: u64 = 120;
pub const DEFAULT_MAX_CONSECUTIVE: u32 = 5;
pub const ENV_FLAP_WINDOW_SECS: &str = "TRUSTY_MPM_RESUME_FLAP_WINDOW_SECS";
pub const ENV_MAX_CONSECUTIVE: &str = "TRUSTY_MPM_RESUME_FLAP_THRESHOLD";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeBreakerConfig {
pub flap_window: Duration,
pub max_consecutive: u32,
}
impl Default for ResumeBreakerConfig {
fn default() -> Self {
Self {
flap_window: Duration::from_secs(DEFAULT_FLAP_WINDOW_SECS),
max_consecutive: DEFAULT_MAX_CONSECUTIVE,
}
}
}
impl ResumeBreakerConfig {
pub fn from_env() -> Self {
Self::from_env_with(|k| std::env::var(k).ok())
}
pub fn from_env_with(get: impl Fn(&str) -> Option<String>) -> Self {
let defaults = Self::default();
let flap_window = get(ENV_FLAP_WINDOW_SECS)
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(defaults.flap_window);
let max_consecutive = get(ENV_MAX_CONSECUTIVE)
.and_then(|v| v.trim().parse::<u32>().ok())
.map(|k| k.max(1))
.unwrap_or(defaults.max_consecutive);
Self {
flap_window,
max_consecutive,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlapState {
#[serde(default)]
pub last_auto_resume_at: Option<DateTime<Utc>>,
#[serde(default)]
pub consecutive: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakerVerdict {
Reset,
Counting {
consecutive: u32,
},
Park {
consecutive: u32,
},
}
impl BreakerVerdict {
pub fn consecutive(&self) -> u32 {
match self {
Self::Reset => 0,
Self::Counting { consecutive } | Self::Park { consecutive } => *consecutive,
}
}
}
pub fn evaluate(
cfg: &ResumeBreakerConfig,
state: &FlapState,
now: DateTime<Utc>,
) -> BreakerVerdict {
let Some(last) = state.last_auto_resume_at else {
return BreakerVerdict::Reset;
};
let age = now.signed_duration_since(last);
let Ok(age) = age.to_std() else {
return BreakerVerdict::Reset;
};
if age >= cfg.flap_window {
return BreakerVerdict::Reset;
}
let consecutive = state.consecutive.saturating_add(1);
if consecutive >= cfg.max_consecutive.max(1) {
BreakerVerdict::Park { consecutive }
} else {
BreakerVerdict::Counting { consecutive }
}
}
#[derive(Debug)]
pub struct ResumeBreakerStore {
path: PathBuf,
tmp_path: PathBuf,
states: HashMap<String, FlapState>,
}
impl ResumeBreakerStore {
pub const FILE_NAME: &'static str = "resume-breaker.json";
pub async fn load(data_dir: &Path) -> Self {
let path = data_dir.join(Self::FILE_NAME);
let tmp_path = super::json_file::staging_path(&path);
let mut store = Self {
path,
tmp_path,
states: HashMap::new(),
};
store.reload().await;
store
}
async fn reload(&mut self) {
match tokio::fs::read_to_string(&self.path).await {
Ok(body) => match serde_json::from_str(&body) {
Ok(states) => self.states = states,
Err(e) => warn!(
path = %self.path.display(),
"resume-breaker: sidecar unparsable ({e}); keeping the counters already in memory"
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self.states.clear();
}
Err(e) => warn!(
path = %self.path.display(),
"resume-breaker: sidecar unreadable ({e}); keeping the counters already in memory"
),
}
}
pub async fn state_of(&mut self, id: &ManagedSessionId) -> FlapState {
self.reload().await;
self.states
.get(&id.to_string())
.cloned()
.unwrap_or_default()
}
pub async fn note_auto_resume(&mut self, id: &ManagedSessionId, now: DateTime<Utc>) {
self.reload().await;
let entry = self.states.entry(id.to_string()).or_default();
entry.last_auto_resume_at = Some(now);
self.persist().await;
}
pub async fn note_operator_resume(&mut self, id: &ManagedSessionId) {
self.reload().await;
if self.states.remove(&id.to_string()).is_some() {
self.persist().await;
}
}
pub async fn record_death(
&mut self,
id: &ManagedSessionId,
cfg: &ResumeBreakerConfig,
now: DateTime<Utc>,
) -> BreakerVerdict {
let state = self.state_of(id).await;
let verdict = evaluate(cfg, &state, now);
match verdict {
BreakerVerdict::Reset => {
self.states.remove(&id.to_string());
}
BreakerVerdict::Counting { consecutive } | BreakerVerdict::Park { consecutive } => {
self.states.insert(
id.to_string(),
FlapState {
last_auto_resume_at: state.last_auto_resume_at,
consecutive,
},
);
}
}
self.persist().await;
verdict
}
async fn persist(&mut self) {
let body = match serde_json::to_string_pretty(&self.states) {
Ok(b) => b,
Err(e) => {
warn!("resume-breaker: could not serialise counters ({e}); not persisted");
return;
}
};
if let Err(e) = super::json_file::write_atomic(&self.path, &self.tmp_path, &body).await {
warn!(
path = %self.path.display(),
"resume-breaker: could not persist counters ({e}); they will restart empty"
);
}
}
}
impl super::SessionManager {
pub async fn resume_auto(
&self,
id: &ManagedSessionId,
) -> Result<super::SessionRecord, super::manager::ManagedError> {
let _in_flight = self.begin_resume(id)?;
let record = self.resume_inner(id).await?;
self.resume_breaker
.write()
.await
.note_auto_resume(id, Utc::now())
.await;
if let Some(relauncher) = self.relauncher()
&& let Err(msg) = relauncher.relaunch(&record).await
{
let msg = format!("auto-resume relaunch did not take: {msg}");
if let Err(e) = self.mark_errored(id, &msg).await {
tracing::error!(id = %id, "could not mark a failed auto-resume errored: {e}");
}
return Err(super::manager::ManagedError::AutoResumeRecorded(msg));
}
Ok(record)
}
pub(crate) async fn note_operator_resume(&self, id: &ManagedSessionId) {
self.resume_breaker
.write()
.await
.note_operator_resume(id)
.await;
}
pub(crate) async fn runtime_exit_stop_cause(
&self,
id: &ManagedSessionId,
tmux_name: &str,
) -> super::record::StopCause {
use super::record::StopCause;
let verdict = self
.resume_breaker
.write()
.await
.record_death(id, &self.resume_breaker_cfg, Utc::now())
.await;
match verdict {
BreakerVerdict::Park { consecutive } => {
warn!(
id = %id,
name = %tmux_name,
consecutive,
window_secs = self.resume_breaker_cfg.flap_window.as_secs(),
"runtime-reap: parking auto-resume — this session's runtime exited within the \
flap window of its own auto-resume {consecutive} times in a row (#6568). \
Resume it by hand once the cause is fixed; that clears the park"
);
StopCause::ResumeFlapping
}
BreakerVerdict::Counting { consecutive } => {
tracing::debug!(
id = %id,
name = %tmux_name,
consecutive,
threshold = self.resume_breaker_cfg.max_consecutive,
"runtime-reap: auto-resume flap streak (#6568)"
);
StopCause::Unexpected
}
BreakerVerdict::Reset => StopCause::Unexpected,
}
}
#[cfg(test)]
pub(crate) fn set_resume_breaker_config(&mut self, cfg: ResumeBreakerConfig) {
self.resume_breaker_cfg = cfg;
}
}