Skip to content

Commit f4f98f4

Browse files
authored
fix(gateway): cancel post-ready maintenance on close
Fixes the post-ready maintenance shutdown race by marking close before gateway_stop hooks, clearing delayed timers, and suppressing already-fired maintenance work during shutdown. Verification: - pnpm test:serial src/gateway/server-runtime-services.test.ts src/gateway/server-import-boundary.test.ts - pnpm exec oxfmt --check --threads=1 src/gateway/server.impl.ts src/gateway/server-import-boundary.test.ts src/gateway/server-runtime-services.ts src/gateway/server-runtime-services.test.ts - git diff --check - crabbox blacksmith-testbox tbx_01kqrw87d527jwcfxbp6qk1wc3: pnpm check:changed (exit 0)
1 parent 8a8a125 commit f4f98f4

4 files changed

Lines changed: 201 additions & 24 deletions

File tree

src/gateway/server-import-boundary.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,15 @@ describe("gateway startup import boundaries", () => {
5050
expect(validation).not.toContain("legacy-secretref-env-marker");
5151
expect(validation).not.toContain("commands/doctor");
5252
});
53+
54+
it("marks gateway close before awaiting gateway_stop hooks", () => {
55+
const serverImpl = readSource("src/gateway/server.impl.ts");
56+
const closeStart = serverImpl.indexOf("close: async (opts)");
57+
const hookStart = serverImpl.indexOf("runGlobalGatewayStopSafely", closeStart);
58+
const markStart = serverImpl.indexOf("markClosePreludeStarted();", closeStart);
59+
60+
expect(closeStart).toBeGreaterThan(-1);
61+
expect(markStart).toBeGreaterThan(closeStart);
62+
expect(markStart).toBeLessThan(hookStart);
63+
});
5364
});

src/gateway/server-runtime-services.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ vi.mock("./model-pricing-cache.js", () => ({
5555
const {
5656
activateGatewayScheduledServices,
5757
runGatewayPostReadyMaintenance,
58+
scheduleGatewayPostReadyMaintenance,
5859
startGatewayRuntimeServices,
5960
} = await import("./server-runtime-services.js");
6061

@@ -245,6 +246,64 @@ describe("server-runtime-services", () => {
245246
expect(recordPostReadyMemory).toHaveBeenCalledTimes(1);
246247
});
247248

249+
it("returns a cancellable post-ready maintenance timer", async () => {
250+
vi.useFakeTimers();
251+
const startMaintenance = vi.fn(async () => null);
252+
const onStarted = vi.fn();
253+
const handle = scheduleGatewayPostReadyMaintenance(
254+
createPostReadyMaintenanceScheduleParams({
255+
delayMs: 25,
256+
onStarted,
257+
startMaintenance,
258+
}),
259+
);
260+
261+
clearTimeout(handle);
262+
await vi.advanceTimersByTimeAsync(25);
263+
264+
expect(onStarted).not.toHaveBeenCalled();
265+
expect(startMaintenance).not.toHaveBeenCalled();
266+
});
267+
268+
it("clears delayed maintenance handles when close starts during maintenance startup", async () => {
269+
vi.useFakeTimers();
270+
let closing = false;
271+
let resolveMaintenance!: (maintenance: ReturnType<typeof createMaintenanceHandles>) => void;
272+
const startMaintenance = vi.fn(
273+
() =>
274+
new Promise<ReturnType<typeof createMaintenanceHandles>>((resolve) => {
275+
resolveMaintenance = resolve;
276+
}),
277+
);
278+
const applyMaintenance = vi.fn();
279+
const cron = { start: vi.fn(async () => undefined) };
280+
const recordPostReadyMemory = vi.fn();
281+
282+
scheduleGatewayPostReadyMaintenance(
283+
createPostReadyMaintenanceScheduleParams({
284+
delayMs: 25,
285+
isClosing: () => closing,
286+
startMaintenance,
287+
applyMaintenance,
288+
cron,
289+
recordPostReadyMemory,
290+
}),
291+
);
292+
293+
await vi.advanceTimersByTimeAsync(25);
294+
expect(startMaintenance).toHaveBeenCalledTimes(1);
295+
296+
closing = true;
297+
resolveMaintenance(createMaintenanceHandles());
298+
await Promise.resolve();
299+
await Promise.resolve();
300+
301+
expect(applyMaintenance).not.toHaveBeenCalled();
302+
expect(cron.start).not.toHaveBeenCalled();
303+
expect(recordPostReadyMemory).not.toHaveBeenCalled();
304+
expect(vi.getTimerCount()).toBe(0);
305+
});
306+
248307
it("keeps scheduled services disabled for minimal test gateways", () => {
249308
const cron = { start: vi.fn(async () => undefined) };
250309

@@ -279,3 +338,30 @@ function createLog() {
279338
error: vi.fn(),
280339
};
281340
}
341+
342+
function createPostReadyMaintenanceScheduleParams(
343+
overrides: Partial<Parameters<typeof scheduleGatewayPostReadyMaintenance>[0]> = {},
344+
): Parameters<typeof scheduleGatewayPostReadyMaintenance>[0] {
345+
return {
346+
delayMs: 1,
347+
isClosing: () => false,
348+
startMaintenance: vi.fn(async () => null),
349+
applyMaintenance: vi.fn(),
350+
shouldStartCron: () => true,
351+
markCronStartHandled: vi.fn(),
352+
cron: { start: vi.fn(async () => undefined) },
353+
logCron: { error: vi.fn() },
354+
log: createLog(),
355+
recordPostReadyMemory: vi.fn(),
356+
...overrides,
357+
};
358+
}
359+
360+
function createMaintenanceHandles() {
361+
return {
362+
tickInterval: setInterval(() => undefined, 60_000),
363+
healthInterval: setInterval(() => undefined, 60_000),
364+
dedupeCleanup: setInterval(() => undefined, 60_000),
365+
mediaCleanup: setInterval(() => undefined, 60_000),
366+
};
367+
}

src/gateway/server-runtime-services.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type GatewayRuntimeServiceLogger = {
1818
type GatewayPostReadyLogger = {
1919
warn: (message: string) => void;
2020
};
21-
type GatewayMaintenanceHandles = NonNullable<
21+
export type GatewayMaintenanceHandles = NonNullable<
2222
Awaited<ReturnType<typeof startGatewayMaintenanceTimers>>
2323
>;
2424

@@ -60,6 +60,18 @@ export function startGatewayCronWithLogging(params: {
6060
void params.cron.start().catch((err) => params.logCron.error(`failed to start: ${String(err)}`));
6161
}
6262

63+
function clearGatewayMaintenanceHandles(maintenance: GatewayMaintenanceHandles | null): void {
64+
if (!maintenance) {
65+
return;
66+
}
67+
clearInterval(maintenance.tickInterval);
68+
clearInterval(maintenance.healthInterval);
69+
clearInterval(maintenance.dedupeCleanup);
70+
if (maintenance.mediaCleanup) {
71+
clearInterval(maintenance.mediaCleanup);
72+
}
73+
}
74+
6375
export async function runGatewayPostReadyMaintenance(params: {
6476
startMaintenance: () => Promise<GatewayMaintenanceHandles | null>;
6577
applyMaintenance: (maintenance: GatewayMaintenanceHandles) => void;
@@ -88,6 +100,59 @@ export async function runGatewayPostReadyMaintenance(params: {
88100
params.recordPostReadyMemory();
89101
}
90102

103+
export function scheduleGatewayPostReadyMaintenance(params: {
104+
delayMs: number;
105+
isClosing: () => boolean;
106+
onStarted?: () => void;
107+
startMaintenance: () => Promise<GatewayMaintenanceHandles | null>;
108+
applyMaintenance: (maintenance: GatewayMaintenanceHandles) => void;
109+
shouldStartCron: () => boolean;
110+
markCronStartHandled: () => void;
111+
cron: { start: () => Promise<void> };
112+
logCron: { error: (message: string) => void };
113+
log: GatewayPostReadyLogger;
114+
recordPostReadyMemory: () => void;
115+
}): ReturnType<typeof setTimeout> {
116+
const timer = setTimeout(() => {
117+
params.onStarted?.();
118+
if (params.isClosing()) {
119+
return;
120+
}
121+
void runGatewayPostReadyMaintenance({
122+
startMaintenance: async () => {
123+
if (params.isClosing()) {
124+
return null;
125+
}
126+
const maintenance = await params.startMaintenance();
127+
if (params.isClosing()) {
128+
clearGatewayMaintenanceHandles(maintenance);
129+
return null;
130+
}
131+
return maintenance;
132+
},
133+
applyMaintenance: (maintenance) => {
134+
if (params.isClosing()) {
135+
clearGatewayMaintenanceHandles(maintenance);
136+
return;
137+
}
138+
params.applyMaintenance(maintenance);
139+
},
140+
shouldStartCron: () => !params.isClosing() && params.shouldStartCron(),
141+
markCronStartHandled: params.markCronStartHandled,
142+
cron: params.cron,
143+
logCron: params.logCron,
144+
log: params.log,
145+
recordPostReadyMemory: () => {
146+
if (!params.isClosing()) {
147+
params.recordPostReadyMemory();
148+
}
149+
},
150+
});
151+
}, params.delayMs);
152+
timer.unref?.();
153+
return timer;
154+
}
155+
91156
function recoverPendingOutboundDeliveries(params: {
92157
cfg: OpenClawConfig;
93158
log: GatewayRuntimeServiceLogger;

src/gateway/server.impl.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -894,8 +894,20 @@ export async function startGatewayServer(
894894
deps.cron = runtimeState.cronState.cron;
895895

896896
let closePreludeStarted = false;
897-
const runClosePrelude = async () => {
897+
let postReadyMaintenanceTimer: ReturnType<typeof setTimeout> | null = null;
898+
const clearPostReadyMaintenanceTimer = () => {
899+
if (!postReadyMaintenanceTimer) {
900+
return;
901+
}
902+
clearTimeout(postReadyMaintenanceTimer);
903+
postReadyMaintenanceTimer = null;
904+
};
905+
const markClosePreludeStarted = () => {
898906
closePreludeStarted = true;
907+
clearPostReadyMaintenanceTimer();
908+
};
909+
const runClosePrelude = async () => {
910+
markClosePreludeStarted();
899911
clearCurrentPluginMetadataSnapshot();
900912
const { runGatewayClosePrelude } = await loadGatewayCloseModule();
901913
await runGatewayClosePrelude({
@@ -1492,28 +1504,30 @@ export async function startGatewayServer(
14921504
log.warn(`gateway: failed to promote config last-known-good backup: ${String(err)}`);
14931505
});
14941506
if (!minimalTestGateway) {
1495-
const handle = setTimeout(() => {
1496-
void gatewayRuntimeServices.runGatewayPostReadyMaintenance({
1497-
startMaintenance: earlyRuntime.startMaintenance,
1498-
applyMaintenance: (maintenance) => {
1499-
runtimeState.tickInterval = maintenance.tickInterval;
1500-
runtimeState.healthInterval = maintenance.healthInterval;
1501-
runtimeState.dedupeCleanup = maintenance.dedupeCleanup;
1502-
runtimeState.mediaCleanup = maintenance.mediaCleanup;
1503-
},
1504-
shouldStartCron: () => !gatewayCronStartHandled,
1505-
markCronStartHandled: () => {
1506-
gatewayCronStartHandled = true;
1507-
},
1508-
cron: runtimeState.cronState.cron,
1509-
logCron,
1510-
log,
1511-
recordPostReadyMemory: () => {
1512-
startupTrace.detail("memory.post-ready", collectProcessMemoryUsageMb());
1513-
},
1514-
});
1515-
}, POST_READY_MAINTENANCE_DELAY_MS);
1516-
handle.unref?.();
1507+
postReadyMaintenanceTimer = gatewayRuntimeServices.scheduleGatewayPostReadyMaintenance({
1508+
delayMs: POST_READY_MAINTENANCE_DELAY_MS,
1509+
isClosing: () => closePreludeStarted,
1510+
onStarted: () => {
1511+
postReadyMaintenanceTimer = null;
1512+
},
1513+
startMaintenance: earlyRuntime.startMaintenance,
1514+
applyMaintenance: (maintenance) => {
1515+
runtimeState.tickInterval = maintenance.tickInterval;
1516+
runtimeState.healthInterval = maintenance.healthInterval;
1517+
runtimeState.dedupeCleanup = maintenance.dedupeCleanup;
1518+
runtimeState.mediaCleanup = maintenance.mediaCleanup;
1519+
},
1520+
shouldStartCron: () => !gatewayCronStartHandled,
1521+
markCronStartHandled: () => {
1522+
gatewayCronStartHandled = true;
1523+
},
1524+
cron: runtimeState.cronState.cron,
1525+
logCron,
1526+
log,
1527+
recordPostReadyMemory: () => {
1528+
startupTrace.detail("memory.post-ready", collectProcessMemoryUsageMb());
1529+
},
1530+
});
15171531
} else {
15181532
startupTrace.detail("memory.post-ready", collectProcessMemoryUsageMb());
15191533
}
@@ -1527,6 +1541,7 @@ export async function startGatewayServer(
15271541
return {
15281542
close: async (opts) => {
15291543
try {
1544+
markClosePreludeStarted();
15301545
// Run gateway_stop plugin hook before shutdown
15311546
const { runGlobalGatewayStopSafely } = await import("../plugins/hook-runner-global.js");
15321547
await runGlobalGatewayStopSafely({

0 commit comments

Comments
 (0)