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 launchd;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod persist;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod state_store;
|
||||||
mod surface;
|
mod surface;
|
||||||
|
|
||||||
use anyhow::Result;
|
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