Never lose work: atomic saves (temporary file, flush, rename) keeping the replaced version as <file>.bak; autosave of unsaved changes into a recovery folder (every 5 min, preference autosave_minutes, and after commands over 1.5 s; skipped when unchanged; entries locked while their app runs); Recover unsaved design? at launch; doc.recovery_list / doc.recover / doc.recovery_discard; kill-mid-save test

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 01b349842c
commit f40356a74f
11 files changed
+679 -1

No files matched your search

+24
View File
@@ -55,6 +55,8 @@ fn main() -> ExitCode {
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -217,3 +219,25 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
let stdout = std::io::stdout();
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
name: format!("p{i}"),
expr: format!("{i} mm + 0.5 mm"),
unit: "mm".into(),
comment: "filler to make the file large".into(),
model: false,
});
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -0,0 +1,45 @@
//! Kill the process while it saves: the design file is always the old version or the new one,
//! whole, and opens.
use std::process::Command;
use std::time::Duration;
fn round_of(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
}
#[test]
fn killed_mid_save_the_old_file_survives() {
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solvecraft");
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
assert!(ok.success());
assert_eq!(round_of(&path).as_deref(), Some("0"));
let mut kills = 0;
let mut seen = std::collections::BTreeSet::new();
for i in 0..25u64 {
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
// Let it get into its save loop (building the design takes a moment), then kill it.
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
kills += 1;
}
let _ = child.wait();
let round = round_of(&path);
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
seen.insert(round);
let bak = solvecraft_engine::io::backup_path(&path);
if bak.exists() {
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
}
}
assert!(kills > 20, "the saver was killed while running ({kills})");
assert!(seen.len() > 5, "the saver got to saving before it was killed ({seen:?})");
let _ = std::fs::remove_dir_all(&dir);
}