diff --git a/src/croner.ts b/src/croner.ts index ecdc5e9..816a2d3 100644 --- a/src/croner.ts +++ b/src/croner.ts @@ -499,6 +499,13 @@ class Cron { waitMs = maxDelay; } + // Clamp negative delays to 0 - some runtimes (e.g. Deno) treat large negative values + // as large positive delays due to 32-bit integer overflow, causing jobs with allowPast:true + // and a far-past date to never fire. Use a backoff when paused to avoid a tight loop. + if (waitMs < 0) { + waitMs = this._states.paused ? 1000 : 0; + } + // Start the timer loop // _checkTrigger will either call _trigger (if it's time, croner isn't paused and whatever), // or recurse back to this function to wait for next trigger diff --git a/test/croner.test.ts b/test/croner.test.ts index ac9aeff..5a8a26d 100644 --- a/test/croner.test.ts +++ b/test/croner.test.ts @@ -1159,6 +1159,43 @@ test( }), ); +test( + "Fire-once job with allowPast: true, paused: true, and far-past date should fire after resume", + //@ts-ignore + timeout(4000, (resolve) => { + let fired = false; + let resumed = false; + let settled = false; + let watchdog: ReturnType | undefined; + const veryOldDate = new Date("2020-01-01T00:00:00"); + const job = new Cron(veryOldDate, { allowPast: true, paused: true }, () => { + fired = true; + + if (!resumed || settled) return; + + settled = true; + if (watchdog) clearTimeout(watchdog); + job.stop(); + assertEquals(fired, true); + resolve(); + }); + + // Should not fire while paused + setTimeout(() => { + assertEquals(fired, false); + resumed = true; + job.resume(); + + watchdog = setTimeout(() => { + if (settled) return; + settled = true; + job.stop(); + assertEquals(fired, true); + }, 2000); + }, 100); + }), +); + test("Fire-once job with no allowPast and date > 1s in past should have null nextRun and not be running", function () { // A once-job 3 seconds in the past without allowPast should silently not schedule const pastTime = new Date(Date.now() - 3000);