| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { createNoopLogger, createCronStoreHarness } from "./service.test-harness.js"; |
| import { createCronServiceState } from "./service/state.js"; |
| import { armTimer, onTimer } from "./service/timer.js"; |
| import type { CronJob } from "./types.js"; |
|
|
| const noopLogger = createNoopLogger(); |
| const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-tight-loop-" }); |
|
|
| |
| |
| |
| |
| |
| |
| function createStuckPastDueJob(params: { id: string; nowMs: number; pastDueMs: number }): CronJob { |
| const pastDueAt = params.nowMs - params.pastDueMs; |
| return { |
| id: params.id, |
| name: "stuck-job", |
| enabled: true, |
| deleteAfterRun: false, |
| createdAtMs: pastDueAt - 60_000, |
| updatedAtMs: pastDueAt - 60_000, |
| schedule: { kind: "cron", expr: "*/15 * * * *" }, |
| sessionTarget: "isolated", |
| wakeMode: "next-heartbeat", |
| payload: { kind: "agentTurn", message: "monitor" }, |
| delivery: { mode: "none" }, |
| state: { |
| nextRunAtMs: pastDueAt, |
| |
| |
| runningAtMs: pastDueAt + 1, |
| }, |
| }; |
| } |
|
|
| describe("CronService - armTimer tight loop prevention", () => { |
| function extractTimeoutDelays(timeoutSpy: ReturnType<typeof vi.spyOn>) { |
| const calls = timeoutSpy.mock.calls as Array<[unknown, unknown, ...unknown[]]>; |
| return calls |
| .map(([, delay]: [unknown, unknown, ...unknown[]]) => delay) |
| .filter((d: unknown): d is number => typeof d === "number"); |
| } |
|
|
| function createTimerState(params: { |
| storePath: string; |
| now: number; |
| runIsolatedAgentJob?: () => Promise<{ status: "ok" }>; |
| }) { |
| return createCronServiceState({ |
| storePath: params.storePath, |
| cronEnabled: true, |
| log: noopLogger, |
| nowMs: () => params.now, |
| enqueueSystemEvent: vi.fn(), |
| requestHeartbeatNow: vi.fn(), |
| runIsolatedAgentJob: |
| params.runIsolatedAgentJob ?? vi.fn().mockResolvedValue({ status: "ok" }), |
| }); |
| } |
|
|
| beforeEach(() => { |
| noopLogger.debug.mockClear(); |
| noopLogger.info.mockClear(); |
| noopLogger.warn.mockClear(); |
| noopLogger.error.mockClear(); |
| }); |
|
|
| afterEach(() => { |
| vi.clearAllMocks(); |
| }); |
|
|
| it("enforces a minimum delay when the next wake time is in the past", () => { |
| const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); |
| const now = Date.parse("2026-02-28T12:32:00.000Z"); |
| const pastDueMs = 17 * 60 * 1000; |
|
|
| const state = createTimerState({ |
| storePath: "/tmp/test-cron/jobs.json", |
| now, |
| }); |
| state.store = { |
| version: 1, |
| jobs: [createStuckPastDueJob({ id: "monitor", nowMs: now, pastDueMs })], |
| }; |
|
|
| armTimer(state); |
|
|
| expect(state.timer).not.toBeNull(); |
| const delays = extractTimeoutDelays(timeoutSpy); |
|
|
| |
| |
| expect(delays.length).toBeGreaterThan(0); |
| for (const d of delays) { |
| expect(d).toBeGreaterThanOrEqual(2_000); |
| } |
|
|
| timeoutSpy.mockRestore(); |
| }); |
|
|
| it("does not add extra delay when the next wake time is in the future", () => { |
| const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); |
| const now = Date.parse("2026-02-28T12:32:00.000Z"); |
|
|
| const state = createTimerState({ |
| storePath: "/tmp/test-cron/jobs.json", |
| now, |
| }); |
| state.store = { |
| version: 1, |
| jobs: [ |
| { |
| id: "future-job", |
| name: "future-job", |
| enabled: true, |
| deleteAfterRun: false, |
| createdAtMs: now, |
| updatedAtMs: now, |
| schedule: { kind: "cron", expr: "*/15 * * * *" }, |
| sessionTarget: "isolated" as const, |
| wakeMode: "next-heartbeat" as const, |
| payload: { kind: "agentTurn" as const, message: "test" }, |
| delivery: { mode: "none" as const }, |
| state: { nextRunAtMs: now + 10_000 }, |
| }, |
| ], |
| }; |
|
|
| armTimer(state); |
|
|
| const delays = extractTimeoutDelays(timeoutSpy); |
|
|
| |
| expect(delays).toContain(10_000); |
|
|
| timeoutSpy.mockRestore(); |
| }); |
|
|
| it("breaks the onTimer→armTimer hot-loop with stuck runningAtMs", async () => { |
| const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); |
| const store = await makeStorePath(); |
| const now = Date.parse("2026-02-28T12:32:00.000Z"); |
| const pastDueMs = 17 * 60 * 1000; |
|
|
| await fs.mkdir(path.dirname(store.storePath), { recursive: true }); |
| await fs.writeFile( |
| store.storePath, |
| JSON.stringify( |
| { |
| version: 1, |
| jobs: [createStuckPastDueJob({ id: "monitor", nowMs: now, pastDueMs })], |
| }, |
| null, |
| 2, |
| ), |
| "utf-8", |
| ); |
|
|
| const state = createTimerState({ |
| storePath: store.storePath, |
| now, |
| }); |
|
|
| |
| |
| await onTimer(state); |
|
|
| expect(state.running).toBe(false); |
| expect(state.timer).not.toBeNull(); |
|
|
| |
| |
| const allDelays = extractTimeoutDelays(timeoutSpy); |
|
|
| |
| const lastDelay = allDelays[allDelays.length - 1]; |
| expect(lastDelay).toBeGreaterThanOrEqual(2_000); |
|
|
| timeoutSpy.mockRestore(); |
| await store.cleanup(); |
| }); |
| }); |
|
|