From 19e4900b316f1cf2832aa040c8511634d10b93b4 Mon Sep 17 00:00:00 2001 From: Matt Marcin Date: Mon, 26 Oct 2026 16:26:04 -0700 Subject: [PATCH 1/7] fix(machines): survive a machines.json left unreadable by a crash After an unclean Windows shutdown, machines.json came back its old length in NUL bytes. The MachineManager constructor parsed it with a bare JSON.parse, so the server exited at startup; the supervisor then took that for a bad release and fell back to the source checkout, which failed on the same file (#2044). - machines.json and herdr-profile-state.json are written with writePrivateState (exclusive temporary file, fsync, rename), since the registrations and approvals cannot be recomputed. - A write happens only when the serialized document differs from the one last read or written: every remote refresh saved soon, mostly with the same snapshot in a new object, so the files were rewritten on the 5 s poll and after pane events. - A file that is not JSON is moved aside at startup, bytes untouched, to .corrupt- (state-document.ts setAsideDamaged), and the app starts without it. Each copy keeps its own name, unlike preferences' single .bad copy, so a second crash cannot replace the first. JSON of the wrong shape still refuses to start, as before. - GET /api/machines/settings reports the moved files as set_aside (optional, read only), and Settings > Remote PCs says what happened and names them. The supervisor is unchanged: it only sees a child that exited or failed its health check, and telling a state failure from a release failure would need a new exit-code contract between them. --- changelog.d/machines-state-crash-recovery.md | 6 ++ server/herdr-profile-state.test.ts | 49 ++++++++++++++- server/herdr-profile-state.ts | 34 +++++++--- server/machines-refresh.test.ts | 41 +++++++++++- server/machines.test.ts | 66 +++++++++++++++++++- server/machines.ts | 41 +++++++++--- server/state-document.test.ts | 39 ++++++++++++ server/state-document.ts | 20 ++++++ shared/machines.ts | 10 ++- src/components/SettingsDialog.tsx | 4 ++ src/lib/i18n/settings.ja.ts | 1 + src/lib/i18n/settings.ko.ts | 1 + src/lib/i18n/settings.zh.ts | 1 + 13 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 changelog.d/machines-state-crash-recovery.md create mode 100644 server/state-document.test.ts diff --git a/changelog.d/machines-state-crash-recovery.md b/changelog.d/machines-state-crash-recovery.md new file mode 100644 index 0000000000..1f32af9d7c --- /dev/null +++ b/changelog.d/machines-state-crash-recovery.md @@ -0,0 +1,6 @@ +### Fixed +- A crash or power loss no longer leaves the app unable to start because its saved remote PCs + cannot be read. The damaged file is kept beside it under a new name, the app starts without + those PCs, and Settings > Remote PCs names the file so you can add them again. The saved PCs + and herdr's machine approvals are now written so that a crash cannot leave them unreadable, + and only when they changed instead of on every refresh. diff --git a/server/herdr-profile-state.test.ts b/server/herdr-profile-state.test.ts index d6e74932f9..b5460f10d5 100644 --- a/server/herdr-profile-state.test.ts +++ b/server/herdr-profile-state.test.ts @@ -1,8 +1,9 @@ -import { afterEach, expect, it } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { afterEach, expect, it, spyOn } from "bun:test"; +import { existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { HerdrProfileState } from "./herdr-profile-state.ts"; +import { privateStateFs } from "./private-state.ts"; import type { SessionSnapshot } from "../shared/protocol.ts"; const dirs: string[] = []; @@ -80,3 +81,47 @@ it("retries a failed key removal when the old target returns, after a restart an readded.flush(); expect(readFileSync(join(dir, "herdr-profile-state.json"), "utf8")).not.toContain("retiring"); }); +it("sets aside approvals that are not JSON, bytes untouched, and starts without them", () => { + const dir = directory(), path = join(dir, "herdr-profile-state.json"); + const zeros = Buffer.alloc(120); + writeFileSync(path, zeros); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + const store = new HerdrProfileState(dir); + expect(store.setAside).toMatch(/herdr-profile-state\.json\.corrupt-[^:]+$/); + expect(readFileSync(store.setAside!)).toEqual(zeros); + expect(existsSync(path)).toBe(false); + expect(store.approved("id", { destination: "BuildBox" })).toBe(false); + store.approve("id", { destination: "BuildBox" }); + store.flush(); + expect(new HerdrProfileState(dir).approved("id", { destination: "BuildBox" })).toBe(true); + } finally { error.mockRestore(); } +}); +it("writes durably, and only when the document changed", () => { + const dir = directory(); + const temporaries: string[] = []; + const open = spyOn(privateStateFs, "openSync").mockImplementation(((path: string, flags: string, mode?: number) => { + if (flags === "wx") temporaries.push(path); + return openSync(path, flags, mode); + }) as typeof openSync); + try { + const store = new HerdrProfileState(dir); + store.approve("id", { destination: "BuildBox" }); + store.saveSnapshot("id", { panes: [{ pane_id: "test-pane" }] } as SessionSnapshot); + store.flush(); + expect(temporaries).toHaveLength(1); + // every refresh brings a new snapshot object: the same panes write nothing + store.saveSnapshot("id", { panes: [{ pane_id: "test-pane" }] } as SessionSnapshot); + store.flush(); + expect(temporaries).toHaveLength(1); + // nor does a store that has just read the same document + const restored = new HerdrProfileState(dir); + restored.saveSnapshot("id", { panes: [{ pane_id: "test-pane" }] } as SessionSnapshot); + restored.flush(); + expect(temporaries).toHaveLength(1); + restored.saveSnapshot("id", { panes: [{ pane_id: "other-pane" }] } as SessionSnapshot); + restored.flush(); + expect(temporaries).toHaveLength(2); + expect(new HerdrProfileState(dir).snapshot("id")?.panes[0]?.pane_id).toBe("other-pane"); + } finally { open.mockRestore(); } +}); diff --git a/server/herdr-profile-state.ts b/server/herdr-profile-state.ts index a287d9870a..0471961d8e 100644 --- a/server/herdr-profile-state.ts +++ b/server/herdr-profile-state.ts @@ -1,8 +1,9 @@ import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { SshTarget } from "../shared/machines.ts"; import type { SessionSnapshot } from "../shared/protocol.ts"; +import { writePrivateState } from "./private-state.ts"; +import { readStateDocument, setAsideDamaged } from "./state-document.ts"; /** `retiring`: this binding's key removal was started and has not succeeded yet. */ interface ProfileState { binding: string; approved: boolean; connectionApproved: boolean; snapshot: SessionSnapshot | null; retiring?: true } @@ -21,10 +22,20 @@ export class HerdrProfileState { private entries = new Map(); private dirty = false; private path: string; - constructor(private directory: string) { + /** the document as last read or written: a flush that would write the same bytes writes nothing */ + private written: string | undefined; + /** where a file that was not JSON was moved at startup (setAsideDamaged), its approvals with it */ + readonly setAside: string | null = null; + constructor(directory: string) { this.path = join(directory, "herdr-profile-state.json"); - if (!existsSync(this.path)) return; - const saved: unknown = JSON.parse(readFileSync(this.path, "utf8")); + const read = readStateDocument(this.path); + if (read.kind === "missing") return; + if (read.kind === "malformed") { + this.setAside = setAsideDamaged(this.path); + console.error(`${this.path} is not JSON; it was set aside as ${this.setAside}, and herdr's saved machines need approving again`); + return; + } + const saved = read.value; if (!Array.isArray(saved)) throw new Error("Invalid herdr-profile-state.json; approvals were preserved"); for (const item of saved) { if (!item || typeof item.profile_id !== "string" || !item.profile_id || this.entries.has(item.profile_id) @@ -34,6 +45,7 @@ export class HerdrProfileState { || (item.snapshot !== null && (!item.snapshot || typeof item.snapshot !== "object" || !Array.isArray(item.snapshot.panes)))) throw new Error("Invalid herdr-profile-state.json; approvals were preserved"); this.entries.set(item.profile_id, { binding: item.binding, approved: item.approved, connectionApproved: item.connectionApproved ?? item.approved, snapshot: item.snapshot, ...(item.retiring ? { retiring: true as const } : {}) }); } + this.written = JSON.stringify(this.document()); } reconcile(id: string, target: SshTarget | null, retire?: () => void): boolean { const next = binding(target); @@ -82,11 +94,17 @@ export class HerdrProfileState { if (errors.length) throw new AggregateError(errors, "Some saved machine keys could not be removed; cleanup will retry"); return removed; } + private document(): object[] { return [...this.entries].map(([profile_id, state]) => ({ profile_id, ...state })); } + /** + * Writes the approvals durably (writePrivateState): a crash must not leave the file unreadable. A + * refresh hands over a new snapshot object each time, so an unchanged one still marks the state + * dirty; only a document that differs from the one on disk pays for the write and its fsync. + */ flush(): void { if (!this.dirty) return; - mkdirSync(this.directory, { recursive: true, mode: 0o700 }); - const tmp = this.path + ".tmp"; - writeFileSync(tmp, JSON.stringify([...this.entries].map(([profile_id, state]) => ({ profile_id, ...state }))), { mode: 0o600 }); - chmodSync(tmp, 0o600); renameSync(tmp, this.path); this.dirty = false; + const document = this.document(); + const text = JSON.stringify(document); + if (text !== this.written) { writePrivateState(this.path, document); this.written = text; } + this.dirty = false; } } diff --git a/server/machines-refresh.test.ts b/server/machines-refresh.test.ts index 8cb898e36d..52612f7eb8 100644 --- a/server/machines-refresh.test.ts +++ b/server/machines-refresh.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +8,7 @@ import type { ServerMessage, SessionSnapshot } from "../shared/protocol.ts"; import { CompletionTracker } from "./completion.ts"; import { handleMachineRequest } from "./machine-api.ts"; import { bridgeAgentNews, MachineManager, SOURCE_CHECK_INTERVAL_MS } from "./machines.ts"; +import { privateStateFs } from "./private-state.ts"; import type { PushService } from "./push.ts"; import { REMOTE_LIMITS, RemoteResponseTooLarge } from "./remote-json.ts"; @@ -1021,3 +1022,41 @@ describe("workspace order evidence through the manager", () => { expect(JSON.stringify(await (await ask("")).json())).not.toContain("fixture-token"); }); }); + +describe("saving a remote PC's snapshot", () => { + it("rewrites machines.json durably, and only when a refresh changed what it holds", async () => { + const { manager, drive, remote, runtime } = await remoteFixture(); + const internals = manager as unknown as { stateDir: string; saveTimer?: unknown }; + // each durable write creates its temporary file exclusively ("wx") and fsyncs it + const temporaries: string[] = []; + const open = spyOn(privateStateFs, "openSync"); + const fsync = spyOn(privateStateFs, "fsyncSync"); + cleanups.push(() => { open.mockRestore(); fsync.mockRestore(); }); + open.mockImplementation(((path: string, flags: string, mode?: number) => { + if (flags === "wx" && path.includes("machines.json")) temporaries.push(path); + return openSync(path, flags, mode); + }) as typeof openSync); + // a refresh saves after a short wait (saveSoon): a save is done once its timer is gone + const refreshed = async () => { + await drive.refresh(runtime); + for (let attempt = 0; attempt < 200 && internals.saveTimer !== undefined; attempt++) await Bun.sleep(25); + expect(internals.saveTimer).toBeUndefined(); + }; + const saved = () => JSON.parse(readFileSync(join(internals.stateDir, "machines.json"), "utf8")) as { snapshot: SessionSnapshot | null }[]; + + await refreshed(); + expect(temporaries).toHaveLength(1); + expect(fsync).toHaveBeenCalled(); + expect(saved()[0]!.snapshot?.panes[0]?.agent_status).toBe("working"); + + // the same panes again, in a new object as every read brings: nothing to write + await refreshed(); + await refreshed(); + expect(temporaries).toHaveLength(1); + + remote.set(snapshot("done")); + await refreshed(); + expect(temporaries).toHaveLength(2); + expect(saved()[0]!.snapshot?.panes[0]?.agent_status).toBe("done"); + }); +}); diff --git a/server/machines.test.ts b/server/machines.test.ts index e5a8afae2d..081828b28c 100644 --- a/server/machines.test.ts +++ b/server/machines.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, win32 } from "node:path"; import { describeProgress } from "../src/lib/bridgeProgress.ts"; @@ -1049,3 +1049,67 @@ describe("bridge verification failures", () => { expect((await unanswered({ host: "203.0.113.99" })).forwards).toEqual([[18472]]); }); }); + +// A crash can leave machines.json its old length in NULs (#2044): the app must still start. +describe("saved PCs that cannot be read", () => { + const offline = async () => { throw new Error("local offline"); }; + const dirs: string[] = []; + afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + const directory = () => { const dir = mkdtempSync(join(tmpdir(), "herdr-machines-state-")); dirs.push(dir); return dir; }; + + it("sets a file of NULs aside and starts without the PCs it held", () => { + const dir = directory(); + const zeros = Buffer.alloc(353); + writeFileSync(join(dir, "machines.json"), zeros); + const error = spyOn(console, "error").mockImplementation(() => {}); + let manager: MachineManager | undefined; + try { + manager = new MachineManager(dir, {} as PushService, new CompletionTracker(null), offline); + const aside = readdirSync(dir).filter((name) => name.startsWith("machines.json.corrupt-")); + expect(aside).toHaveLength(1); + expect(aside[0]).not.toContain(":"); + expect(readFileSync(join(dir, aside[0]!))).toEqual(zeros); + expect(existsSync(join(dir, "machines.json"))).toBe(false); + expect(manager.list().map((machine) => machine.kind)).toEqual(["local"]); + expect(manager.settings()).toEqual({ auto_update_bridges: true, set_aside: aside }); + expect(String(error.mock.calls[0]?.[0])).toContain(aside[0]!); + // the next save makes a readable file again + (manager as unknown as { persist(): void }).persist(); + expect(JSON.parse(readFileSync(join(dir, "machines.json"), "utf8"))).toEqual([]); + } finally { manager?.stop(); error.mockRestore(); } + }); + + it("sets aside herdr's approvals that are not JSON, and says so with the PCs' settings", () => { + const dir = directory(); + writeFileSync(join(dir, "herdr-profile-state.json"), Buffer.alloc(120)); + const error = spyOn(console, "error").mockImplementation(() => {}); + let manager: MachineManager | undefined; + try { + manager = new MachineManager(dir, {} as PushService, new CompletionTracker(null), offline); + expect(manager.settings().set_aside).toEqual([expect.stringMatching(/^herdr-profile-state\.json\.corrupt-/)]); + } finally { manager?.stop(); error.mockRestore(); } + }); + + it("still refuses to start on JSON that is not a list of PCs, leaving it in place", () => { + const dir = directory(); + writeFileSync(join(dir, "machines.json"), "{}"); + expect(() => new MachineManager(dir, {} as PushService, new CompletionTracker(null), offline)).toThrow("Invalid machines.json"); + expect(readFileSync(join(dir, "machines.json"), "utf8")).toBe("{}"); + expect(readdirSync(dir).filter((name) => name.includes("corrupt"))).toEqual([]); + }); + + it("does not rewrite saved PCs that have not changed since they were read", () => { + const dir = directory(); + const id = "0b9d6a52-7a5e-4f8e-9a51-2f3c4d5e6f70"; + const path = join(dir, "machines.json"); + const text = JSON.stringify([{ id, name: "macbook", enabled: false, target: { destination: "macbook" }, snapshot: null }]); + writeFileSync(path, text); + const manager = new MachineManager(dir, {} as PushService, new CompletionTracker(null), offline); + try { + (manager as unknown as { persist(): void }).persist(); + expect(readFileSync(path, "utf8")).toBe(text); + manager.patch(id, { name: "laptop" }); + expect(JSON.parse(readFileSync(path, "utf8"))[0].name).toBe("laptop"); + } finally { manager.stop(); } + }); +}); diff --git a/server/machines.ts b/server/machines.ts index ed519d6313..c4f3a11b62 100644 --- a/server/machines.ts +++ b/server/machines.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { createServer as tcpServer } from "node:net"; import { hostname } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BRIDGE_PROTOCOL, LOCAL_MACHINE, REMOTE_BUNDLE_VERSION, type BridgeIdentity, type Machine, type MachineAction, type MachineEvent, type MachineSettings, type SetupAction, type SetupJob, type SetupProgress, type SetupRequest, type SshTarget } from "../shared/machines.ts"; import type { ServerMessage, SessionSnapshot, HerdrPane } from "../shared/protocol.ts"; import type { PushService } from "./push.ts"; @@ -16,6 +16,8 @@ import type { CompletionTracker } from "./completion.ts"; import { HerdrProfileState, herdrMachineId } from "./herdr-profile-state.ts"; import { sameSshSession, type HerdrMachineProfile } from "./herdr-profiles.ts"; import { validateTarget } from "./machine-security.ts"; +import { writePrivateState } from "./private-state.ts"; +import { readStateDocument, setAsideDamaged } from "./state-document.ts"; import { detectHost, HERDR_INSTALL_CMD, installLockOwner } from "./remote-host.ts"; import { discard, readCappedJson, REMOTE_LIMITS } from "./remote-json.ts"; import { SshConnection, sshClient } from "./ssh.ts"; @@ -202,6 +204,10 @@ export class MachineManager { private saveTimer?: ReturnType; private profileState: HerdrProfileState; private statePath: string; + /** machines.json as last read or written (persist) */ + private written: string | undefined; + /** the state files that were not JSON at startup, by the name they were moved to (settings) */ + private setAside: string[] = []; private sshDir: string; private local: Machine = { id: LOCAL_MACHINE, name: hostname(), kind: "local", enabled: true, state: "connecting", error: null, snapshot: null }; private localDiagnostics = new ConnectionDiagnostics("machine.local"); @@ -239,8 +245,17 @@ export class MachineManager { : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "read failed"; if (cause !== "ENOENT") console.error(`Cannot read ${this.settingsPath}; remote PC settings start empty`, cause); } - if (existsSync(this.statePath)) { - const saved: unknown = JSON.parse(readFileSync(this.statePath, "utf8")); + if (this.profileState.setAside !== null) this.setAside.push(basename(this.profileState.setAside)); + // bytes that are not JSON (a crash can leave the file all NULs) are moved aside and the app starts + // without saved PCs; JSON that is not a list of PCs still refuses to start, so nothing replaces it + const read = readStateDocument(this.statePath); + if (read.kind === "malformed") { + const aside = setAsideDamaged(this.statePath); + this.setAside.push(basename(aside)); + console.error(`${this.statePath} is not JSON; it was set aside as ${aside}, and the app starts without the PCs added here`); + } + if (read.kind === "json") { + const saved = read.value; if (!Array.isArray(saved)) throw new Error("Invalid machines.json; registrations were preserved"); for (const item of saved as StoredMachine[]) { if (!/^[a-f0-9-]{36}$/.test(item.id) || typeof item.name !== "string" || typeof item.enabled !== "boolean") throw new Error("Invalid machine registration"); @@ -249,6 +264,7 @@ export class MachineManager { this.machines.set(item.id, runtime); if (item.enabled) queueMicrotask(() => void this.reconnect(runtime)); } + this.written = JSON.stringify(this.stored()); } void this.refreshLocal(); this.localTimer = setInterval(() => void this.refreshLocal(), 5000); @@ -429,17 +445,24 @@ export class MachineManager { this.saveTimer = setTimeout(() => { this.saveTimer = undefined; if (!this.stopped) { try { this.persist(); } catch (e) { console.error("PC state could not be saved:", e instanceof Error ? e.message : "write failed"); } } }, 1000); this.saveTimer.unref(); } + private stored(): StoredMachine[] { + return [...this.machines.values()].filter(({ machine }) => !machine.herdr_profile_id).map(({ machine: m }) => ({ id: m.id, name: m.name, target: m.target!, enabled: m.enabled, snapshot: m.snapshot })); + } + /** + * The registrations cannot be recomputed, so they are written durably (writePrivateState): a + * crash after a plain rename can leave the file its old length in NULs. Every refresh of a remote + * PC saves soon, mostly with the same snapshot, so only a document that differs from the one on + * disk pays for the write and its fsync. + */ private persist(): void { - mkdirSync(this.stateDir, { recursive: true, mode: 0o700 }); - const data: StoredMachine[] = [...this.machines.values()].filter(({ machine }) => !machine.herdr_profile_id).map(({ machine: m }) => ({ id: m.id, name: m.name, target: m.target!, enabled: m.enabled, snapshot: m.snapshot })); - const tmp = this.statePath + ".tmp"; - writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); - chmodSync(tmp, 0o600); renameSync(tmp, this.statePath); + const data = this.stored(); + const text = JSON.stringify(data); + if (text !== this.written) { writePrivateState(this.statePath, data); this.written = text; } for (const { machine } of this.machines.values()) if (machine.herdr_profile_id) this.profileState.saveSnapshot(machine.herdr_profile_id, machine.snapshot); this.profileState.flush(); } job(id: string): SetupJob | undefined { return this.jobs.get(id)?.public; } - settings(): MachineSettings { return { ...this.preferences }; } + settings(): MachineSettings { return { ...this.preferences, ...(this.setAside.length > 0 ? { set_aside: [...this.setAside] } : {}) }; } updateSettings(patch: Record): MachineSettings { if (patch["auto_update_bridges"] !== undefined) { if (typeof patch["auto_update_bridges"] !== "boolean") throw new Error("auto_update_bridges must be boolean"); diff --git a/server/state-document.test.ts b/server/state-document.test.ts new file mode 100644 index 0000000000..b37d33d926 --- /dev/null +++ b/server/state-document.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { readStateDocument, setAsideDamaged } from "./state-document.ts"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const directory = () => { const dir = mkdtempSync(join(tmpdir(), "herdr-state-document-")); dirs.push(dir); return dir; }; + +describe("setAsideDamaged", () => { + it("moves a file of NULs aside, bytes untouched, under a name a Windows file may have", () => { + const dir = directory(), path = join(dir, "machines.json"); + const zeros = Buffer.alloc(353); + writeFileSync(path, zeros); + expect(readStateDocument(path)).toEqual({ kind: "malformed" }); + const aside = setAsideDamaged(path, new Date("2026-10-26T07:06:23.123Z")); + expect(basename(aside)).toBe("machines.json.corrupt-2026-10-26T07-06-23-123Z"); + expect(readFileSync(aside)).toEqual(zeros); + expect(readStateDocument(path)).toEqual({ kind: "missing" }); + }); + + it("keeps every damaged copy: a later one never replaces an earlier one", () => { + const dir = directory(), path = join(dir, "machines.json"); + writeFileSync(path, "first"); + setAsideDamaged(path, new Date("2026-10-24T01:00:00.000Z")); + writeFileSync(path, "second"); + setAsideDamaged(path, new Date("2026-10-26T01:00:00.000Z")); + const kept = readdirSync(dir).sort(); + expect(kept).toHaveLength(2); + expect(kept.map((name) => readFileSync(join(dir, name), "utf8"))).toEqual(["first", "second"]); + }); + + it("throws, naming the file, when it cannot be moved", () => { + const dir = directory(), path = join(dir, "machines.json"); + expect(() => setAsideDamaged(path)).toThrow(/machines\.json is not JSON and cannot be set aside as machines\.json\.corrupt-.* \(ENOENT\)/); + expect(existsSync(path)).toBe(false); + }); +}); diff --git a/server/state-document.ts b/server/state-document.ts index 543017853d..635d5e738d 100644 --- a/server/state-document.ts +++ b/server/state-document.ts @@ -21,6 +21,26 @@ export function readStateDocument(path: string): StateDocument { /** The name a damaged document is kept under until the next damaged one replaces it. */ export const damagedCopy = (path: string): string => `${path}.bad`; +/** + * Moves a document that is not JSON out of the way at once, bytes untouched, to + * `.corrupt-