From 5427ad9aebc81252160584e2e27701c23a26ddfa Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Tue, 21 Jul 2026 14:37:21 +0530 Subject: [PATCH 1/2] fix: chunk long setTimeout delays to avoid 32-bit overflow in schedule-runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTimeout's delay is a 32-bit signed int under the hood — any runAt more than ~24.8 days out silently clamps to 1ms and fires almost immediately instead of waiting. scheduleAt() now re-schedules in safe chunks until the real target time is reached. --- src/schedule-runner.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/schedule-runner.ts b/src/schedule-runner.ts index 9e677ce..7f00f31 100644 --- a/src/schedule-runner.ts +++ b/src/schedule-runner.ts @@ -19,6 +19,22 @@ const activeTasks = new Map(); const activeTimers = new Map>(); const runningJobs = new Set(); +// setTimeout's delay is a 32-bit signed int under the hood — anything past +// this fires almost immediately instead of waiting (Node clamps it to 1ms +// and emits a TimeoutOverflowWarning). Chunk long waits into safe segments. +const MAX_TIMEOUT_MS = 2_147_483_647; + +export function scheduleAt(id: string, targetTime: number, onDue: () => void): void { + const remaining = targetTime - Date.now(); + if (remaining <= 0) { + activeTimers.delete(id); + onDue(); + return; + } + const timer = setTimeout(() => scheduleAt(id, targetTime, onDue), Math.min(remaining, MAX_TIMEOUT_MS)); + activeTimers.set(id, timer); +} + export async function startScheduler(opts: SchedulerOptions): Promise { const schedules = await discoverSchedules(opts.agentDir); let activeCount = 0; @@ -33,10 +49,9 @@ export async function startScheduler(opts: SchedulerOptions): Promise { console.log(dim(`[scheduler] "${schedule.id}" runAt is in the past — skipping`)); continue; } - const timer = setTimeout(() => { + scheduleAt(schedule.id, new Date(schedule.runAt).getTime(), () => { executeScheduledJob(schedule, opts, true); - }, delay); - activeTimers.set(schedule.id, timer); + }); const when = new Date(schedule.runAt).toLocaleString(); console.log(dim(`[scheduler] "${schedule.id}" scheduled once at ${when} (in ${Math.round(delay / 1000)}s)`)); activeCount++; From b79aa770f5d7122cc71c86b8f77897dc33eb8e64 Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Tue, 21 Jul 2026 14:46:34 +0530 Subject: [PATCH 2/2] test: add unit tests for scheduleAt covering the timer overflow fix Exports activeTimers alongside scheduleAt so tests can verify cancellation. Covers: delay under the limit, delay requiring a second chunk, an already-past target firing synchronously, and cancellation mid-chunk. --- src/schedule-runner.ts | 2 +- test/schedule-timer-overflow.test.ts | 92 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/schedule-timer-overflow.test.ts diff --git a/src/schedule-runner.ts b/src/schedule-runner.ts index 7f00f31..2c79498 100644 --- a/src/schedule-runner.ts +++ b/src/schedule-runner.ts @@ -16,7 +16,7 @@ export interface SchedulerOptions { } const activeTasks = new Map(); -const activeTimers = new Map>(); +export const activeTimers = new Map>(); const runningJobs = new Set(); // setTimeout's delay is a 32-bit signed int under the hood — anything past diff --git a/test/schedule-timer-overflow.test.ts b/test/schedule-timer-overflow.test.ts new file mode 100644 index 0000000..3997af9 --- /dev/null +++ b/test/schedule-timer-overflow.test.ts @@ -0,0 +1,92 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let scheduleAt: typeof import("../dist/schedule-runner.js").scheduleAt; +let activeTimers: typeof import("../dist/schedule-runner.js").activeTimers; + +before(async () => { + const mod = await import("../dist/schedule-runner.js"); + scheduleAt = mod.scheduleAt; + activeTimers = mod.activeTimers; +}); + +const MAX_TIMEOUT_MS = 2_147_483_647; + +describe("scheduleAt (timer overflow guard)", () => { + it("delay < MAX_TIMEOUT_MS: fires in a single setTimeout, callback called exactly once", (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + + const start = Date.now(); + const delay = 10 * 24 * 60 * 60 * 1000; // 10 days — well under the limit + const target = start + delay; + + let fireCount = 0; + scheduleAt("short-job", target, () => fireCount++); + + assert.equal(fireCount, 0, "must not fire before the delay elapses"); + t.mock.timers.tick(delay); + assert.equal(fireCount, 1, "must fire exactly once once the delay elapses"); + }); + + it("delay slightly above MAX_TIMEOUT_MS: fires via two chunks, callback called once, at the right time", (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + + const start = Date.now(); + const delay = MAX_TIMEOUT_MS + 1000; // just over the limit — needs a 2nd chunk + const target = start + delay; + + let fireCount = 0; + let firedAt = 0; + scheduleAt("two-chunk-job", target, () => { + fireCount++; + firedAt = Date.now(); + }); + + // After the first chunk (exactly MAX_TIMEOUT_MS), it must not have fired yet — + // there's still 1000ms of real delay left. + t.mock.timers.tick(MAX_TIMEOUT_MS); + assert.equal(fireCount, 0, "must not fire after only the first chunk"); + + // The remaining 1000ms is the second chunk. + t.mock.timers.tick(1000); + assert.equal(fireCount, 1, "must fire exactly once after the second chunk"); + assert.equal(firedAt, target, "must fire exactly at the real target time"); + }); + + it("delay <= 0 at call time: callback fires synchronously, no timer scheduled", (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + + const now = Date.now(); + let fired = false; + scheduleAt("past-job", now - 1000, () => { + fired = true; + }); + + assert.equal(fired, true, "must fire immediately when the target time is already in the past"); + assert.equal(activeTimers.has("past-job"), false, "must not leave a dangling timer entry"); + }); + + it("cancel mid-chunk: clearing the activeTimers entry prevents the callback from firing", (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + + const start = Date.now(); + const delay = MAX_TIMEOUT_MS + 1000; // needs 2 chunks, so there's a "mid-chunk" to cancel during + const target = start + delay; + + let fired = false; + scheduleAt("cancelled-job", target, () => { + fired = true; + }); + + // Cancel after the first chunk has elapsed, before the second chunk's callback fires. + t.mock.timers.tick(MAX_TIMEOUT_MS); + const timer = activeTimers.get("cancelled-job"); + assert.ok(timer, "a pending timer must exist mid-chunk"); + clearTimeout(timer); + activeTimers.delete("cancelled-job"); + + // Advance past the point where it would have fired, if not cancelled. + t.mock.timers.tick(1000); + assert.equal(fired, false, "must not fire after being cancelled mid-chunk"); + }); +});