From 3ce6e4de2030fcecb4900f4a6ad830fef21d8524 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 24 Jul 2026 15:33:19 -0800 Subject: [PATCH] Fsync rotated save backups and the parent directory. write_save_atomically only synced the staging temp, so a crash after bak copy or rename could lose recovery material or the directory entry. Defense: player-contract continuity requires durable prior-save rotation and a durable replace, not only a synced payload. Co-authored-by: Cursor (cherry picked from commit fb8462ecf0b17d8256c269c316e8f00760181be3) --- crates/misaligned-core/src/save.rs | 43 +++++++++++++++++++---- wiki/log/2026-07-24-save-bak-dir-fsync.md | 30 ++++++++++++++++ wiki/mechanics/sim-mechanics.md | 9 ++--- wiki/vision/player-contract.md | 10 +++--- 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 wiki/log/2026-07-24-save-bak-dir-fsync.md diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 2fc09384..e433b125 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::Write as _; +use std::io::{self, Write as _}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -405,8 +405,12 @@ fn sibling_path(path: &Path, suffix: &str) -> PathBuf { /// 1. Serialize to a sibling `.tmp` file in the save directory and fsync it, /// so the rename below moves fully durable bytes. /// 2. If a save already exists, copy it to the single `.bak` generation -/// (copy, not rename: the current save never disappears mid-rotation). -/// 3. Atomically rename the temp file over the target. +/// (copy, not rename: the current save never disappears mid-rotation) +/// and fsync the backup so recovery material is durable before the +/// replace. +/// 3. Atomically rename the temp file over the target, then fsync the +/// parent directory so the directory entry itself reaches stable storage +/// where the platform supports it. /// /// A crash or error at any step leaves the prior save readable at the target /// path or, past step 3, at the `.bak` path. The load path never reads the @@ -436,11 +440,38 @@ fn write_save_atomically(path: &Path, json: &str) -> Result<(), String> { let _ = fs::remove_file(&temp); return Err(format!("Failed to rotate save backup: {e}")); } + if let Err(e) = sync_existing_file(&backup) { + let _ = fs::remove_file(&temp); + return Err(format!("Failed to sync save backup: {e}")); + } } - fs::rename(&temp, path).map_err(|e| { + if let Err(e) = fs::rename(&temp, path) { let _ = fs::remove_file(&temp); - format!("Failed to replace save: {e}") - }) + return Err(format!("Failed to replace save: {e}")); + } + sync_directory_best_effort(dir); + Ok(()) +} + +fn sync_existing_file(path: &Path) -> io::Result<()> { + fs::File::open(path)?.sync_all() +} + +/// Directory fsync is best-effort: some platforms reject it; the file-level +/// syncs above remain the hard durability gate. +fn sync_directory_best_effort(dir: &Path) { + let Ok(file) = fs::File::open(dir) else { + return; + }; + match file.sync_all() { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::Unsupported | io::ErrorKind::InvalidInput + ) => {} + Err(_) => {} + } } /// Pre-release policy (player-contract continuity rider, 2026-07-16): diff --git a/wiki/log/2026-07-24-save-bak-dir-fsync.md b/wiki/log/2026-07-24-save-bak-dir-fsync.md new file mode 100644 index 00000000..1931dd50 --- /dev/null +++ b/wiki/log/2026-07-24-save-bak-dir-fsync.md @@ -0,0 +1,30 @@ +# Sync rotated save backup and parent directory + +``` +Type: log +Date: 2026-07-24 +Scope: wiki/vision/player-contract.md +``` + +## Intent + +Close the queued insecurity that `write_save_atomically` fsynced only the +`.tmp` staging file. + +## Finding + +After rotating the prior save to `.bak`, neither the backup bytes nor the +parent directory entry were synced before returning success — a power loss +could lose recovery material or the rename. + +## Act + +Backup file is fsynced after copy (hard fail). Parent directory is +best-effort fsynced after rename. player-contract and sim-mechanics prose +match. + +## Defense + +player-contract continuity: the save is the player's property. Durability +must cover the recovery generation and the directory entry that names the +current file, not only the staging payload. diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 8f43cff7..1bd09ecd 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -494,10 +494,11 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. - Writes are atomic and non-destructive (2026-07-11, player-contract continuity clause): `save_game` serializes to a sibling temp file that is - synced and atomically renamed over the - target. The save is never truncated in place; a killed or failed write - leaves the prior copy at the save path or its `.bak`. Loading never reads - the `.bak` — it is manual recovery material only. + synced, rotates the prior save to a synced `.bak` by copy, atomically + renames over the target, then best-effort fsyncs the parent directory. + The save is never truncated in place; a killed or failed write leaves the + prior copy at the save path or its `.bak`. Loading never reads the `.bak` + — it is manual recovery material only. ## Authored reservoirs and people verbs diff --git a/wiki/vision/player-contract.md b/wiki/vision/player-contract.md index d158df36..feee15d2 100644 --- a/wiki/vision/player-contract.md +++ b/wiki/vision/player-contract.md @@ -33,10 +33,12 @@ highest-severity tick findings. saving: the game never truncates the existing save in place. Every save write goes to a sibling temp file that is synced and atomically renamed over the target, and the prior save is first rotated (by copy) to exactly - one backup generation (`misaligned_save.txt.bak`). A failed or killed - write — or a junk overwrite — leaves the previous save readable at the - save path or its backup. The backup is manual recovery material only; the - load path never reads it automatically. + one backup generation (`misaligned_save.txt.bak`) that is synced before + the replace; the parent directory is fsynced best-effort after rename so + the directory entry reaches stable storage where the platform allows. + A failed or killed write — or a junk overwrite — leaves the previous save + readable at the save path or its backup. The backup is manual recovery + material only; the load path never reads it automatically. 4. **Performance.** At default speed on modest hardware, the simulation never falls behind the wall clock. Depth may cost content; it may not cost the heartbeat. -- 2.51.2