fix(heartbeat): default non-finite schedule inputs

This commit is contained in:
Peter Steinberger
2026-05-28 22:53:53 -04:00
parent 9d84a13bb8
commit 45892a6595
3 changed files with 58 additions and 7 deletions

View File

@@ -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", () => {

View File

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

View File

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