fix(core): compute cron next-fire time with epoch arithmetic across DST fall-back (#11723)

nextFireTime advanced its minute-by-minute scan with local Date setters
(setSeconds / setMinutes). ECMA-262 resolves an ambiguous fall-back
wall-clock to its earlier occurrence, so an anchor inside the repeated
autumn hour was re-projected up to 59 minutes into the past, breaking the
strictly-after contract, and a forward scan skipped the entire second
occurrence of the hour.

Traverse elapsed time instead: initialise the candidate with epoch
arithmetic (floor(after/60000)*60000 + 60000) and advance with
candidate.setTime(candidate.getTime() + 60000), while continuing to match
cron fields with local getters. Cron syntax, jitter policy, and other
scheduler behavior are unchanged. Fixes the root cause reported in #11720.


Patrol-Run: qwen-issue-patrol/jmtygitr20b

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-12 16:39:15 +00:00
1 parent 1331c7af85
commit b5c7635ff9
2 files changed
+73 -6

No files matched your search

+65 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { matches, nextFireTime, parseCron } from './cronParser.js';
describe('parseCron', () => {
@@ -227,3 +227,67 @@ describe('nextFireTime', () => {
);
});
});
describe('nextFireTime across DST transitions', () => {
// America/New_York spring forward 2026-03-08 02:00, fall back 2026-11-01 02:00.
// The repeated local hour 01:00–01:59 on fall-back day exists twice
// (EDT = UTC-4 first, EST = UTC-5 second). Local Date setters resolve the
// ambiguous wall-clock to the earlier occurrence, which used to re-project
// a live instant into the past (see issue #11720).
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = 'America/New_York';
});
afterAll(() => {
if (originalTz === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTz;
}
});
it('returns a strictly-later instant for an anchor inside the repeated hour', () => {
// 06:30:15Z is the second 01:30 (EST). The old local-setter scan snapped
// back to 05:31Z, 59 minutes before the input.
const after = new Date('2026-11-01T06:30:15.000Z');
const next = nextFireTime('* * * * *', after);
expect(next.toISOString()).toBe('2026-11-01T06:31:00.000Z');
expect(next.getTime()).toBeGreaterThan(after.getTime());
});
it('reaches the second occurrence of the repeated hour when scanning forward', () => {
// 05:59Z is the first 01:59 (EDT). Local setMinutes(60) built local
// 02:00 (EST only) and jumped straight to 07:00Z, skipping 06:00Z.
const after = new Date('2026-11-01T05:59:00.000Z');
const next = nextFireTime('* * * * *', after);
expect(next.toISOString()).toBe('2026-11-01T06:00:00.000Z');
});
it('fires a fixed-hour job in both occurrences of the repeated hour', () => {
// Anchored at the first 01:30 (05:30Z), the second 01:30 (06:30Z) must
// still be reachable rather than skipped to the next day.
const after = new Date('2026-11-01T05:30:00.000Z');
const next = nextFireTime('30 1 * * *', after);
expect(next.toISOString()).toBe('2026-11-01T06:30:00.000Z');
});
it('advances across the spring-forward gap', () => {
// 06:59Z is 01:59 EST; 02:00–02:59 does not exist that morning, so the
// next real minute is 03:00 EDT (07:00Z).
const after = new Date('2026-03-08T06:59:00.000Z');
const next = nextFireTime('* * * * *', after);
expect(next.toISOString()).toBe('2026-03-08T07:00:00.000Z');
expect(next.getTime()).toBeGreaterThan(after.getTime());
});
it('returns a strictly-later instant when anchored exactly on a repeated-hour match', () => {
// 06:00Z is exactly the second 01:00 (EST). The old scan re-materialised
// 01:01 as EDT (05:01Z), one minute before the input.
const after = new Date('2026-11-01T06:00:00.000Z');
const next = nextFireTime('* * * * *', after);
expect(next.toISOString()).toBe('2026-11-01T06:01:00.000Z');
expect(next.getTime()).toBeGreaterThan(after.getTime());
});
});
+8 -5
View File
@@ -170,10 +170,13 @@ export function matches(cronExpr: string, date: Date): boolean {
export function nextFireTime(cronExpr: string, after: Date): Date {
const fields = parseCron(cronExpr);
// Start at the next whole minute after `after`
const candidate = new Date(after.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Start at the next whole minute after `after`. Use epoch arithmetic
// rather than local Date setters: a local `setMinutes`/`setSeconds`
// resolves an ambiguous fall-back wall-clock to its earlier occurrence,
// which would re-project the candidate up to 59 minutes into the past.
const candidate = new Date(
Math.floor(after.getTime() / 60000) * 60000 + 60000,
);
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
const maxIterations = 4 * 366 * 24 * 60;
@@ -193,7 +196,7 @@ export function nextFireTime(cronExpr: string, after: Date): Date {
if (minuteOk && hourOk && monthOk && dayOk) {
return candidate;
}
candidate.setMinutes(candidate.getMinutes() + 1);
candidate.setTime(candidate.getTime() + 60000);
}
throw new Error(