diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 9563765e720..0c2a45ea416 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -243,6 +243,45 @@ describe("cron store", () => { expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(legacy.jobs[0].createdAtMs + 60_000); }); + it("treats a corrupt state sidecar as absent", async () => { + const store = await makeStorePath(); + const payload = makeStore("job-1", true); + payload.jobs[0].state = { nextRunAtMs: payload.jobs[0].createdAtMs + 60_000 }; + const statePath = store.storePath.replace(/\.json$/, "-state.json"); + + await saveCronStore(store.storePath, payload); + await fs.writeFile(statePath, "{ not json", "utf-8"); + + const loaded = await loadCronStore(store.storePath); + + expect(loaded.jobs[0]?.updatedAtMs).toBe(payload.jobs[0].createdAtMs); + expect(loaded.jobs[0]?.state).toEqual({}); + }); + + it("propagates unreadable state sidecar errors", async () => { + const store = await makeStorePath(); + const payload = makeStore("job-1", true); + const statePath = store.storePath.replace(/\.json$/, "-state.json"); + + await saveCronStore(store.storePath, payload); + + const origReadFile = fs.readFile.bind(fs); + const spy = vi.spyOn(fs, "readFile").mockImplementation(async (filePath, options) => { + if (filePath === statePath) { + const err = new Error("permission denied") as NodeJS.ErrnoException; + err.code = "EACCES"; + throw err; + } + return origReadFile(filePath, options as never) as never; + }); + + try { + await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to read cron state/); + } finally { + spy.mockRestore(); + } + }); + it("sanitizes invalid updatedAtMs values from the state sidecar", async () => { const store = await makeStorePath(); const job = makeStore("job-1", true).jobs[0]; diff --git a/src/cron/store.ts b/src/cron/store.ts index 0595496dfee..7b548edeed0 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -81,8 +81,19 @@ export function resolveCronStorePath(storePath?: string) { } async function loadStateFile(statePath: string): Promise { + let raw: string; + try { + raw = await fs.promises.readFile(statePath, "utf-8"); + } catch (err) { + if ((err as { code?: unknown })?.code === "ENOENT") { + return null; + } + throw new Error(`Failed to read cron state at ${statePath}: ${String(err)}`, { + cause: err, + }); + } + try { - const raw = await fs.promises.readFile(statePath, "utf-8"); const parsed = parseJsonWithJson5Fallback(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; @@ -92,10 +103,7 @@ async function loadStateFile(statePath: string): Promise { return null; } return { version: 1, jobs: record.jobs as Record }; - } catch (err) { - if ((err as { code?: unknown })?.code === "ENOENT") { - return null; - } + } catch { // Best-effort: if state file is corrupt, treat as absent. return null; }