feat(daemon): per-surface JSON snapshot store (atomic write, corrupt-tolerant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:33:26 +07:00
parent bb5edb941c
commit bd36a83db2
2 changed files with 124 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ mod lifecycle;
mod persist;
mod registry;
mod server;
mod snapshot_store;
mod state_store;
mod surface;
+123
View File
@@ -0,0 +1,123 @@
use std::path::PathBuf;
use spacesh_core::snapshot::Snapshot;
use spacesh_proto::SurfaceId;
/// Stores one visible-screen snapshot per surface as `<dir>/<surface_id>.json`.
pub trait SnapshotStore: Send + Sync {
fn save(&self, sid: &SurfaceId, snap: &Snapshot);
fn load(&self, sid: &SurfaceId) -> Option<Snapshot>;
fn remove(&self, sid: &SurfaceId);
}
/// Writer command: persist or delete a surface's snapshot. Shared by the
/// router ticker, the close/remove paths, and each actor's on-exit dump, so a
/// single channel type flows everywhere.
pub enum SnapshotMsg {
Save(SurfaceId, Snapshot),
Remove(SurfaceId),
}
/// A no-op store for tests and contexts that do not persist snapshots.
pub struct NullSnapshotStore;
impl SnapshotStore for NullSnapshotStore {
fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None }
fn remove(&self, _sid: &SurfaceId) {}
}
/// JSON file store. Filenames are the surface id (e.g. `s_1f.json`); ids are
/// `^[a-z]_[0-9a-f]+$` so they are always safe path components.
pub struct JsonSnapshotStore {
dir: PathBuf,
}
impl JsonSnapshotStore {
pub fn new(dir: PathBuf) -> Self {
let _ = std::fs::create_dir_all(&dir);
Self { dir }
}
fn path(&self, sid: &SurfaceId) -> PathBuf {
self.dir.join(format!("{}.json", sid.0))
}
}
impl SnapshotStore for JsonSnapshotStore {
fn save(&self, sid: &SurfaceId, snap: &Snapshot) {
let path = self.path(sid);
let tmp = path.with_extension("json.tmp");
let Ok(bytes) = serde_json::to_vec(snap) else { return };
if std::fs::write(&tmp, &bytes).is_err() { return; }
if std::fs::File::open(&tmp).and_then(|f| f.sync_all()).is_err() { return; }
let _ = std::fs::rename(&tmp, &path);
}
fn load(&self, sid: &SurfaceId) -> Option<Snapshot> {
let bytes = std::fs::read(self.path(sid)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn remove(&self, sid: &SurfaceId) {
let _ = std::fs::remove_file(self.path(sid));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let p = std::env::temp_dir().join(format!("spacesh-snap-{name}-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
fn sample() -> Snapshot {
Snapshot { ansi: "\u{1b}[mhello".into(), cols: 80, rows: 24, cursor_row: 1, cursor_col: 6 }
}
#[test]
fn save_then_load_round_trips() {
let dir = tmp_dir("roundtrip");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_1".into());
store.save(&sid, &sample());
assert_eq!(store.load(&sid), Some(sample()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_loads_none() {
let store = JsonSnapshotStore::new(tmp_dir("missing"));
assert_eq!(store.load(&SurfaceId("s_none".into())), None);
}
#[test]
fn corrupt_loads_none() {
let dir = tmp_dir("corrupt");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_2".into());
std::fs::write(dir.join("s_2.json"), b"{ not json").unwrap();
assert_eq!(store.load(&sid), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remove_deletes_file() {
let dir = tmp_dir("remove");
let store = JsonSnapshotStore::new(dir.clone());
let sid = SurfaceId("s_3".into());
store.save(&sid, &sample());
assert!(store.load(&sid).is_some());
store.remove(&sid);
assert_eq!(store.load(&sid), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn null_store_is_inert() {
let store = NullSnapshotStore;
let sid = SurfaceId("s_4".into());
store.save(&sid, &sample());
assert_eq!(store.load(&sid), None);
store.remove(&sid);
}
}