use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DesiredState {
pub desired_running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_start_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_start_epoch_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_heartbeat_epoch_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_native_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_platform_error: Option<String>,
#[serde(default)]
pub restart_attempt: u32,
#[serde(default)]
pub recovery_pending: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_reason: Option<String>,
}
pub trait DesiredStateBackend: Send + Sync {
fn load(&self) -> Result<DesiredState, String>;
fn save(&self, state: &DesiredState) -> Result<(), String>;
fn clear(&self) -> Result<(), String>;
}
const FILE_NAME: &str = "bg-desired-state.json";
const TEMP_SUFFIX: &str = ".tmp";
pub struct FileDesiredStateBackend {
path: PathBuf,
}
impl FileDesiredStateBackend {
pub fn new(dir: impl Into<PathBuf>) -> Self {
let mut path = dir.into();
path.push(FILE_NAME);
Self { path }
}
fn temp_path(&self) -> PathBuf {
let mut p = self.path.clone().into_os_string();
p.push(TEMP_SUFFIX);
PathBuf::from(p)
}
fn clean_stale_temp(temp_path: &Path) {
let _ = fs::remove_file(temp_path);
}
}
impl DesiredStateBackend for FileDesiredStateBackend {
fn load(&self) -> Result<DesiredState, String> {
match fs::read_to_string(&self.path) {
Ok(data) => serde_json::from_str(&data).map_err(|e| {
format!("malformed desired-state at {}: {e}", self.path.display())
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Self::clean_stale_temp(&self.temp_path());
Ok(DesiredState::default())
}
Err(e) => Err(e.to_string()),
}
}
fn save(&self, state: &DesiredState) -> Result<(), String> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
let temp_path = self.temp_path();
{
let mut file = File::create(&temp_path).map_err(|e| e.to_string())?;
file.write_all(json.as_bytes()).map_err(|e| e.to_string())?;
file.flush().map_err(|e| e.to_string())?;
if let Err(e) = file.sync_all() {
log::warn!("fsync of desired-state temp failed: {e}");
}
}
fs::rename(&temp_path, &self.path).map_err(|e| {
Self::clean_stale_temp(&temp_path);
e.to_string()
})
}
fn clear(&self) -> Result<(), String> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
}?;
Self::clean_stale_temp(&self.temp_path());
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
#[test]
fn desired_state_default_values() {
let state = DesiredState::default();
assert!(!state.desired_running);
assert_eq!(state.last_start_config, None);
assert_eq!(state.last_start_epoch_ms, None);
assert_eq!(state.last_heartbeat_epoch_ms, None);
assert_eq!(state.last_native_state, None);
assert_eq!(state.last_platform_error, None);
assert_eq!(state.restart_attempt, 0);
assert!(!state.recovery_pending);
assert_eq!(state.recovery_reason, None);
}
#[test]
fn desired_state_serde_roundtrip() {
let state = DesiredState {
desired_running: true,
last_start_config: Some(serde_json::json!({"serviceLabel":"test"})),
last_start_epoch_ms: Some(1700000000000),
last_heartbeat_epoch_ms: Some(1700000001000),
last_native_state: Some("running".into()),
last_platform_error: None,
restart_attempt: 2,
recovery_pending: true,
recovery_reason: Some("boot".into()),
};
let json = serde_json::to_string(&state).unwrap();
let de: DesiredState = serde_json::from_str(&json).unwrap();
assert_eq!(de, state);
}
#[test]
fn desired_state_json_keys_camel_case() {
let state = DesiredState {
desired_running: true,
last_start_config: Some(serde_json::json!({"serviceLabel":"test"})),
last_start_epoch_ms: Some(1700000000000),
last_heartbeat_epoch_ms: Some(1700000001000),
last_native_state: Some("running".into()),
last_platform_error: Some("err".into()),
restart_attempt: 1,
recovery_pending: true,
recovery_reason: Some("boot".into()),
};
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("\"desiredRunning\":"), "{json}");
assert!(json.contains("\"lastStartConfig\":"), "{json}");
assert!(json.contains("\"lastStartEpochMs\":"), "{json}");
assert!(json.contains("\"lastHeartbeatEpochMs\":"), "{json}");
assert!(json.contains("\"lastNativeState\":"), "{json}");
assert!(json.contains("\"lastPlatformError\":"), "{json}");
assert!(json.contains("\"restartAttempt\":"), "{json}");
assert!(json.contains("\"recoveryPending\":"), "{json}");
assert!(json.contains("\"recoveryReason\":"), "{json}");
}
#[test]
fn desired_state_default_serde_roundtrip() {
let state = DesiredState::default();
let json = serde_json::to_string(&state).unwrap();
let de: DesiredState = serde_json::from_str(&json).unwrap();
assert_eq!(de, state);
}
fn temp_dir() -> PathBuf {
tempfile::tempdir().unwrap().keep()
}
#[test]
fn file_backend_roundtrip() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
let state = DesiredState {
desired_running: true,
last_start_config: Some(
serde_json::json!({"serviceLabel":"Syncing","foregroundServiceType":"dataSync"}),
),
last_start_epoch_ms: Some(1700000000000),
last_heartbeat_epoch_ms: Some(1700000005000),
last_native_state: Some("running".into()),
last_platform_error: None,
restart_attempt: 0,
recovery_pending: false,
recovery_reason: None,
};
backend.save(&state).unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, state);
}
#[test]
fn file_backend_load_missing_file_returns_default() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
let loaded = backend.load().unwrap();
assert_eq!(loaded, DesiredState::default());
}
#[test]
fn file_backend_clear_loads_default() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
let state = DesiredState {
desired_running: true,
..Default::default()
};
backend.save(&state).unwrap();
backend.clear().unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, DesiredState::default());
}
#[test]
fn file_backend_clear_removes_file() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
let state = DesiredState {
desired_running: true,
..Default::default()
};
backend.save(&state).unwrap();
assert!(dir.join(FILE_NAME).exists());
backend.clear().unwrap();
assert!(!dir.join(FILE_NAME).exists());
}
#[test]
fn file_backend_clear_when_missing_is_ok() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
backend.clear().unwrap();
}
#[test]
fn file_backend_save_creates_parent_dir() {
let dir = temp_dir();
let nested = dir.join("sub").join("dir");
let backend = FileDesiredStateBackend::new(nested);
let state = DesiredState::default();
backend.save(&state).unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, state);
}
#[test]
fn file_backend_overwrite_on_save() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
let state1 = DesiredState {
desired_running: true,
..Default::default()
};
backend.save(&state1).unwrap();
let state2 = DesiredState {
desired_running: false,
restart_attempt: 5,
..Default::default()
};
backend.save(&state2).unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, state2);
assert_ne!(loaded, state1);
}
#[test]
fn core04_save_leaves_no_temp_after_success() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
backend
.save(&DesiredState {
desired_running: true,
..Default::default()
})
.unwrap();
assert!(dir.join(FILE_NAME).exists(), "canonical must exist");
assert!(
!dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists(),
"temp must be gone after successful save"
);
}
#[test]
fn core04_malformed_canonical_surfaces_error_not_default() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
std::fs::write(dir.join(FILE_NAME), "{ this is not valid json").unwrap();
let err = backend.load().unwrap_err();
assert!(
err.contains("malformed desired-state"),
"expected malformed-canonical diagnostic, got: {err}"
);
}
#[test]
fn core04_stale_temp_does_not_break_subsequent_save() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
std::fs::write(
dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX),
"stale garbage from a crashed save",
)
.unwrap();
let state = DesiredState {
desired_running: true,
restart_attempt: 7,
..Default::default()
};
backend.save(&state).unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, state);
assert!(
!dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists(),
"stale temp must be cleared by successful save"
);
}
#[test]
fn core04_canonical_is_never_partial_across_overwrites() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
for i in 0..20u32 {
let state = DesiredState {
desired_running: i % 2 == 0,
restart_attempt: i,
recovery_reason: Some(format!("iter-{i}")),
last_platform_error: Some("x".repeat(i as usize)),
..Default::default()
};
backend.save(&state).unwrap();
let raw = std::fs::read_to_string(dir.join(FILE_NAME)).unwrap();
let parsed: DesiredState = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed, state, "iter {i}: canonical must be complete");
assert_eq!(backend.load().unwrap(), state, "iter {i}");
assert!(
!dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists(),
"iter {i}: temp leaked"
);
}
}
#[test]
fn core04_clear_removes_stale_temp_too() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
backend
.save(&DesiredState {
desired_running: true,
..Default::default()
})
.unwrap();
std::fs::write(dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX), "stale").unwrap();
assert!(dir.join(FILE_NAME).exists());
assert!(dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists());
backend.clear().unwrap();
assert!(!dir.join(FILE_NAME).exists());
assert!(!dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists());
}
#[test]
fn core04_load_missing_file_cleans_stale_temp() {
let dir = temp_dir();
let backend = FileDesiredStateBackend::new(dir.clone());
std::fs::write(dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX), "stale").unwrap();
assert!(!dir.join(FILE_NAME).exists());
let loaded = backend.load().unwrap();
assert_eq!(loaded, DesiredState::default());
assert!(
!dir.join(FILE_NAME.to_owned() + TEMP_SUFFIX).exists(),
"load of missing canonical must clean stale temp"
);
}
#[test]
fn backend_is_object_safe() {
let dir = temp_dir();
let backend: Box<dyn DesiredStateBackend> = Box::new(FileDesiredStateBackend::new(dir));
let state = DesiredState::default();
backend.save(&state).unwrap();
let loaded = backend.load().unwrap();
assert_eq!(loaded, state);
}
}