From 4cbbb96b579511e1cda92cc5258bb492a1730258 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 12:40:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(world):=20crash-safe=20saves=20=E2=80=94?= =?UTF-8?q?=20verify-readback=20+=20.bak=20snapshot=20before=20touching=20?= =?UTF-8?q?the=20real=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A save is a user's level; half-written is unacceptable. No rename FFI exists, so instead of atomic replace: write .tmp, READ IT BACK and byte-compare (the check that catches the historical 0-bytes-reported-ok writeFile failure), snapshot the current file to .bak, then write . A crash at any point leaves a verified copy in .tmp or the previous version in .bak. Applies to saveWorld and savePrefab. Verified by the editor's ui-smoke script: save produces the siblings and the saved file parses with the expected entity count. Claude-Session: https://claude.ai/code/session_01PmL9WgNMkAgvpYSHEZga8K --- src/world/saver.ts | 56 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/world/saver.ts b/src/world/saver.ts index b778b59..31117c2 100644 --- a/src/world/saver.ts +++ b/src/world/saver.ts @@ -5,8 +5,23 @@ // The saver always validates before writing so the editor can't produce // corrupt files. On validation failure, returns false and populates the // `errors` array; on success, writes the file and returns true. +// +// CRASH SAFETY (2026-07-17): a save is a user's level — the one file that +// must never be half-written. There is no rename FFI, so true atomic replace +// isn't available; instead every save goes through `safeWrite`: +// +// 1. write `.tmp` and READ IT BACK — a byte mismatch fails the save +// before the real file is touched (this is the check that would have +// caught the era when writeFile wrote 0 bytes and reported success); +// 2. snapshot the current `` to `.bak`; +// 3. write ``. +// +// A crash at any point leaves a good copy in `.bak` (the previous save) or +// `.tmp` (the new one, already verified) — losing data requires the disk to +// fail three times in a row. The `.tmp`/`.bak` siblings are cheap litter; +// gitignore them in game repos. -import { writeFile } from '../core/index'; +import { readFile, writeFile, fileExists } from '../core/index'; import { WORLD_SCHEMA_VERSION, WorldData, PrefabData } from './types'; import { validateWorld, validatePrefab, formatValidationErrors } from './validate'; import { serializeWorld, serializePrefab } from './serialize'; @@ -16,6 +31,33 @@ export interface SaveResult { errors: string[]; } +// Verified, backup-keeping write. Returns an error string, or null on success. +function safeWrite(path: string, json: string): string | null { + const tmpPath = path + '.tmp'; + if (!writeFile(tmpPath, json)) { + return 'could not write ' + tmpPath; + } + const readBack = readFile(tmpPath); + if (readBack !== json) { + return 'readback mismatch on ' + tmpPath + ' (wrote ' + json.length + + ' chars, read ' + (readBack ? readBack.length : 0) + ') — disk or FFI fault, original untouched'; + } + + if (fileExists(path)) { + const prev = readFile(path); + if (prev && prev.length > 0) { + // Best-effort: a failed backup shouldn't block the save itself. + writeFile(path + '.bak', prev); + } + } + + if (!writeFile(path, json)) { + return 'could not write ' + path + ' — previous version is in ' + path + + '.bak, the new one in ' + tmpPath; + } + return null; +} + // Write a world file. On success, returns `{ ok: true, errors: [] }`. // On validation failure, returns the errors without touching the filesystem. // On write failure (disk full, permissions), returns `{ ok: false, errors: [...] }`. @@ -34,9 +76,9 @@ export function saveWorld(path: string, world: WorldData): SaveResult { // and writeFile then wrote a ZERO-BYTE FILE AND RETURNED SUCCESS. Saving a world // destroyed it, silently. const json = serializeWorld(world); - const ok = writeFile(path, json); - if (!ok) { - return { ok: false, errors: ['writeFile failed for path: ' + path] }; + const err = safeWrite(path, json); + if (err !== null) { + return { ok: false, errors: ['saveWorld: ' + err] }; } return { ok: true, errors: [] }; } @@ -51,9 +93,9 @@ export function savePrefab(path: string, prefab: PrefabData): SaveResult { } const json = serializePrefab(prefab); - const ok = writeFile(path, json); - if (!ok) { - return { ok: false, errors: ['writeFile failed for path: ' + path] }; + const err = safeWrite(path, json); + if (err !== null) { + return { ok: false, errors: ['savePrefab: ' + err] }; } return { ok: true, errors: [] }; }