feat(daemon): StateStore trait + atomic JSON store with corrupt-file backup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
mod launchd;
|
||||
mod lifecycle;
|
||||
mod persist;
|
||||
mod registry;
|
||||
mod server;
|
||||
mod state_store;
|
||||
mod surface;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spacesh_proto::workspace::{Group, Workspace};
|
||||
|
||||
/// The full persisted snapshot of structure (no live processes).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersistState {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<Group>,
|
||||
#[serde(default)]
|
||||
pub workspaces: Vec<Workspace>,
|
||||
}
|
||||
|
||||
pub trait StateStore: Send + Sync {
|
||||
fn load(&self) -> Result<PersistState>;
|
||||
fn save(&self, state: &PersistState) -> Result<()>;
|
||||
}
|
||||
|
||||
/// JSON file store with atomic write (temp + rename) and corrupt-file backup.
|
||||
pub struct JsonStateStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonStateStore {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn backup_corrupt(&self, ts: u128) {
|
||||
let bak = self.path.with_extension(format!("corrupt-{ts}"));
|
||||
let _ = std::fs::rename(&self.path, bak);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateStore for JsonStateStore {
|
||||
fn load(&self) -> Result<PersistState> {
|
||||
if !self.path.exists() {
|
||||
return Ok(PersistState { version: 1, ..Default::default() });
|
||||
}
|
||||
let bytes = std::fs::read(&self.path)?;
|
||||
match serde_json::from_slice::<PersistState>(&bytes) {
|
||||
Ok(state) => Ok(state),
|
||||
Err(_) => {
|
||||
// Corrupt file: back it up and start fresh.
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
self.backup_corrupt(ts);
|
||||
Ok(PersistState { version: 1, ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self, state: &PersistState) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let bytes = serde_json::to_vec_pretty(state)?;
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
// fsync the temp file before rename for durability.
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn touch(_p: &Path) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use spacesh_proto::ids::WorkspaceId;
|
||||
|
||||
fn tmp_file(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("spacesh-state-{name}-{n}.json"));
|
||||
p
|
||||
}
|
||||
|
||||
fn sample() -> PersistState {
|
||||
PersistState {
|
||||
version: 1,
|
||||
groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_1".into()),
|
||||
path: "/tmp/p".into(),
|
||||
name: "p".into(),
|
||||
group_id: None,
|
||||
order: 0,
|
||||
unread: false,
|
||||
layout: None,
|
||||
surfaces: std::collections::HashMap::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let path = tmp_file("roundtrip");
|
||||
let store = JsonStateStore::new(path.clone());
|
||||
store.save(&sample()).unwrap();
|
||||
let back = store.load().unwrap();
|
||||
assert_eq!(back, sample());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_loads_default() {
|
||||
let store = JsonStateStore::new(tmp_file("missing"));
|
||||
let s = store.load().unwrap();
|
||||
assert_eq!(s.version, 1);
|
||||
assert!(s.workspaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_backed_up_and_load_returns_default() {
|
||||
let path = tmp_file("corrupt");
|
||||
std::fs::write(&path, b"{ this is not valid json").unwrap();
|
||||
let store = JsonStateStore::new(path.clone());
|
||||
let s = store.load().unwrap();
|
||||
assert!(s.workspaces.is_empty());
|
||||
// original path no longer holds the corrupt bytes (renamed away)
|
||||
assert!(!path.exists());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user