From 4f7ed2a5a398e901c55ff55742ebd3e30e577607 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 21:20:51 +0700 Subject: [PATCH] feat(daemon): StateStore trait + atomic JSON store with corrupt-file backup Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/main.rs | 2 + crates/spaceshd/src/state_store.rs | 133 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 crates/spaceshd/src/state_store.rs diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index d26842b..ed7908f 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -1,7 +1,9 @@ mod launchd; mod lifecycle; +mod persist; mod registry; mod server; +mod state_store; mod surface; use anyhow::Result; diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs new file mode 100644 index 0000000..52ce86a --- /dev/null +++ b/crates/spaceshd/src/state_store.rs @@ -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, + #[serde(default)] + pub workspaces: Vec, +} + +pub trait StateStore: Send + Sync { + fn load(&self) -> Result; + 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 { + if !self.path.exists() { + return Ok(PersistState { version: 1, ..Default::default() }); + } + let bytes = std::fs::read(&self.path)?; + match serde_json::from_slice::(&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); + } +}