Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/startup-recovery-store.js
Original file line number Diff line number Diff line change
@@ -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 */ }
}
81 changes: 81 additions & 0 deletions test/startup-recovery-store.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading