mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-10-27 23:30:55 +01:00
fix: keep long auto-delete intervals from overflowing setTimeout (#672)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Emrik Östling <emrik.ostling@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Signed-off-by: YodHeVauHe <mugharold@gmail.com>
This commit is contained in:
@@ -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<typeof setTimeout>;
|
||||
|
||||
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<typeof setTimeout> | 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+8
-4
@@ -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<typeof setTimeout> | 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);
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
} {
|
||||
const armed: ArmedTimer[] = [];
|
||||
const armTimer = (callback: () => void, delayMs: number) => {
|
||||
armed.push({ delayMs, fire: callback });
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user