Files
openclaw/src/infra/restart.deferral-timeout.test.ts
Joey Krug 304703f165 fix: resume orphaned subagent sessions after SIGUSR1 reload
Closes #47711

After a SIGUSR1 gateway reload aborts in-flight subagent LLM calls, the gateway now scans for orphaned sessions and sends a synthetic resume message to restart their work. Also makes the deferral timeout configurable via gateway.reload.deferralTimeoutMs (default: 5 minutes, up from 90s).
2026-03-15 22:32:36 -07:00

120 lines
3.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __testing, deferGatewayRestartUntilIdle, type RestartDeferralHooks } from "./restart.js";
describe("deferGatewayRestartUntilIdle timeout", () => {
beforeEach(() => {
vi.useFakeTimers();
__testing.resetSigusr1State();
// Add a listener so emitGatewayRestart uses process.emit instead of process.kill
process.on("SIGUSR1", () => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
__testing.resetSigusr1State();
process.removeAllListeners("SIGUSR1");
});
it("uses default 5-minute timeout when maxWaitMs is not specified", () => {
const hooks: RestartDeferralHooks = {
onTimeout: vi.fn(),
onReady: vi.fn(),
};
// Always return 1 pending item to prevent draining
deferGatewayRestartUntilIdle({
getPendingCount: () => 1,
hooks,
});
// Advance to just before 5 minutes — should NOT have timed out yet
vi.advanceTimersByTime(299_999);
expect(hooks.onTimeout).not.toHaveBeenCalled();
// Advance past 5 minutes — should time out
vi.advanceTimersByTime(1);
expect(hooks.onTimeout).toHaveBeenCalledOnce();
expect(hooks.onReady).not.toHaveBeenCalled();
});
it("respects custom maxWaitMs configuration", () => {
const hooks: RestartDeferralHooks = {
onTimeout: vi.fn(),
onReady: vi.fn(),
};
const customTimeoutMs = 120_000; // 2 minutes
deferGatewayRestartUntilIdle({
getPendingCount: () => 1,
maxWaitMs: customTimeoutMs,
hooks,
});
// Advance to just before 2 minutes
vi.advanceTimersByTime(119_999);
expect(hooks.onTimeout).not.toHaveBeenCalled();
// Advance past 2 minutes
vi.advanceTimersByTime(1);
expect(hooks.onTimeout).toHaveBeenCalledOnce();
});
it("calls onReady and does not timeout when pending count drops to 0", () => {
const hooks: RestartDeferralHooks = {
onTimeout: vi.fn(),
onReady: vi.fn(),
};
let pending = 3;
deferGatewayRestartUntilIdle({
getPendingCount: () => pending,
hooks,
});
// Advance a few poll intervals, then drain
vi.advanceTimersByTime(1000);
expect(hooks.onReady).not.toHaveBeenCalled();
pending = 0;
vi.advanceTimersByTime(500); // Next poll interval
expect(hooks.onReady).toHaveBeenCalledOnce();
expect(hooks.onTimeout).not.toHaveBeenCalled();
});
it("immediately restarts when pending count is 0", () => {
const hooks: RestartDeferralHooks = {
onReady: vi.fn(),
onTimeout: vi.fn(),
};
deferGatewayRestartUntilIdle({
getPendingCount: () => 0,
hooks,
});
// onReady should be called synchronously
expect(hooks.onReady).toHaveBeenCalledOnce();
expect(hooks.onTimeout).not.toHaveBeenCalled();
});
it("handles getPendingCount error by restarting immediately", () => {
const hooks: RestartDeferralHooks = {
onCheckError: vi.fn(),
onReady: vi.fn(),
};
deferGatewayRestartUntilIdle({
getPendingCount: () => {
throw new Error("store corrupted");
},
hooks,
});
expect(hooks.onCheckError).toHaveBeenCalledOnce();
expect(hooks.onReady).not.toHaveBeenCalled();
});
});