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:
@@ -7,6 +7,7 @@ mod lifecycle;
|
|||||||
mod persist;
|
mod persist;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod snapshot_store;
|
||||||
mod state_store;
|
mod state_store;
|
||||||
mod surface;
|
mod surface;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user