Files
openclaw/src/utils/timer-delay.test.ts
hcl fd74fc5a4f fix(heartbeat): clamp scheduler delay to Node setTimeout cap (#71414) (#71478)
* fix(heartbeat): clamp scheduler delay to Node setTimeout cap (#71414)

When `agents.defaults.heartbeat.every` resolves to >2_147_483_647 ms
(~24.85d), the previous scheduleNext() called setTimeout with the raw
delay. Node clamps any delay > 2^31-1 to 1 ms, fires the callback, and
the heartbeat re-arms with the same oversized value - a tight loop that
floods the log with TimeoutOverflowWarning and crashes the gateway with
exit code 1.

Clamp the computed delay to HEARTBEAT_MAX_TIMEOUT_MS (2_147_483_647)
before calling setTimeout. The worst case is now one heartbeat every
~24.85d instead of crash-loop. Warn once per process when clamping
fires, so a misconfigured "365d" remains visible without flooding.

This is a defense-in-depth fix at the scheduler layer; loadConfig-level
rejection is a broader change with more blast radius and a separate
question (some users may legitimately want "every: 365d" to mean
"effectively never"). The clamped behaviour is closer to that intent
than the crash is.

Test: new scheduler test sets heartbeat.every="365d" with fake timers,
advances 60s, and asserts runSpy was never called (with the bug, it
would be called ~60_000 times).

* style: format heartbeat scheduler clamp

* fix: share safe timeout delay clamp (#71478) (thanks @hclsys)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-25 10:03:43 +01:00

35 lines
1.1 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
MAX_SAFE_TIMEOUT_DELAY_MS,
resolveSafeTimeoutDelayMs,
setSafeTimeout,
} from "./timer-delay.js";
describe("resolveSafeTimeoutDelayMs", () => {
it("clamps to Node's signed-32-bit timer ceiling", () => {
expect(resolveSafeTimeoutDelayMs(3_000_000_000)).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
it("respects custom minimums", () => {
expect(resolveSafeTimeoutDelayMs(10, { minMs: 250 })).toBe(250);
expect(resolveSafeTimeoutDelayMs(10, { minMs: 0 })).toBe(10);
});
it("falls back to the minimum for non-finite input", () => {
expect(resolveSafeTimeoutDelayMs(Number.POSITIVE_INFINITY, { minMs: 250 })).toBe(250);
expect(resolveSafeTimeoutDelayMs(Number.NaN)).toBe(1);
});
});
describe("setSafeTimeout", () => {
it("arms setTimeout with the clamped delay", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const timer = setSafeTimeout(() => undefined, 3_000_000_000);
clearTimeout(timer);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
timeoutSpy.mockRestore();
});
});