fix(cron): guard against year-rollback in croner nextRun (#30777)

* fix(cron): guard against year-rollback in croner nextRun

Croner can return a past-year timestamp for some timezone/date
combinations (e.g. Asia/Shanghai).  When nextRun returns a value at or
before nowMs, retry from the next whole second and, if still stale,
from midnight-tomorrow UTC before giving up.

Closes #30351

* googlechat: guard API calls with SSRF-safe fetch

* test: fix hoisted plugin context mock setup

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Sid
2026-03-02 12:22:59 +08:00
committed by GitHub
parent 6fc0787bf0
commit 4691aab019
4 changed files with 142 additions and 85 deletions

View File

@@ -73,6 +73,16 @@ describe("cron schedule", () => {
expect(next).toBe(anchor + 30_000);
});
it("never returns a past timestamp for Asia/Shanghai daily schedule (#30351)", () => {
const nowMs = Date.parse("2026-03-01T00:00:00.000Z");
const next = computeNextRunAtMs(
{ kind: "cron", expr: "0 8 * * *", tz: "Asia/Shanghai" },
nowMs,
);
expect(next).toBeDefined();
expect(next!).toBeGreaterThan(nowMs);
});
describe("cron with specific seconds (6-field pattern)", () => {
// Pattern: fire at exactly second 0 of minute 0 of hour 12 every day
const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" };

View File

@@ -54,25 +54,39 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
timezone: resolveCronTimezone(schedule.tz),
catch: false,
});
const next = cron.nextRun(new Date(nowMs));
let next = cron.nextRun(new Date(nowMs));
if (!next) {
return undefined;
}
const nextMs = next.getTime();
let nextMs = next.getTime();
if (!Number.isFinite(nextMs)) {
return undefined;
}
if (nextMs > nowMs) {
return nextMs;
}
// Guard against same-second rescheduling loops: if croner returns
// "now" (or an earlier instant), retry from the next whole second.
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
const retry = cron.nextRun(new Date(nextSecondMs));
if (!retry) {
// Workaround for croner year-rollback bug: some timezone/date combinations
// (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year.
// Retry from a later reference point when the returned time is not in the
// future.
if (nextMs <= nowMs) {
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
const retry = cron.nextRun(new Date(nextSecondMs));
if (retry) {
const retryMs = retry.getTime();
if (Number.isFinite(retryMs) && retryMs > nowMs) {
return retryMs;
}
}
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
const retry2 = cron.nextRun(new Date(tomorrowMs));
if (retry2) {
const retry2Ms = retry2.getTime();
if (Number.isFinite(retry2Ms) && retry2Ms > nowMs) {
return retry2Ms;
}
}
return undefined;
}
const retryMs = retry.getTime();
return Number.isFinite(retryMs) && retryMs > nowMs ? retryMs : undefined;
return nextMs;
}