diff --git a/src/infra/heartbeat-schedule.test.ts b/src/infra/heartbeat-schedule.test.ts index 9f01e459c25..c379aebc4dc 100644 --- a/src/infra/heartbeat-schedule.test.ts +++ b/src/infra/heartbeat-schedule.test.ts @@ -61,6 +61,37 @@ describe("heartbeat schedule helpers", () => { }), ).toBe(nextDueMs); }); + + it("falls back to finite schedule values for non-finite numeric inputs", () => { + expect( + resolveHeartbeatPhaseMs({ + schedulerSeed: "device-a", + agentId: "main", + intervalMs: Number.NaN, + }), + ).toBe(0); + + expect( + computeNextHeartbeatPhaseDueMs({ + nowMs: Number.NaN, + intervalMs: Number.NaN, + phaseMs: Number.NaN, + }), + ).toBe(1); + + expect( + resolveNextHeartbeatDueMs({ + nowMs: 10, + intervalMs: Number.NaN, + phaseMs: Number.NaN, + prev: { + intervalMs: 1, + phaseMs: 0, + nextDueMs: 20, + }, + }), + ).toBe(20); + }); }); describe("seekNextActivePhaseDueMs", () => { diff --git a/src/infra/heartbeat-schedule.ts b/src/infra/heartbeat-schedule.ts index 6df73e1e0f0..153542f2f34 100644 --- a/src/infra/heartbeat-schedule.ts +++ b/src/infra/heartbeat-schedule.ts @@ -1,4 +1,9 @@ import { createHash } from "node:crypto"; +import { resolveIntegerOption } from "./numeric-options.js"; + +function resolvePositiveIntervalMs(value: number): number { + return resolveIntegerOption(value, 1, { min: 1 }); +} function normalizeModulo(value: number, divisor: number) { return ((value % divisor) + divisor) % divisor; @@ -9,7 +14,7 @@ export function resolveHeartbeatPhaseMs(params: { agentId: string; intervalMs: number; }) { - const intervalMs = Math.max(1, Math.floor(params.intervalMs)); + const intervalMs = resolvePositiveIntervalMs(params.intervalMs); const digest = createHash("sha256").update(`${params.schedulerSeed}:${params.agentId}`).digest(); return digest.readUInt32BE(0) % intervalMs; } @@ -19,9 +24,12 @@ export function computeNextHeartbeatPhaseDueMs(params: { intervalMs: number; phaseMs: number; }) { - const intervalMs = Math.max(1, Math.floor(params.intervalMs)); - const nowMs = Math.floor(params.nowMs); - const phaseMs = normalizeModulo(Math.floor(params.phaseMs), intervalMs); + const intervalMs = resolvePositiveIntervalMs(params.intervalMs); + const nowMs = Number.isFinite(params.nowMs) ? Math.floor(params.nowMs) : 0; + const phaseMs = normalizeModulo( + Number.isFinite(params.phaseMs) ? Math.floor(params.phaseMs) : 0, + intervalMs, + ); const cyclePositionMs = normalizeModulo(nowMs, intervalMs); let deltaMs = normalizeModulo(phaseMs - cyclePositionMs, intervalMs); if (deltaMs === 0) { @@ -40,8 +48,11 @@ export function resolveNextHeartbeatDueMs(params: { nextDueMs: number; }; }) { - const intervalMs = Math.max(1, Math.floor(params.intervalMs)); - const phaseMs = normalizeModulo(Math.floor(params.phaseMs), intervalMs); + const intervalMs = resolvePositiveIntervalMs(params.intervalMs); + const phaseMs = normalizeModulo( + Number.isFinite(params.phaseMs) ? Math.floor(params.phaseMs) : 0, + intervalMs, + ); const prev = params.prev; if ( prev && @@ -81,7 +92,7 @@ export function seekNextActivePhaseDueMs(params: { if (!isActive) { return params.startMs; } - const intervalMs = Math.max(1, Math.floor(params.intervalMs)); + const intervalMs = resolvePositiveIntervalMs(params.intervalMs); const horizonMs = params.startMs + MAX_SEEK_HORIZON_MS; let candidateMs = params.startMs; let iterations = 0; diff --git a/src/infra/numeric-options.ts b/src/infra/numeric-options.ts index 2907881db37..a3934cbfd04 100644 --- a/src/infra/numeric-options.ts +++ b/src/infra/numeric-options.ts @@ -1,3 +1,12 @@ export function resolveNonNegativeIntegerOption(value: number, fallback: number): number { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback; } + +export function resolveIntegerOption( + value: number, + fallback: number, + params: { min: number }, +): number { + const candidate = Number.isFinite(value) ? value : fallback; + return Math.max(params.min, Math.floor(candidate)); +}