From dd73d4b0da2deddd59b4d026ee9f8bb32366f754 Mon Sep 17 00:00:00 2001 From: MatthewSynthia Date: Tue, 28 Jul 2026 02:12:12 -0700 Subject: [PATCH] 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 --- src/gateway/server-close.test.ts | 20 ++++++++++++++++++++ src/gateway/server-close.ts | 12 ++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 04fa3580fc4d..d5e75b11acc5 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -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); diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index a27f6d09477a..ee9a7b28c5e1 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -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?.(),