The control channel read each request with no length cap, so a client sending gigabytes without a newline made the server thread allocate until the process was killed. Read lines against the 16 MiB cap the MCP server already uses, now one shared constant, answer a longer one with an error and close; close a connection after eight lines that are not JSON, serve at most sixteen connections at once, and run at most sixty-four queued requests per frame so a flood cannot starve drawing. Every file command checked the size with the metadata and then read to the end. A character device such as /dev/zero reports a length of 0, so the cap passed and the read was unbounded; a FIFO blocked the calling thread, which over the control channel is the thread drawing the window. vfs::read now takes the caller's cap, refuses anything that is not a regular file before opening it, re-checks on the handle to close the window between the two, and reads no more than the cap plus the byte that proves it was exceeded. Callers pass the limits they already had, and the recovery read and the CLI's own reads go through it too. `solvecraft-cli eval /dev/zero` and a FIFO now fail at once instead of never returning. Fixes #147 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 4156e8acd11c90c435a54a3b0ccc59991db71b12)
270 lines
11 KiB
Rust
270 lines
11 KiB
Rust
//! Autosave and crash recovery.
|
|
//!
|
|
//! A running app keeps one recovery entry in the recovery folder: `<id>.solvecraft` (the design
|
|
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
|
|
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
|
|
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
|
|
//! entry whose lock nobody holds belongs to an app that crashed (or closed with unsaved changes):
|
|
//! the next launch offers to recover it.
|
|
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use solvecraft_doc::Document;
|
|
|
|
use crate::{EngineError, Result, Session};
|
|
|
|
/// The recovery folder: `SOLVECRAFT_RECOVERY_DIR`, else the platform's per-user data folder.
|
|
pub fn default_dir() -> Option<PathBuf> {
|
|
// Tests never touch the user's real recovery folder.
|
|
if cfg!(test) {
|
|
return Some(std::env::temp_dir().join(format!("solvecraft-test-recovery-{}", std::process::id())));
|
|
}
|
|
if let Some(d) = std::env::var_os("SOLVECRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) {
|
|
return Some(PathBuf::from(d));
|
|
}
|
|
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
|
|
let base = if cfg!(windows) {
|
|
env("LOCALAPPDATA")?.join("SolveCraft")
|
|
} else if cfg!(target_os = "macos") {
|
|
env("HOME")?.join("Library/Application Support/SolveCraft")
|
|
} else {
|
|
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?.join("solvecraft")
|
|
};
|
|
Some(base.join("recovery"))
|
|
}
|
|
|
|
/// What an entry's `<id>.json` holds.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Entry {
|
|
pub id: String,
|
|
/// The design's file, if it had one.
|
|
pub path: Option<String>,
|
|
pub name: String,
|
|
/// When it was autosaved (seconds since 1970).
|
|
pub saved_at: u64,
|
|
pub features: usize,
|
|
}
|
|
|
|
fn unix_now() -> u64 {
|
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
|
}
|
|
|
|
fn valid_id(id: &str) -> bool {
|
|
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
|
}
|
|
|
|
fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
|
|
dir.join(format!("{id}.{ext}"))
|
|
}
|
|
|
|
/// Entries left by apps that are no longer running, newest first.
|
|
pub fn orphans(dir: &Path) -> Vec<Entry> {
|
|
let mut out: Vec<Entry> = fs::read_dir(dir)
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.filter_map(|e| {
|
|
let p = e.path();
|
|
if p.extension().and_then(|x| x.to_str()) != Some("json") {
|
|
return None;
|
|
}
|
|
let entry: Entry = serde_json::from_slice(&fs::read(&p).ok()?).ok()?;
|
|
if !valid_id(&entry.id) || !file(dir, &entry.id, "solvecraft").is_file() {
|
|
return None;
|
|
}
|
|
// Held by a running app: not an orphan.
|
|
let lock = File::open(file(dir, &entry.id, "lock"));
|
|
if let Ok(l) = &lock
|
|
&& l.try_lock().is_err()
|
|
{
|
|
return None;
|
|
}
|
|
Some(entry)
|
|
})
|
|
.take(1000)
|
|
.collect();
|
|
out.sort_by_key(|e| std::cmp::Reverse(e.saved_at));
|
|
out
|
|
}
|
|
|
|
/// Read an entry's design.
|
|
pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> {
|
|
if !valid_id(id) {
|
|
return Err(EngineError::Other(format!("no recovered design `{id}`")));
|
|
}
|
|
let entry: Entry = fs::read(file(dir, id, "json"))
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.ok_or_else(|| EngineError::Other(format!("no recovered design `{id}`")))?;
|
|
let path = file(dir, id, "solvecraft");
|
|
let bytes = solvecraft_io::vfs::read(&path.to_string_lossy(), solvecraft_io::MAX_DESIGN_BYTES)
|
|
.map_err(|e| EngineError::Other(format!("recovered design `{id}`: {e}")))?;
|
|
Ok((solvecraft_io::read_design(&bytes)?, entry))
|
|
}
|
|
|
|
/// Delete an entry.
|
|
pub fn remove(dir: &Path, id: &str) {
|
|
if valid_id(id) {
|
|
for ext in ["json", "solvecraft", "lock"] {
|
|
let _ = fs::remove_file(file(dir, id, ext));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One app's autosaver.
|
|
pub struct Autosaver {
|
|
pub dir: PathBuf,
|
|
pub id: String,
|
|
/// Autosave this often while there are unsaved changes.
|
|
pub interval: Duration,
|
|
lock: Option<File>,
|
|
last: Option<Arc<Document>>,
|
|
last_at: Instant,
|
|
has_entry: bool,
|
|
}
|
|
|
|
impl Autosaver {
|
|
/// Start autosaving into `dir` (created if needed), holding this app's lock.
|
|
pub fn new(dir: &Path, interval: Duration) -> Result<Autosaver> {
|
|
let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display()));
|
|
fs::create_dir_all(dir).map_err(io)?;
|
|
// Locks of apps that ended with nothing to recover.
|
|
for e in fs::read_dir(dir).into_iter().flatten().flatten() {
|
|
let p = e.path();
|
|
if p.extension().and_then(|x| x.to_str()) == Some("lock")
|
|
&& !p.with_extension("json").exists()
|
|
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
|
|
{
|
|
let _ = fs::remove_file(&p);
|
|
}
|
|
}
|
|
// Unique within the process too: an app started again in the same second (tests that
|
|
// "crash" and restart) must not take over the crashed app's entry.
|
|
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
|
|
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
|
|
lock.lock().map_err(io)?;
|
|
Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false })
|
|
}
|
|
|
|
/// Autosave now if the design has unsaved changes not written yet (and drop the entry when
|
|
/// nothing is unsaved). Returns whether it wrote.
|
|
pub fn save(&mut self, s: &Session) -> Result<bool> {
|
|
self.last_at = Instant::now();
|
|
if !s.is_dirty() {
|
|
if self.has_entry {
|
|
for ext in ["json", "solvecraft"] {
|
|
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
|
}
|
|
self.has_entry = false;
|
|
}
|
|
self.last = None;
|
|
return Ok(false);
|
|
}
|
|
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
|
|
return Ok(false);
|
|
}
|
|
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
|
|
solvecraft_io::write_atomic(&file(&self.dir, &self.id, "solvecraft"), &solvecraft_io::write_design(&s.doc), false).map_err(io)?;
|
|
let entry =
|
|
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
|
|
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
solvecraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
|
|
self.last = Some(s.doc.clone());
|
|
self.has_entry = true;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Autosave when the interval has passed, or right away after a big operation.
|
|
pub fn tick(&mut self, s: &Session, big: bool) -> Result<bool> {
|
|
if big || self.last_at.elapsed() >= self.interval { self.save(s) } else { Ok(false) }
|
|
}
|
|
|
|
/// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next
|
|
/// launch to offer. The lock is released either way.
|
|
pub fn close(mut self, s: &Session) {
|
|
let _ = self.save(s);
|
|
if !self.has_entry {
|
|
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
|
|
}
|
|
self.lock = None;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("solvecraft-recovery-{tag}-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&d);
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn autosave_only_unsaved_changes_and_recover_after_a_crash() {
|
|
let d = dir("crash");
|
|
let mut s = Session::default();
|
|
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
|
assert!(!a.save(&s).unwrap(), "nothing unsaved");
|
|
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
|
assert!(!a.tick(&s, false).unwrap(), "not due yet");
|
|
assert!(a.tick(&s, true).unwrap(), "after a big operation");
|
|
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
|
|
// Our own entry is locked: not offered.
|
|
assert!(orphans(&d).is_empty());
|
|
// The app dies without closing (the lock goes with the process).
|
|
drop(a);
|
|
let found = orphans(&d);
|
|
assert_eq!(found.len(), 1);
|
|
let (doc, entry) = load(&d, &found[0].id).unwrap();
|
|
assert_eq!(doc, *s.doc);
|
|
assert_eq!(entry.features, 1);
|
|
remove(&d, &entry.id);
|
|
assert!(orphans(&d).is_empty());
|
|
let _ = fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn an_app_restarted_in_the_same_second_still_offers_the_crashed_ones_work() {
|
|
let d = dir("restart");
|
|
let mut s = Session::default();
|
|
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
|
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
|
assert!(a.save(&s).unwrap());
|
|
drop(a);
|
|
let b = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
|
let found = orphans(&d);
|
|
assert_eq!(found.len(), 1, "the crashed app's entry is offered");
|
|
assert_ne!(found[0].id, b.id);
|
|
let _ = fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
|
|
let d = dir("clean");
|
|
let mut s = Session::default();
|
|
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
|
|
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
|
assert!(a.tick(&s, false).unwrap());
|
|
s.mark_saved();
|
|
a.tick(&s, false).unwrap();
|
|
assert!(!file(&d, &a.id, "solvecraft").exists());
|
|
a.close(&s);
|
|
assert_eq!(fs::read_dir(&d).unwrap().count(), 0);
|
|
// Closing with unsaved changes keeps them for the next launch.
|
|
let b = Autosaver::new(&d, Duration::ZERO).unwrap();
|
|
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
|
|
b.close(&s);
|
|
assert_eq!(orphans(&d).len(), 1);
|
|
assert!(load(&d, "../etc").is_err());
|
|
let _ = fs::remove_dir_all(&d);
|
|
}
|
|
}
|