diff --git a/src/helpers/scheduleDelayed.ts b/src/helpers/scheduleDelayed.ts new file mode 100644 index 00000000..789bd7d1 --- /dev/null +++ b/src/helpers/scheduleDelayed.ts @@ -0,0 +1,48 @@ +/** SetTimeout delays above this overflow a signed 32-bit integer and fire immediately. */ +const MAX_TIMEOUT_DELAY_MS = 2 ** 31 - 1; + +type ArmTimer = (callback: () => void, delayMs: number) => ReturnType; + +const armNativeTimer: ArmTimer = (callback, delayMs) => setTimeout(callback, delayMs); + +export type ScheduledTask = { + /** Stops the task. Works across chunk boundaries, unlike a raw timer handle. */ + cancel: () => void; +}; + +export function scheduleDelayed( + delayMs: number, + task: () => void, + armTimer: ArmTimer = armNativeTimer, +): ScheduledTask { + let handle: ReturnType | undefined; + let cancelled = false; + + const arm = (remainingMs: number) => { + const chunkMs = Math.min(remainingMs, MAX_TIMEOUT_DELAY_MS); + handle = armTimer(() => { + if (cancelled) { + return; + } + + const restMs = remainingMs - chunkMs; + if (restMs > 0) { + arm(restMs); + return; + } + + task(); + }, chunkMs); + }; + + arm(delayMs); + + return { + cancel: () => { + cancelled = true; + if (handle !== undefined) { + clearTimeout(handle); + } + }, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 995934f2..3fcff561 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import db from "./db/db"; import { Jobs } from "./db/types"; import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env"; import "./helpers/printVersions"; +import { scheduleDelayed, ScheduledTask } from "./helpers/scheduleDelayed"; import { chooseConverter } from "./pages/chooseConverter"; import { convert } from "./pages/convert"; import { deleteFile } from "./pages/deleteFile"; @@ -75,12 +76,15 @@ app.listen(process.env.PORT || 3000); console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`); -let clearJobsTimer: ReturnType | undefined; +const MILLISECONDS_PER_HOUR = 60 * 60 * 1000; +const autoDeleteIntervalMs = AUTO_DELETE_EVERY_N_HOURS * MILLISECONDS_PER_HOUR; +let clearJobsTimer: ScheduledTask; + const clearJobs = () => { const jobs = db .query("SELECT * FROM jobs WHERE date_created < ?") .as(Jobs) - .all(new Date(Date.now() - AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000).toISOString()); + .all(new Date(Date.now() - autoDeleteIntervalMs).toISOString()); for (const job of jobs) { // delete the directories @@ -97,7 +101,7 @@ const clearJobs = () => { db.query("DELETE FROM jobs WHERE id = ?").run(job.id); } - clearJobsTimer = setTimeout(clearJobs, AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000); + clearJobsTimer = scheduleDelayed(autoDeleteIntervalMs, clearJobs); }; if (AUTO_DELETE_EVERY_N_HOURS > 0) { @@ -109,7 +113,7 @@ const handleShutdown = async () => { if (isShuttingDown) return; isShuttingDown = true; if (clearJobsTimer) { - clearTimeout(clearJobsTimer); + clearJobsTimer.cancel(); } try { await app.stop(true); diff --git a/tests/helpers/scheduleDelayed.test.ts b/tests/helpers/scheduleDelayed.test.ts new file mode 100644 index 00000000..2a2c532a --- /dev/null +++ b/tests/helpers/scheduleDelayed.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "bun:test"; + +import { scheduleDelayed } from "../../src/helpers/scheduleDelayed"; + +/** Largest delay setTimeout accepts before a 32-bit overflow. */ +const MAX_TIMEOUT_DELAY_MS = 2 ** 31 - 1; +/** Delay reported in the TimeoutOverflowWarning: 30 days in milliseconds. */ +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +type ArmedTimer = { + delayMs: number; + fire: () => void; +}; + +function captureTimers(): { + armed: ArmedTimer[]; + armTimer: (callback: () => void, delayMs: number) => ReturnType; +} { + const armed: ArmedTimer[] = []; + const armTimer = (callback: () => void, delayMs: number) => { + armed.push({ delayMs, fire: callback }); + return 0 as unknown as ReturnType; + }; + return { armed, armTimer }; +} + +test("runs a one day delay as a single timer", () => { + const { armed, armTimer } = captureTimers(); + let runs = 0; + const oneDayMs = 24 * 60 * 60 * 1000; + + scheduleDelayed( + oneDayMs, + () => { + runs += 1; + }, + armTimer, + ); + + expect(armed).toHaveLength(1); + const timer = armed.shift(); + expect(timer?.delayMs).toBe(oneDayMs); + expect(runs).toBe(0); + + timer?.fire(); + expect(runs).toBe(1); + expect(armed).toHaveLength(0); +}); + +test("runs a 30 day delay in timer-sized chunks that add up to 30 days", () => { + expect(THIRTY_DAYS_MS).toBe(2_592_000_000); + + const { armed, armTimer } = captureTimers(); + let runs = 0; + scheduleDelayed( + THIRTY_DAYS_MS, + () => { + runs += 1; + }, + armTimer, + ); + + let elapsedMs = 0; + let chunks = 0; + while (runs === 0) { + chunks += 1; + expect(chunks).toBeLessThanOrEqual(3); + + const next = armed.shift(); + if (!next) { + throw new Error("timer chain ended before the task ran"); + } + + expect(next.delayMs).toBeGreaterThan(0); + expect(next.delayMs).toBeLessThanOrEqual(MAX_TIMEOUT_DELAY_MS); + elapsedMs += next.delayMs; + next.fire(); + } + + expect(runs).toBe(1); + expect(elapsedMs).toBe(THIRTY_DAYS_MS); + expect(armed).toHaveLength(0); +}); + +test("cancelling before the first chunk fires prevents the task", () => { + const { armed, armTimer } = captureTimers(); + let runs = 0; + const scheduled = scheduleDelayed( + THIRTY_DAYS_MS, + () => { + runs += 1; + }, + armTimer, + ); + + scheduled.cancel(); + armed.shift()?.fire(); + expect(runs).toBe(0); + expect(armed).toHaveLength(0); +}); + +test("cancelling after a chunk fired still stops the remaining chain", () => { + const { armed, armTimer } = captureTimers(); + let runs = 0; + const scheduled = scheduleDelayed( + THIRTY_DAYS_MS, + () => { + runs += 1; + }, + armTimer, + ); + + armed.shift()?.fire(); + expect(armed).toHaveLength(1); + expect(runs).toBe(0); + + scheduled.cancel(); + armed.shift()?.fire(); + expect(runs).toBe(0); + expect(armed).toHaveLength(0); +}); + +test("does not emit a timeout overflow warning for a 30 day delay", async () => { + const warnings: string[] = []; + const onWarning = (warning: Error) => { + warnings.push(warning.message); + }; + process.on("warning", onWarning); + + let ran = false; + const scheduled = scheduleDelayed(THIRTY_DAYS_MS, () => { + ran = true; + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(ran).toBe(false); + expect(warnings.join("\n")).not.toContain("TimeoutOverflowWarning"); + expect(warnings.join("\n")).not.toContain("2592000000"); + } finally { + scheduled.cancel(); + process.off("warning", onWarning); + } +});