use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const STATE_FILE: &str = "oino.safe-mode.json";
pub const TRIGGER_FAILURES: u32 = 2;
pub const ENTRY_LIMIT: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FailureStage {
Services,
Session,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailureEntry {
pub at: u64,
#[serde(rename = "where")]
pub stage: FailureStage,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_id: Option<String>,
pub error: String,
}
impl FailureEntry {
#[must_use]
pub fn new(stage: FailureStage, error: impl Into<String>) -> Self {
Self {
at: now_ms(),
stage,
extension_id: None,
error: error.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SafeModeState {
#[serde(default)]
pub v: u32,
#[serde(default)]
pub consecutive_failures: u32,
#[serde(default)]
pub last_failures: Vec<FailureEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_start: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SafeModePlan {
pub disable_extensions: bool,
pub consecutive_failures: u32,
pub suspects: Vec<(String, String)>,
}
#[derive(Debug)]
pub struct SafeMode {
path: PathBuf,
state: SafeModeState,
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
#[must_use]
pub fn banner(plan: &SafeModePlan) -> String {
let mut text = format!(
"Safe mode: extensions are off after {} failed start{}",
plan.consecutive_failures,
if plan.consecutive_failures == 1 {
""
} else {
"s"
}
);
if !plan.suspects.is_empty() {
let suspects = plan
.suspects
.iter()
.map(|(id, error)| format!("{id} ({error})"))
.collect::<Vec<_>>()
.join(", ");
text.push_str("; suspects: ");
text.push_str(&suspects);
}
text
}
impl SafeMode {
#[must_use]
pub fn load(agent_dir: &Path) -> Self {
let path = agent_dir.join("state").join(STATE_FILE);
let mut state = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<SafeModeState>(&text).ok())
.unwrap_or_default();
state.v = 1;
let mut safe = Self { path, state };
if let Some(at) = safe.state.open_start.take() {
safe.state.last_failures.push(FailureEntry {
at,
stage: FailureStage::Session,
extension_id: None,
error: "the process exited before startup completed".to_owned(),
});
safe.state.consecutive_failures = safe.state.consecutive_failures.saturating_add(1);
safe.trim();
safe.save();
}
safe
}
#[must_use]
pub fn plan(&self) -> SafeModePlan {
SafeModePlan {
disable_extensions: self.state.consecutive_failures >= TRIGGER_FAILURES,
consecutive_failures: self.state.consecutive_failures,
suspects: {
let mut suspects = Vec::new();
for entry in &self.state.last_failures {
if let Some(id) = &entry.extension_id
&& !suspects
.iter()
.any(|(known, _): &(String, String)| known == id)
{
suspects.push((id.clone(), entry.error.clone()));
}
}
suspects
},
}
}
pub fn begin_start(&mut self) {
self.state.open_start = Some(now_ms());
self.save();
}
pub fn end_start(&mut self) {
if self.state.open_start.take().is_some() {
self.save();
}
}
pub fn note_suspect(&mut self, id: &str, error: impl Into<String>) {
self.state.last_failures.push(FailureEntry {
at: now_ms(),
stage: FailureStage::Services,
extension_id: Some(id.to_owned()),
error: error.into(),
});
self.trim();
self.save();
}
pub fn record_failure(&mut self, entry: FailureEntry) {
self.state.open_start = None;
self.state.consecutive_failures = self.state.consecutive_failures.saturating_add(1);
self.state.last_failures.push(entry);
self.trim();
self.save();
}
pub fn record_success(&mut self) {
self.state.open_start = None;
self.state.consecutive_failures = 0;
self.state.last_failures.clear();
self.save();
}
fn trim(&mut self) {
let excess = self.state.last_failures.len().saturating_sub(ENTRY_LIMIT);
if excess > 0 {
self.state.last_failures.drain(..excess);
}
}
fn save(&self) {
if let Ok(text) = serde_json::to_string(&self.state)
&& let Err(error) = oino_settings::storage::write_atomic(&self.path, text.as_bytes())
&& let Some(parent) = self.path.parent()
{
tracing::warn!(
"writing {} failed: {error}",
parent.join(STATE_FILE).display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load(dir: &Path) -> SafeMode {
SafeMode::load(&dir.join("agent"))
}
#[test]
fn missing_state_is_fresh_and_below_the_threshold() {
let dir = tempfile::tempdir().unwrap();
let plan = load(dir.path()).plan();
assert!(!plan.disable_extensions);
assert_eq!(plan.consecutive_failures, 0);
assert_eq!(plan.suspects.len(), 0);
}
#[test]
fn two_recorded_failures_trip_the_plan_and_success_resets() {
let dir = tempfile::tempdir().unwrap();
let mut safe = load(dir.path());
safe.note_suspect("acme.notify", "init failed: boom");
safe.record_failure(FailureEntry::new(FailureStage::Services, "Error: boom"));
safe.record_failure(FailureEntry::new(FailureStage::Session, "Error: crash"));
drop(safe);
let mut safe = load(dir.path());
let plan = safe.plan();
assert!(plan.disable_extensions);
assert_eq!(plan.consecutive_failures, 2);
assert_eq!(
plan.suspects,
vec![("acme.notify".to_owned(), "init failed: boom".to_owned())]
);
let banner = banner(&plan);
assert!(banner.starts_with("Safe mode: extensions are off after 2 failed starts"));
assert!(
banner.contains("suspects: acme.notify (init failed: boom)"),
"{banner}"
);
safe.record_success();
drop(safe);
let plan = load(dir.path()).plan();
assert!(!plan.disable_extensions);
assert_eq!(plan.suspects.len(), 0);
}
#[test]
fn unfinished_start_counts_as_a_failed_start() {
let dir = tempfile::tempdir().unwrap();
let mut safe = load(dir.path());
safe.begin_start();
safe.record_failure(FailureEntry::new(FailureStage::Services, "Error: one"));
drop(safe);
let mut second = load(dir.path());
assert_eq!(second.plan().consecutive_failures, 1);
second.begin_start();
drop(second);
let plan = load(dir.path()).plan();
assert!(plan.disable_extensions);
assert_eq!(plan.consecutive_failures, 2);
}
#[test]
fn user_input_errors_clear_the_marker_without_counting() {
let dir = tempfile::tempdir().unwrap();
let mut safe = load(dir.path());
safe.begin_start();
safe.end_start();
drop(safe);
assert_eq!(load(dir.path()).plan().consecutive_failures, 0);
}
#[test]
fn failure_entries_are_capped_at_eight() {
let dir = tempfile::tempdir().unwrap();
let mut safe = load(dir.path());
for i in 0..6 {
safe.note_suspect(&format!("acme.ext{i}"), "boom");
safe.record_failure(FailureEntry::new(
FailureStage::Services,
format!("Error: {i}"),
));
}
assert_eq!(safe.state.last_failures.len(), ENTRY_LIMIT);
let reloaded = load(dir.path());
assert_eq!(reloaded.state.last_failures.len(), ENTRY_LIMIT);
assert_eq!(
reloaded.state.last_failures.last().unwrap().error,
"Error: 5"
);
}
#[test]
fn state_file_shape_matches_the_spec() {
let dir = tempfile::tempdir().unwrap();
let mut safe = load(dir.path());
safe.note_suspect("acme.notify", "init failed: boom");
safe.record_failure(FailureEntry::new(FailureStage::Session, "Error: crash"));
drop(safe);
let text =
std::fs::read_to_string(dir.path().join("agent/state").join(STATE_FILE)).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["v"], 1);
assert_eq!(value["consecutiveFailures"], 1);
let entries = value["lastFailures"].as_array().unwrap();
assert!(entries[0]["extensionId"].is_string());
assert_eq!(entries[0]["where"], "services");
assert!(entries[0].get("at").is_some_and(serde_json::Value::is_u64));
assert_eq!(entries[1]["where"], "session");
assert!(entries[1].get("extensionId").is_none());
assert!(!text.contains("openStart"));
}
}