// First heartbeat fires and throws
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// Second heartbeat should still fire (scheduler must not be dead)
await vi.advanceTimersByTimeAsync(30 * 60_000);
expect(runSpy).toHaveBeenCalledTimes(2);
runner.stop();
});
it("cleanup is idempotent and does not clear a newer runner's handler", async () => {
useFakeHeartbeatTime();
// Stop runner A (stale cleanup) — should NOT kill runner B's handler
runnerA.stop();
// Runner B should still fire
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy2).toHaveBeenCalledTimes(1);
expect(runSpy1).not.toHaveBeenCalled();
// Double-stop should be safe (idempotent)
runnerA.stop();
runnerB.stop();
});
it("run() returns skipped when runner is stopped", async () => {
useFakeHeartbeatTime();
// First heartbeat returns requests-in-flight
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// The wake layer retries after DEFAULT_RETRY_MS (1 s). No scheduleNext() // is called inside runOnce, so we must wait for the full cooldown.
await vi.advanceTimersByTimeAsync(1_000);
expect(runSpy).toHaveBeenCalledTimes(2);
runner.stop();
});
it("does not push nextDueMs forward on repeated requests-in-flight skips", async () => {
useFakeHeartbeatTime();
// Simulate a long-running heartbeat: the first 5 calls return // requests-in-flight (retries from the wake layer), then the 6th succeeds. const callTimes: number[] = [];
let callCount = 0; const runSpy = vi.fn().mockImplementation(async () => {
callTimes.push(Date.now());
callCount++; if (callCount <= 5) { return { status: "skipped", reason: "requests-in-flight" } as const;
} return { status: "ran", durationMs: 1 } as const;
});
// Trigger the first heartbeat at the agent's first slot — returns requests-in-flight.
await vi.advanceTimersByTimeAsync(firstDueMs + 1);
expect(runSpy).toHaveBeenCalledTimes(1);
// Simulate 4 more retries at short intervals (wake layer retries). for (let i = 0; i < 4; i++) {
requestHeartbeatNow({ reason: "retry", coalesceMs: 0 });
await vi.advanceTimersByTimeAsync(1_000);
}
expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(false);
// The next interval tick at the next scheduled slot should still fire — // the retries must not push the phase out by multiple intervals.
await vi.advanceTimersByTimeAsync(firstDueMs + intervalMs - Date.now() + 1);
expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(true);
it("clamps oversized scheduler delays so heartbeats do not fire in a tight loop (#71414)", async () => {
useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); // 365d resolves to ~31_536_000_000 ms, well past Node setTimeout's // 2_147_483_647 ms cap. Without clamping, setTimeout would fire after // 1ms and re-arm in a tight loop, exhausting the runner. const runner = startHeartbeatRunner({
cfg: heartbeatConfig([{ id: "main", heartbeat: { every: "365d" } }]),
runOnce: runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
}); // Advance well past the broken 1ms re-arm but well under the clamped cap // (~24.85d). If the bug is present, runSpy gets called many times.
await vi.advanceTimersByTimeAsync(60_000);
expect(runSpy).not.toHaveBeenCalled();
runner.stop();
});
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.