fix(gateway): keep tearing down listeners when cron.stopAndDrain() rejects on shutdown (#114848)

The close handler ran cron.stopAndDrain() and heartbeatRunner.stop() bare, while every sibling
teardown uses shutdownStep() (catch/warn/continue). stopAndDrain() re-throws stream-watcher stop
failures by design, so the rejection skipped the remaining teardown -- wss.close(), httpServer.close(),
client closes, interval clears -- leaving the port bound and timers live; the next listen() then hits
EADDRINUSE. Wrap both calls in shutdownStep. Adds a regression test.

Co-authored-by: MatthewSynthia <matthewsynthia@users.noreply.github.com>
This commit is contained in:
MatthewSynthia
2026-07-28 02:12:12 -07:00
committed by GitHub
parent 92b4af2dc8
commit dd73d4b0da
2 changed files with 26 additions and 6 deletions
+20
View File
@@ -182,6 +182,26 @@ describe("createGatewayCloseHandler", () => {
}
});
it("still runs later teardown when cron.stopAndDrain() rejects (no listener strand)", async () => {
const stopAndDrain = vi.fn().mockRejectedValue(new Error("stream watcher stop failed"));
const httpClose = vi.fn((cb: (err?: Error | null) => void) => cb(null));
const deps = createGatewayCloseTestDeps({
cron: { stop: vi.fn(), stopAndDrain } as never,
httpServer: { close: httpClose, closeIdleConnections: vi.fn() } as never,
});
const close = createGatewayCloseHandler(deps);
const result = await close({ reason: "test" });
// A rejecting stopAndDrain must be swallowed (recorded as a warning) and must NOT skip the
// remaining teardown -- otherwise the HTTP/WS listeners and timers strand and the next
// start hits EADDRINUSE.
expect(stopAndDrain).toHaveBeenCalledTimes(1);
expect(deps.heartbeatRunner.stop).toHaveBeenCalledTimes(1);
expect(httpClose).toHaveBeenCalled();
expect(result.warnings.length).toBeGreaterThan(0);
});
it("completes a clean shutdown with a ShutdownResult", async () => {
const deps = createGatewayCloseTestDeps();
const close = createGatewayCloseHandler(deps);
+6 -6
View File
@@ -883,12 +883,12 @@ export function createGatewayCloseHandler(
await measureCloseStep("gmail-watcher", () =>
shutdownStep("gmail-watcher", () => stopGmailWatcherOnDemand(), warnings),
);
if (params.cron.stopAndDrain) {
await params.cron.stopAndDrain();
} else {
params.cron.stop();
}
params.heartbeatRunner.stop();
await shutdownStep(
"cron",
() => (params.cron.stopAndDrain ? params.cron.stopAndDrain() : params.cron.stop()),
warnings,
);
await shutdownStep("heartbeat-runner", () => params.heartbeatRunner.stop(), warnings);
await shutdownStep(
"task-registry-maintenance",
() => params.stopTaskRegistryMaintenance?.(),