fix(heartbeat): add iteration cap to active-hours seek + edge-case tests

This commit is contained in:
Alex Knight
2026-05-01 21:32:37 +10:00
committed by clawsweeper
parent a794fe7975
commit 24d3da402f
2 changed files with 69 additions and 3 deletions

View File

@@ -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:0017: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);
});
});

View File

@@ -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;
}