From 24d3da402f3361d8e7dd4baeb3b1e2a649251c63 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Fri, 1 May 2026 21:32:37 +1000 Subject: [PATCH] fix(heartbeat): add iteration cap to active-hours seek + edge-case tests --- src/infra/heartbeat-schedule.test.ts | 54 ++++++++++++++++++++++++++++ src/infra/heartbeat-schedule.ts | 18 ++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/infra/heartbeat-schedule.test.ts b/src/infra/heartbeat-schedule.test.ts index 1fc085785e5..126e3c8ab3d 100644 --- a/src/infra/heartbeat-schedule.test.ts +++ b/src/infra/heartbeat-schedule.test.ts @@ -192,4 +192,58 @@ describe("seekNextActivePhaseDueMs", () => { // Should skip 32 half-hour slots (17:00 through 08:30) to reach 09:00 next day expect(result).toBe(Date.parse("2026-01-02T09:00:00.000Z")); }); + + it("caps iterations for pathological sub-second intervals", () => { + // 1ms interval with always-false predicate — without the iteration cap + // this would loop ~604 million times. The cap (10 080) prevents that. + const startMs = Date.parse("2026-01-01T12:00:00.000Z"); + const t0 = performance.now(); + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: 1, // 1ms — pathological + phaseMs: 0, + isActive: () => false, + }); + const elapsedMs = performance.now() - t0; + + // Falls back to startMs (runtime guard will handle it). + expect(result).toBe(startMs); + // Must complete quickly — without the cap this would take minutes. + expect(elapsedMs).toBeLessThan(500); + }); + + it("handles intervalMs larger than the seek horizon", () => { + // 8-day interval — only the startMs candidate is checked within horizon. + const startMs = Date.parse("2026-01-01T03:00:00.000Z"); + const eightDays = 8 * 24 * HOUR; + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: eightDays, + phaseMs: 0, + isActive: (ms) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 9 && hour < 17; + }, + }); + + // startMs (03:00) is outside 09:00–17:00. The next candidate would be + // 8 days later which is past the 7-day horizon. Falls back to startMs. + expect(result).toBe(startMs); + }); + + it("returns startMs when intervalMs larger than horizon and startMs is active", () => { + const startMs = Date.parse("2026-01-01T12:00:00.000Z"); // 12:00 — active + const eightDays = 8 * 24 * HOUR; + const result = seekNextActivePhaseDueMs({ + startMs, + intervalMs: eightDays, + phaseMs: 0, + isActive: (ms) => { + const hour = new Date(ms).getUTCHours(); + return hour >= 9 && hour < 17; + }, + }); + + expect(result).toBe(startMs); + }); }); diff --git a/src/infra/heartbeat-schedule.ts b/src/infra/heartbeat-schedule.ts index 5b432476a1b..d9c3da59b96 100644 --- a/src/infra/heartbeat-schedule.ts +++ b/src/infra/heartbeat-schedule.ts @@ -66,9 +66,18 @@ export function resolveNextHeartbeatDueMs(params: { * * `isActive` is a predicate that mirrors `isWithinActiveHours` — the caller * binds the config/heartbeat so this module stays config-agnostic. + * + * `phaseMs` is accepted for call-site symmetry but unused: the caller passes + * a phase-aligned `startMs`, and advancing by `intervalMs` preserves alignment. */ const MAX_SEEK_HORIZON_MS = 7 * 24 * 60 * 60_000; // 7 days +// Cap iterations so pathological sub-minute intervals (e.g. "1s", "1ms") +// cannot block the event loop. 10 080 = 7 days at 1-minute steps — generous +// enough for any reasonable config. Pathological configs fall back to the raw +// slot where the runtime guard still gates execution. +const MAX_SEEK_ITERATIONS = 10_080; + export function seekNextActivePhaseDueMs(params: { startMs: number; intervalMs: number; @@ -82,13 +91,16 @@ export function seekNextActivePhaseDueMs(params: { const intervalMs = Math.max(1, Math.floor(params.intervalMs)); const horizonMs = params.startMs + MAX_SEEK_HORIZON_MS; let candidateMs = params.startMs; - while (candidateMs <= horizonMs) { + let iterations = 0; + while (candidateMs <= horizonMs && iterations < MAX_SEEK_ITERATIONS) { if (isActive(candidateMs)) { return candidateMs; } candidateMs += intervalMs; + iterations++; } - // All slots within the seek horizon fall outside active hours — return the - // raw first slot so the runtime execution guard can still gate it. + // All slots within the seek horizon (or iteration cap) fall outside active + // hours — return the raw first slot so the runtime execution guard can + // still gate it. return params.startMs; }