From cad0fcc65d7733a3ab292bac7ced5d40c0a850cc Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:07:42 +0200 Subject: [PATCH] fix(sse): bound daily quota cooldowns around daylight-saving transitions (#13671) Fixes the DST-gap bug in `nextDailyResetAtMs`: a reset hour that does not exist on the transition day landed one hour early (New York 02:00 came out as 01:00; Havana/Santiago midnight as 23:00 the day before). The walk across the gap is bounded to one day and uses a cached formatter. Maintainer rework before merge (kept the idea, no default behavior change): - Dropped the 24h clamp in `getMsUntilTomorrow` (on a 25h fall-back day 24.5h is the correct wait; clamping expired the lock 30 minutes early) and the unreachable `ms <= 0` branch, with their tests; characterization tests pin ordinary and fall-back days. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13671-dst-gap.md | 1 + open-sse/services/dailyQuotaReset.ts | 97 +++++++++++++++++++++----- stryker.conf.json | 1 + tests/unit/daily-reset-dst-gap.test.ts | 79 +++++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/13671-dst-gap.md create mode 100644 tests/unit/daily-reset-dst-gap.test.ts diff --git a/changelog.d/fixes/13671-dst-gap.md b/changelog.d/fixes/13671-dst-gap.md new file mode 100644 index 0000000000..cec8fa3160 --- /dev/null +++ b/changelog.d/fixes/13671-dst-gap.md @@ -0,0 +1 @@ +- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis diff --git a/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts index 46db112236..fd9360d49b 100644 --- a/open-sse/services/dailyQuotaReset.ts +++ b/open-sse/services/dailyQuotaReset.ts @@ -32,17 +32,30 @@ type ZonedParts = { second: number; }; +// Formatter construction dominates zonedParts; the DST-gap walk below calls it +// hundreds of times, so reuse one formatter per (validated) IANA zone. +const zonedFormatters = new Map(); + +function zonedFormatter(timeZone: string): Intl.DateTimeFormat { + let fmt = zonedFormatters.get(timeZone); + if (!fmt) { + fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + zonedFormatters.set(timeZone, fmt); + } + return fmt; +} + function zonedParts(ms: number, timeZone: string): ZonedParts { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const fmt = zonedFormatter(timeZone); const bag: Record = {}; for (const part of fmt.formatToParts(new Date(ms))) { if (part.type !== "literal") bag[part.type] = part.value; @@ -71,6 +84,35 @@ function addCalendarDay( return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() }; } +/** + * Offset-iteration wall-clock → epoch conversion. `exact` is false when the + * iteration never lands on the wanted wall time, which is what a wall time + * inside a DST gap (a local time that does not exist) does. + */ +function convergeWallTime( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + timeZone: string +): { ms: number; exact: boolean } { + const wanted = Date.UTC(year, month - 1, day, hour, minute, second); + let guess = wanted; + for (let i = 0; i < 4; i++) { + const p = zonedParts(guess, timeZone); + const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + const delta = asIfUtc - wanted; + if (delta === 0) return { ms: guess, exact: true }; + guess -= delta; + } + return { ms: guess, exact: false }; +} + +/** Gap-walk bound: one full day covers every civil gap, including a skipped calendar day. */ +const MAX_GAP_WALK_MINUTES = 24 * 60; + /** Convert wall-clock time in `timeZone` to epoch ms. */ function zonedLocalToUtc( year: number, @@ -81,16 +123,33 @@ function zonedLocalToUtc( second: number, timeZone: string ): number { - const wanted = Date.UTC(year, month - 1, day, hour, minute, second); - let guess = wanted; - for (let i = 0; i < 4; i++) { - const p = zonedParts(guess, timeZone); - const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); - const delta = asIfUtc - wanted; - if (delta === 0) return guess; - guess -= delta; + const first = convergeWallTime(year, month, day, hour, minute, second, timeZone); + if (first.exact) return first.ms; + // DST gap (New York 02:00 on spring-forward, Havana/Santiago 00:00): the offset + // iteration settles an hour EARLY. Walk the wall clock forward minute by minute to + // the first wall time that exists; gap widths vary (30 min, 1 h), so never add a + // fixed offset. + let date = { year, month, day }; + let minuteOfDay = hour * 60 + minute; + for (let step = 0; step < MAX_GAP_WALK_MINUTES; step++) { + minuteOfDay += 1; + if (minuteOfDay >= 24 * 60) { + minuteOfDay -= 24 * 60; + date = addCalendarDay(date.year, date.month, date.day); + } + const h = Math.floor(minuteOfDay / 60); + const candidate = convergeWallTime( + date.year, + date.month, + date.day, + h, + minuteOfDay % 60, + second, + timeZone + ); + if (candidate.exact) return candidate.ms; } - return guess; + return first.ms; } /** diff --git a/stryker.conf.json b/stryker.conf.json index 1e95da7312..0be11313fa 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -247,6 +247,7 @@ "tests/unit/correctness/sanitizers.property.test.ts", "tests/unit/cursor-renewal.test.ts", "tests/unit/custom-model-target-format.test.ts", + "tests/unit/daily-reset-dst-gap.test.ts", "tests/unit/db-reset-module-state.test.ts", "tests/unit/db-server-tool-executions-migration.test.ts", "tests/unit/db/stats-dbstat-optional.test.ts", diff --git a/tests/unit/daily-reset-dst-gap.test.ts b/tests/unit/daily-reset-dst-gap.test.ts new file mode 100644 index 0000000000..0ae77e9104 --- /dev/null +++ b/tests/unit/daily-reset-dst-gap.test.ts @@ -0,0 +1,79 @@ +/** + * #13671 — a configured daily reset hour that does not exist on a DST + * spring-forward day must resolve to the first wall-clock time that exists, + * never an hour early (and never on the previous calendar day). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts"); + +const HOUR_MS = 60 * 60 * 1000; + +function wallClock(timeZone: string, ms: number): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(ms)); +} + +test("New York 02:00 on spring-forward resolves to 03:00 EDT, not 01:00 EST", () => { + // 2026-03-08: 02:00 -> 03:00 in America/New_York; 02:00 does not exist. + const nowMs = Date.parse("2026-03-08T00:30:00-05:00"); + const next = nextDailyResetAtMs("America/New_York", 2, nowMs); + assert.equal(new Date(next).toISOString(), "2026-03-08T07:00:00.000Z"); + assert.equal(wallClock("America/New_York", next), "2026-03-08, 03:00"); +}); + +test("Havana midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-03-08: 00:00 -> 01:00 in America/Havana; midnight does not exist. + const nowMs = Date.parse("2026-03-07T20:00:00-05:00"); + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("Santiago midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-09-06: 00:00 -> 01:00 in America/Santiago; midnight does not exist. + const nowMs = Date.parse("2026-09-05T20:00:00-04:00"); + const next = nextDailyResetAtMs("America/Santiago", 0, nowMs); + assert.equal(wallClock("America/Santiago", next), "2026-09-06, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("between the old (wrong) 23:00 and the real 01:00 the reset is still ahead", () => { + const nowMs = Date.parse("2026-03-07T23:30:00-05:00"); // Havana 23:30, before the gap + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 30 * 60 * 1000); +}); + +test("fold hour keeps the first occurrence (characterization)", () => { + // 2026-11-01: fall back, 01:00 occurs twice; the first (EDT) occurrence wins. + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 1, nowMs); + assert.equal(new Date(next).toISOString(), "2026-11-01T05:00:00.000Z"); +}); + +test("a 25h fall-back day keeps its real 24.5h magnitude (characterization, no clamp)", () => { + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 0, nowMs); + assert.equal(next - nowMs, 24.5 * HOUR_MS); +}); + +test("ordinary days are unchanged", () => { + const nowMs = Date.parse("2026-01-15T10:00:00Z"); + assert.equal( + new Date(nextDailyResetAtMs("Europe/Paris", 0, nowMs)).toISOString(), + "2026-01-15T23:00:00.000Z" + ); + assert.equal( + new Date(nextDailyResetAtMs("Asia/Kolkata", 0, nowMs)).toISOString(), + "2026-01-15T18:30:00.000Z" + ); +});