From d7f2a021bbfb42e7d3ad46d70b4941452e9e1b9f Mon Sep 17 00:00:00 2001 From: Aniruddha Adak Date: Tue, 10 Nov 2026 13:30:34 +0530 Subject: [PATCH] fix(core): fsync atomic writes and keep an unreadable video state recoverable --- packages/core/src/atomicFile.test.ts | 179 ++++++++++++++---- packages/core/src/atomicFile.ts | 66 ++++++- packages/studio-server/src/history/index.ts | 1 + .../src/history/videoState.test.ts | 45 ++++- .../studio-server/src/history/videoState.ts | 119 ++++++++++-- 5 files changed, 353 insertions(+), 57 deletions(-) diff --git a/packages/core/src/atomicFile.test.ts b/packages/core/src/atomicFile.test.ts index d5cfb891e05..cdf62467bb8 100644 --- a/packages/core/src/atomicFile.test.ts +++ b/packages/core/src/atomicFile.test.ts @@ -25,6 +25,60 @@ function expectTempSiblingOf(tempPath: string, file: string): void { expect(tempPath.slice(file.length)).toMatch(/^\.hf[0-9a-f]{6}\.tmp$/); } +/** A directory cannot be opened on Windows, so its descriptor is synthetic and never reaches the disk. */ +const DIRECTORY_FD = -1; + +function withPlatform(platform: NodeJS.Platform, run: () => void): void { + const actual = process.platform; + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + try { + run(); + } finally { + Object.defineProperty(process, "platform", { value: actual, configurable: true }); + } +} + +/** + * Filesystem operations that record what an atomic write asks for, in order. The directory open is + * recorded without touching the disk, so the same test can prove the POSIX order while running on Windows. + */ +function recordingOperations(dir: string) { + const events: string[] = []; + const openedFiles: string[] = []; + let fileFd = -1; + const operations = { + openSync: (path: fs.PathLike, flags: string | number, mode?: number) => { + if (String(path) === dir) { + events.push(`open-directory:${String(path)}`); + return DIRECTORY_FD; + } + openedFiles.push(String(path)); + fileFd = fs.openSync(path, flags, mode); + events.push(`open:${String(path)}`); + return fileFd; + }, + writeFileSync: (fd: fs.PathOrFileDescriptor, ...args: any[]) => { + events.push(`write:${String(fd)}`); + return fs.writeFileSync(fd, ...args); + }, + fsyncSync: (fd: number) => { + events.push(`fsync:${fd}`); + if (fd !== DIRECTORY_FD) fs.fsyncSync(fd); + }, + closeSync: (fd: number) => { + events.push(`close:${fd}`); + if (fd !== DIRECTORY_FD) fs.closeSync(fd); + }, + chmodSync: fs.chmodSync, + renameSync: (from: fs.PathLike, to: fs.PathLike) => { + events.push(`rename:${String(from)}:${String(to)}`); + return fs.renameSync(from, to); + }, + unlinkSync: fs.unlinkSync, + }; + return { operations, events, opened: () => openedFiles, tempFd: () => fileFd }; +} + describe("isAtomicTempPath", () => { it("knows the temp files it names, and no one else's", () => { expect(isAtomicTempPath(atomicTempPath("/p/index.html"))).toBe(true); @@ -60,38 +114,62 @@ describe("replaceFileAtomically", () => { }, ); - it("writes the sibling completely before replacing the project file", () => { + it("writes and syncs the sibling, renames it, then syncs the folder", () => { const dir = mkdtempSync(join(tmpdir(), "atomic-file-test-")); dirs.push(dir); const file = join(dir, "index.html"); writeFileSync(file, "old", { mode: 0o640 }); - const events: string[] = []; - const operations = { - writeFileSync: (path: fs.PathLike, ...args: any[]) => { - events.push(`write:${String(path)}`); - return fs.writeFileSync(path, ...args); - }, - chmodSync: fs.chmodSync, - renameSync: (from: fs.PathLike, to: fs.PathLike) => { - events.push(`rename:${String(from)}:${String(to)}`); - expect(readFileSync(from, "utf-8")).toBe("new complete html"); - return fs.renameSync(from, to); - }, - unlinkSync: fs.unlinkSync, - }; + const { operations, events, tempFd } = recordingOperations(dir); - replaceFileAtomically(file, "new complete html", 0o640, operations); + withPlatform("linux", () => + replaceFileAtomically(file, "new complete html", 0o640, operations), + ); - expect(events).toHaveLength(2); - expect(events[0]!.startsWith("write:")).toBe(true); - const tempPath = events[0]!.slice("write:".length); + const tempPath = events[0]!.slice("open:".length); expectTempSiblingOf(tempPath, file); - expect(events[1]).toBe(`rename:${tempPath}:${file}`); + expect(events).toEqual([ + `open:${tempPath}`, + `write:${tempFd()}`, + `fsync:${tempFd()}`, + `close:${tempFd()}`, + `rename:${tempPath}:${file}`, + `open-directory:${dir}`, + `fsync:${DIRECTORY_FD}`, + `close:${DIRECTORY_FD}`, + ]); expect(readFileSync(file, "utf-8")).toBe("new complete html"); // Windows keeps only a read-only bit, no group or other permissions. if (process.platform !== "win32") expect(fs.statSync(file).mode & 0o777).toBe(0o640); }); + it("does not open the folder on Windows, where a directory cannot be fsynced", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-file-win32-test-")); + dirs.push(dir); + const file = join(dir, "index.html"); + const { operations, opened } = recordingOperations(dir); + + withPlatform("win32", () => replaceFileAtomically(file, "new", 0o644, operations)); + + expect(opened()).toHaveLength(1); + expectTempSiblingOf(opened()[0]!, file); + expect(readFileSync(file, "utf-8")).toBe("new"); + }); + + it("removes the temp file when the disk will not confirm its bytes", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-file-fsync-failure-test-")); + dirs.push(dir); + const file = join(dir, "index.html"); + writeFileSync(file, "old"); + const { operations } = recordingOperations(dir); + operations.fsyncSync = () => { + throw Object.assign(new Error("EIO"), { code: "EIO" }); + }; + + expect(() => replaceFileAtomically(file, "new", 0o644, operations)).toThrow("EIO"); + expect(readFileSync(file, "utf-8")).toBe("old"); + expect(fs.readdirSync(dir)).toEqual(["index.html"]); + }); + it("replaces an existing destination", () => { const dir = mkdtempSync(join(tmpdir(), "atomic-file-replace-test-")); dirs.push(dir); @@ -110,10 +188,13 @@ describe("replaceFileAtomically", () => { writeFileSync(file, "old"); const tempPaths: string[] = []; const operations = { - writeFileSync: (path: fs.PathLike, ...args: any[]) => { + openSync: (path: fs.PathLike, flags: string | number, mode?: number) => { tempPaths.push(String(path)); - return fs.writeFileSync(path, ...args); + return fs.openSync(path, flags, mode); }, + writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, + closeSync: fs.closeSync, chmodSync: fs.chmodSync, renameSync: fs.renameSync, unlinkSync: fs.unlinkSync, @@ -134,10 +215,13 @@ describe("replaceFileAtomically", () => { const tempPaths: string[] = []; const removed: string[] = []; const operations = { - writeFileSync: (path: fs.PathLike, ...args: any[]) => { + openSync: (path: fs.PathLike, flags: string | number, mode?: number) => { tempPaths.push(String(path)); - return fs.writeFileSync(path, ...args); + return fs.openSync(path, flags, mode); }, + writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, + closeSync: fs.closeSync, chmodSync: fs.chmodSync, renameSync: () => { throw new Error("publish failed"); @@ -183,12 +267,13 @@ describe("createFileAtomically", () => { const dir = tempDir(); const file = join(dir, "index.html"); const operations = { - writeFileSync: (path: fs.PathLike, ...args: any[]) => { + writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, + chmodSync: fs.chmodSync, + openSync: (path: fs.PathLike, flags: string | number, mode?: number) => { expect(String(path)).not.toBe(file); - return fs.writeFileSync(path, ...args); + return fs.openSync(path, flags, mode); }, - chmodSync: fs.chmodSync, - openSync: fs.openSync, closeSync: fs.closeSync, linkSync: (from: fs.PathLike, to: fs.PathLike) => { expect(readFileSync(from, "utf-8")).toBe("complete html"); @@ -232,6 +317,7 @@ describe("createFileAtomically", () => { function failingLink(code: string) { return { writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, chmodSync: fs.chmodSync, openSync: fs.openSync, closeSync: fs.closeSync, @@ -255,47 +341,64 @@ describe("createFileAtomically", () => { }, ); + /** The descriptor of the file the direct fallback writes, which is the one only it opens by name. */ + function directDescriptor(file: string) { + let direct = -1; + return { + openSync: (path: fs.PathLike, flags: string | number, mode?: number) => { + const fd = fs.openSync(path, flags, mode); + if (String(path) === file) direct = fd; + return fd; + }, + isDirect: (fd: number) => fd === direct, + }; + } + it("removes the partial file a direct write leaves on a full disk, keeping the error", () => { const dir = tempDir(); const file = join(dir, "index.html"); const closed: number[] = []; + const direct = directDescriptor(file); const operations = { ...failingLink("EXDEV"), + openSync: direct.openSync, writeFileSync: (target: fs.PathOrFileDescriptor, ...args: any[]) => { - if (typeof target !== "number") return fs.writeFileSync(target, ...args); - fs.writeFileSync(target, "ht"); - throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" }); + fs.writeFileSync(target, ...args); + if (typeof target === "number" && direct.isDirect(target)) + throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" }); }, closeSync: (fd: number) => { closed.push(fd); fs.closeSync(fd); - throw Object.assign(new Error("EIO"), { code: "EIO" }); + if (direct.isDirect(fd)) throw Object.assign(new Error("EIO"), { code: "EIO" }); }, }; expect(() => createFileAtomically(file, "html", operations)).toThrow("ENOSPC"); expect(fs.readdirSync(dir)).toEqual([]); - // Unlinking an open file succeeds, so only the count shows the descriptor was not leaked. - expect(closed).toHaveLength(1); + // Unlinking an open file succeeds, so only the count shows both the temp and direct descriptors closed. + expect(closed).toHaveLength(2); }); it("removes the file when a network share reports its quota only at close", () => { const dir = tempDir(); const file = join(dir, "index.html"); const closed: number[] = []; + const direct = directDescriptor(file); const operations = { ...failingLink("EXDEV"), + openSync: direct.openSync, closeSync: (fd: number) => { closed.push(fd); fs.closeSync(fd); - throw Object.assign(new Error("EDQUOT"), { code: "EDQUOT" }); + if (direct.isDirect(fd)) throw Object.assign(new Error("EDQUOT"), { code: "EDQUOT" }); }, }; expect(() => createFileAtomically(file, "html", operations)).toThrow("EDQUOT"); expect(fs.readdirSync(dir)).toEqual([]); // A second close could hit a descriptor another request has reused since. - expect(closed).toHaveLength(1); + expect(closed).toHaveLength(2); }); it("keeps another writer's file when the direct write finds the name taken", () => { @@ -324,6 +427,7 @@ describe("createFileAtomically", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const operations = { writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, chmodSync: fs.chmodSync, openSync: fs.openSync, closeSync: fs.closeSync, @@ -401,7 +505,10 @@ describe("replaceFileAtomically on a busy target", () => { writeFileSync(file, "old"); let attempts = 0; const operations = { + openSync: fs.openSync, writeFileSync: fs.writeFileSync, + fsyncSync: fs.fsyncSync, + closeSync: fs.closeSync, chmodSync: fs.chmodSync, unlinkSync: fs.unlinkSync, renameSync: (from: fs.PathLike, to: fs.PathLike) => { diff --git a/packages/core/src/atomicFile.ts b/packages/core/src/atomicFile.ts index a79103e5448..226afb9d02c 100644 --- a/packages/core/src/atomicFile.ts +++ b/packages/core/src/atomicFile.ts @@ -4,8 +4,10 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { realpath } from "./safePath.js"; import { renameWithRetry } from "./renameWithRetry.js"; -type SiblingFileSystem = Pick; -type DirectFileSystem = SiblingFileSystem & Pick; +type SiblingFileSystem = Pick< + typeof fs, + "openSync" | "writeFileSync" | "fsyncSync" | "closeSync" | "chmodSync" | "unlinkSync" +>; // Codes a volume without hard links (FAT, exFAT, some network shares) answers link() with. // EISDIR: libuv maps Windows ERROR_INVALID_FUNCTION (FAT/exFAT refusing a link) to it; nodejs/node#65817. @@ -58,7 +60,7 @@ export function replaceFileAtomically( export function createFileAtomically( filePath: string, content: string | Uint8Array, - operations: DirectFileSystem & Pick = fs, + operations: SiblingFileSystem & Pick = fs, ): void { publishSibling(filePath, content, undefined, operations, (tempPath) => { try { @@ -79,12 +81,14 @@ export function createFileAtomically( function createDirectly( filePath: string, content: string | Uint8Array, - operations: DirectFileSystem, + operations: SiblingFileSystem, ): void { const fd = operations.openSync(filePath, "wx"); let open = true; try { operations.writeFileSync(fd, content); + // The name is the file here, so its bytes must reach the disk before another run trusts it. + operations.fsyncSync(fd); open = false; operations.closeSync(fd); } catch (error) { @@ -135,6 +139,7 @@ function publishSibling( try { if (mode !== undefined) operations.chmodSync(tempPath, mode); publish(tempPath); + syncParentDirectory(filePath, operations); } catch (error) { try { operations.unlinkSync(tempPath); @@ -155,7 +160,7 @@ function writeTempSibling( for (let attempt = 1; ; attempt++) { const tempPath = atomicTempPath(filePath); try { - operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode, flag: "wx" }); + writeAndSyncTempFile(tempPath, content, mode, operations); return tempPath; } catch (error) { if (errorCode(error) === "EEXIST") { @@ -172,6 +177,57 @@ function writeTempSibling( } } +/** + * Writes the temp file and forces its bytes to the disk, so the rename that publishes the name can never + * outrun the data: a power cut before the rename leaves only an unreferenced temp, never a NUL-filled target. + */ +function writeAndSyncTempFile( + tempPath: string, + content: string | Uint8Array, + mode: number | undefined, + operations: SiblingFileSystem, +): void { + const fd = operations.openSync(tempPath, "wx", mode); + let failure: unknown; + try { + operations.writeFileSync(fd, content, { encoding: "utf-8" }); + operations.fsyncSync(fd); + } catch (error) { + failure = error; + } + try { + operations.closeSync(fd); + } catch (error) { + // A close that reports the disk's quota (EDQUOT) is a failed write; a write error already found is the clearer one. + failure ??= error; + } + if (failure) throw failure; +} + +/** + * Forces the rename itself to the disk. Windows refuses to open a directory for fsync, and some network + * filesystems do too; there the rename stays atomic, it just may not survive a power cut. Best effort + * because the bytes are already published: a failed directory sync must not report the write as failed. + */ +function syncParentDirectory(filePath: string, operations: SiblingFileSystem): void { + if (process.platform === "win32") return; + let fd: number | undefined; + try { + fd = operations.openSync(dirname(filePath), "r"); + operations.fsyncSync(fd); + } catch { + // Not every filesystem supports fsync on a directory. + } finally { + if (fd !== undefined) { + try { + operations.closeSync(fd); + } catch { + // The bytes and the name are on disk; a close error here cannot undo that. + } + } + } +} + function errorCode(error: unknown): string { return String((error as NodeJS.ErrnoException)?.code); } diff --git a/packages/studio-server/src/history/index.ts b/packages/studio-server/src/history/index.ts index 1a4e29370c4..3265e944fe3 100644 --- a/packages/studio-server/src/history/index.ts +++ b/packages/studio-server/src/history/index.ts @@ -47,6 +47,7 @@ export { type ArtifactInput, type ArtifactRecord, type VideoState, + VideoStateCorruptError, recordRenderSuccess, type RenderRecord, } from "./videoState.js"; diff --git a/packages/studio-server/src/history/videoState.test.ts b/packages/studio-server/src/history/videoState.test.ts index 47f3d8e4b08..eea014f201f 100644 --- a/packages/studio-server/src/history/videoState.test.ts +++ b/packages/studio-server/src/history/videoState.test.ts @@ -9,7 +9,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { openProjectHistory, type ProjectHistory } from "./projectHistory.js"; import { recordRenderSuccess, @@ -18,6 +18,7 @@ import { recordArtifact, recordArtifacts, forgetRecordedArtifacts, + VideoStateCorruptError, type VideoState, } from "./videoState.js"; @@ -216,17 +217,53 @@ describe("video state", () => { expect(state.snapshots.latest).toBeNull(); }); - it("repairs malformed derived state without losing committed history", async () => { + it("restores a damaged state from the backup instead of starting over", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const f = await fixture(); const revision = await f.edit("first"); - writeFileSync(join(f.projectDir, ".hyperframes/video-state.json"), "{bad"); + const statePath = join(f.projectDir, ".hyperframes/video-state.json"); + writeFileSync(statePath, "{bad"); await f.history.flush(); expect(read(f.projectDir).currentRevision).toBe(revision); + const aside = readdirSync(join(f.projectDir, ".hyperframes")).find((name) => + name.startsWith("video-state.json.invalid-"), + ); + expect(aside).toBeDefined(); + expect(readFileSync(join(f.projectDir, ".hyperframes", aside!), "utf8")).toBe("{bad"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("restored the last published state")); + warn.mockRestore(); + }); + + it("keeps a damaged state and reports it when no backup can be restored", async () => { + const errors: unknown[] = []; + const f = await fixture({ onError: (error: unknown) => errors.push(error) }); + await f.edit("first"); + const statePath = join(f.projectDir, ".hyperframes/video-state.json"); + rmSync(join(f.projectDir, ".hyperframes/video-state.json.bak")); + writeFileSync(statePath, '{"version":1,"historyId":'); + await f.history.flush(); + expect(readFileSync(statePath, "utf8")).toBe('{"version":1,"historyId":'); + const failure = errors.find((error) => error instanceof VideoStateCorruptError); + expect(failure).toBeInstanceOf(VideoStateCorruptError); + expect((failure as VideoStateCorruptError).message).toContain(statePath); + expect((failure as VideoStateCorruptError).message).toContain("delete it"); expect( readdirSync(join(f.projectDir, ".hyperframes")).some((name) => name.startsWith("video-state.json.invalid-"), ), - ).toBe(true); + ).toBe(false); + }); + + it("rejects a write over a damaged state that has no backup", async () => { + const f = await fixture(); + await f.edit("first"); + const statePath = join(f.projectDir, ".hyperframes/video-state.json"); + rmSync(join(f.projectDir, ".hyperframes/video-state.json.bak")); + writeFileSync(statePath, "NUL".repeat(64)); + await expect( + forgetRecordedArtifacts(f.projectDir, [join(f.projectDir, "gone.png")]), + ).rejects.toThrow(VideoStateCorruptError); + expect(readFileSync(statePath, "utf8")).toBe("NUL".repeat(64)); }); it("rejects a state file symlink outside the project", async () => { diff --git a/packages/studio-server/src/history/videoState.ts b/packages/studio-server/src/history/videoState.ts index 2c5f7359ce4..98e19d1c39b 100644 --- a/packages/studio-server/src/history/videoState.ts +++ b/packages/studio-server/src/history/videoState.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { createReadStream, existsSync, readFileSync, renameSync, statSync } from "node:fs"; +import { copyFileSync, createReadStream, existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { replaceFileAtomically } from "@hyperframes/core/atomic-file"; import { pinProjectRecord } from "../helpers/safePath.js"; @@ -85,10 +85,40 @@ export interface VideoState { } function statePath(dir: string): string { - const path = pinProjectRecord(dir, ".hyperframes/video-state.json"); - if (!path) throw new Error("Video state must stay inside the project."); + return projectRecordPath(dir, ".hyperframes/video-state.json", "Video state"); +} + +function backupPath(dir: string): string { + return projectRecordPath(dir, ".hyperframes/video-state.json.bak", "Video state backup"); +} + +function projectRecordPath(dir: string, record: string, label: string): string { + const path = pinProjectRecord(dir, record); + if (!path) throw new Error(`${label} must stay inside the project.`); return path; } + +/** + * The state file exists but cannot be understood. It is never replaced with an empty state: doing that + * loses every check, snapshot, revision and file record the production has. + */ +export class VideoStateCorruptError extends Error { + constructor( + readonly statePath: string, + readonly backupPath: string, + readonly detail: string, + options?: ErrorOptions, + ) { + super( + `The project's video state is unreadable (${detail}).\n` + + `It was not reset, because starting over would lose its checks, snapshots, revisions and file records.\n` + + `Restore ${statePath} from a backup or version control, or delete it to start a fresh history.\n` + + `The last state this version published is copied to ${backupPath}.`, + options, + ); + this.name = "VideoStateCorruptError"; + } +} const collection = (): ArtifactCollection => ({ latest: null, revisions: {}, unversioned: [] }); const emptyState = (): VideoState => ({ version: 1, @@ -102,11 +132,37 @@ const emptyState = (): VideoState => ({ }); function readState(dir: string): VideoState { - if (!existsSync(statePath(dir))) return emptyState(); - const state: VideoState = JSON.parse(readFileSync(statePath(dir), "utf8")); - if (state.version !== 1 || !state.checks || !state.snapshots || !state.revisions || !state.files) - throw new Error("Unsupported video-state schema."); - return state; + const path = statePath(dir); + if (!existsSync(path)) return emptyState(); + return parseState(dir, path, readFileSync(path, "utf8")); +} + +function parseState(dir: string, path: string, text: string): VideoState { + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw corruptState(dir, path, "not valid JSON", error); + } + if (!isVideoState(value)) throw corruptState(dir, path, "unexpected schema or version"); + return value; +} + +function corruptState( + dir: string, + path: string, + detail: string, + cause?: unknown, +): VideoStateCorruptError { + return new VideoStateCorruptError(path, backupPath(dir), detail, { cause }); +} + +function isVideoState(value: unknown): value is VideoState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return Boolean( + state.version === 1 && state.checks && state.snapshots && state.revisions && state.files, + ); } async function updateState(dir: string, update: (state: VideoState) => void): Promise { @@ -118,17 +174,56 @@ async function updateState(dir: string, update: (state: VideoState) => void): Pr try { state = readState(dir); } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - renameSync(statePath(dir), `${statePath(dir)}.invalid-${randomUUID()}`); - state = emptyState(); + if (!(error instanceof VideoStateCorruptError)) throw error; + state = recoverState(dir, error); } update(state); - replaceFileAtomically(statePath(dir), JSON.stringify(state, null, 2) + "\n", 0o644); + publishState(dir, state); } finally { release(); } } +/** + * Puts the last published state back over a damaged one, keeping the damaged bytes beside it. Without a + * readable backup the update fails and nothing is rewritten, so the records are still there to recover. + */ +function recoverState(dir: string, error: VideoStateCorruptError): VideoState { + let recovered: VideoState; + try { + recovered = parseState(dir, error.backupPath, readFileSync(error.backupPath, "utf8")); + } catch (backupError) { + throw corruptState( + dir, + error.statePath, + `${error.detail}, and no readable backup could be restored`, + backupError, + ); + } + const aside = `${error.statePath}.invalid-${randomUUID()}`; + copyFileSync(error.statePath, aside); + replaceFileAtomically(error.statePath, readFileSync(error.backupPath), 0o644); + console.warn( + `[hyperframes] video-state.json was unreadable (${error.detail}); restored the last published state. ` + + `The damaged file is kept at ${aside}.`, + ); + return recovered; +} + +/** + * The backup is new: it is written after the state it copies, so a power cut during the state write leaves + * the previous file as the backup. A failed backup write never fails the update that already succeeded. + */ +function publishState(dir: string, state: VideoState): void { + const path = statePath(dir); + replaceFileAtomically(path, JSON.stringify(state, null, 2) + "\n", 0o644); + try { + copyFileSync(path, backupPath(dir)); + } catch { + // Best effort: the state itself is already durable; only the ability to recover it is lost. + } +} + export async function writeHistoryState( projectDir: string, historyId: string,