From 218bae7b3ee2eab8126fae34fd32a1c11b6cf324 Mon Sep 17 00:00:00 2001 From: rowan kavanagh Date: Thu, 24 Sep 2026 00:16:51 +0100 Subject: [PATCH] feat(app): persist crash-loop state and guard app starts for safe mode (#122) --- src/startup-recovery-store.js | 72 +++++++++++++++++++++++++ test/startup-recovery-store.test.js | 81 +++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/startup-recovery-store.js create mode 100644 test/startup-recovery-store.test.js diff --git a/src/startup-recovery-store.js b/src/startup-recovery-store.js new file mode 100644 index 00000000..cd4c5be7 --- /dev/null +++ b/src/startup-recovery-store.js @@ -0,0 +1,72 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { confirmHealthyStartup, createStartupRecoveryState, recordStartupFailure } from "./startup-recovery.js"; + +const MAX_STATE_BYTES = 4 * 1024; + +// Crash-loop state on disk (#122). A bad or missing file is treated as a clean +// start: recovery must never be the thing that stops the app from starting. +export function createStartupRecoveryStore({ file } = {}) { + if (typeof file !== "string" || !file) throw new TypeError("startup recovery file is required"); + async function load() { + try { + const text = await readFile(file, "utf8"); + if (Buffer.byteLength(text) > MAX_STATE_BYTES) return createStartupRecoveryState(); + return createStartupRecoveryState(JSON.parse(text)); + } catch { + return createStartupRecoveryState(); + } + } + async function save(state) { + const current = createStartupRecoveryState(state); + await mkdir(dirname(file), { recursive: true }); + const temporary = `${file}.tmp`; + await writeFile(temporary, `${JSON.stringify({ version: 1, failures: current.failures, subsystem: current.subsystem })}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, file); + return current; + } + return Object.freeze({ load, save }); +} + +// Maps a startup error to the subsystem that failed. +export function startupFailureSubsystem(error) { + const code = String(error?.startupCode ?? ""); + if (code.startsWith("CONFIG_")) return "configuration"; + if (code.startsWith("CREDENTIAL_") || code.startsWith("PROVIDER_")) return "provider"; + if (code.startsWith("DISCORD_")) return "discord"; + if (code.startsWith("UPDATE")) return "updater"; + return "unknown"; +} + +// Runs one app start under crash-loop protection. The attempt is counted as a +// failure *before* starting, so a hard crash (process killed, native fault) +// still counts; it is cleared once the app has stayed up for healthyAfterMs. +// After three unconfirmed starts in a row, start() is called with safeMode. +// Wiring into the Windows entry point and the tray comes in a follow-up. +export async function guardStartup({ store, start, healthyAfterMs = 60_000, setTimer = setTimeout, clearTimer = clearTimeout } = {}) { + if (typeof store?.load !== "function" || typeof store?.save !== "function") throw new TypeError("store is invalid"); + if (typeof start !== "function") throw new TypeError("start is required"); + const before = await store.load(); + const safeMode = before.safeMode; + await saveQuietly(store, recordStartupFailure(before, before.subsystem ?? "unknown")); + let app; + try { + app = await start({ safeMode, recovery: before }); + } catch (error) { + await saveQuietly(store, recordStartupFailure(before, startupFailureSubsystem(error))); + throw error; + } + // Safe mode stays on until the user picks "retry normal startup": a safe-mode + // run staying up proves nothing about the parts it turned off. + const timer = safeMode ? null : setTimer(() => saveQuietly(store, confirmHealthyStartup(before, true)), healthyAfterMs); + timer?.unref?.(); + return Object.freeze({ + app, safeMode, recovery: before, + cancel: () => { if (timer) clearTimer(timer); }, + retryNormal: () => saveQuietly(store, confirmHealthyStartup(before, true)), + }); +} + +async function saveQuietly(store, state) { + try { await store.save(state); } catch { /* a read-only disk must not block startup */ } +} diff --git a/test/startup-recovery-store.test.js b/test/startup-recovery-store.test.js new file mode 100644 index 00000000..d765b689 --- /dev/null +++ b/test/startup-recovery-store.test.js @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createStartupRecoveryStore, guardStartup, startupFailureSubsystem } from "../src/startup-recovery-store.js"; + +async function storeFile() { return join(await mkdtemp(join(tmpdir(), "np-recovery-")), "startup-recovery.json"); } +function manualTimers() { + const pending = []; + return { setTimer: (fn, ms) => { pending.push({ fn, ms }); return { unref() {} }; }, clearTimer: () => { pending.length = 0; }, fire: async () => { for (const { fn } of pending.splice(0)) await fn(); await new Promise((r) => setImmediate(r)); }, pending }; +} + +test("missing, corrupt or oversized state files load as a clean start", async () => { + const file = await storeFile(); + const store = createStartupRecoveryStore({ file }); + assert.equal((await store.load()).failures, 0); + await writeFile(file, "{not json"); + assert.equal((await store.load()).failures, 0); + await writeFile(file, JSON.stringify({ failures: 2, pad: "x".repeat(5000) })); + assert.equal((await store.load()).failures, 0); +}); + +test("a healthy start clears the count once it has stayed up", async () => { + const file = await storeFile(); + const store = createStartupRecoveryStore({ file }); + await store.save({ failures: 2, subsystem: "provider" }); + const timers = manualTimers(); + const guarded = await guardStartup({ store, start: async ({ safeMode }) => ({ safeMode }), healthyAfterMs: 60_000, ...timers }); + assert.equal(guarded.safeMode, false); + assert.equal((await store.load()).failures, 3, "counted as unconfirmed until healthy"); + assert.equal(timers.pending[0].ms, 60_000); + await timers.fire(); + assert.equal((await store.load()).failures, 0); +}); + +test("three unconfirmed starts in a row start the next one in safe mode", async () => { + const file = await storeFile(); + const store = createStartupRecoveryStore({ file }); + const seen = []; + const start = async (options) => { seen.push(options.safeMode); return {}; }; + // Each run "crashes" before the healthy timer fires. + for (let run = 0; run < 4; run += 1) await guardStartup({ store, start, ...manualTimers() }); + assert.deepEqual(seen, [false, false, false, true]); + assert.equal((await store.load()).safeMode, true); +}); + +test("a start that throws records which subsystem failed and rethrows", async () => { + const file = await storeFile(); + const store = createStartupRecoveryStore({ file }); + const error = Object.assign(new Error("bad config"), { startupCode: "CONFIG_INVALID" }); + await assert.rejects(guardStartup({ store, start: async () => { throw error; }, ...manualTimers() }), /bad config/); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { version: 1, failures: 1, subsystem: "configuration" }); +}); + +test("an unwritable state file never blocks startup", async () => { + const store = { load: async () => ({ failures: 0 }), save: async () => { throw new Error("EROFS"); } }; + const guarded = await guardStartup({ store, start: async () => ({ ok: true }), ...manualTimers() }); + assert.deepEqual(guarded.app, { ok: true }); +}); + +test("maps startup codes to subsystems", () => { + assert.equal(startupFailureSubsystem({ startupCode: "CONFIG_TOO_NEW" }), "configuration"); + assert.equal(startupFailureSubsystem({ startupCode: "CREDENTIAL_READ_FAILED" }), "provider"); + assert.equal(startupFailureSubsystem({ startupCode: "PORT_IN_USE" }), "unknown"); + assert.equal(startupFailureSubsystem(null), "unknown"); +}); + +test("safe mode stays on until the user retries a normal start", async () => { + const file = await storeFile(); + const store = createStartupRecoveryStore({ file }); + await store.save({ failures: 3, subsystem: "discord" }); + const timers = manualTimers(); + const guarded = await guardStartup({ store, start: async () => ({}), ...timers }); + assert.equal(guarded.safeMode, true); + assert.equal(timers.pending.length, 0, "no automatic all-clear in safe mode"); + await guarded.retryNormal(); + assert.equal((await store.load()).failures, 0); + const next = await guardStartup({ store, start: async () => ({}), ...manualTimers() }); + assert.equal(next.safeMode, false); +});