diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index b6d92f7c037..dcd9f41a7d8 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -479,6 +479,41 @@ describe("runDiscordGatewayLifecycle", () => { } }); + it("treats drain timeout as a graceful stop after lifecycle abort", async () => { + vi.useFakeTimers(); + try { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const socket = new EventEmitter(); + const { emitter, gateway } = createGatewayHarness({ ws: socket }); + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + + const abortController = new AbortController(); + const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } = + createLifecycleHarness({ gateway }); + lifecycleParams.abortSignal = abortController.signal; + + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + await vi.advanceTimersByTimeAsync(15_100); + abortController.abort(); + await vi.advanceTimersByTimeAsync(5_500); + await expect(lifecyclePromise).resolves.toBeUndefined(); + + expect(gateway.connect).not.toHaveBeenCalled(); + expect(runtimeError).not.toHaveBeenCalledWith( + expect.stringContaining("gateway socket did not close within 5000ms before reconnect"), + ); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + gatewaySupervisor, + }); + } finally { + vi.useRealTimers(); + } + }); + it("fails fast when startup never reaches READY after a forced reconnect", async () => { vi.useFakeTimers(); try { diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index c553979b70a..fdd01f5a034 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -219,7 +219,8 @@ export async function runDiscordGatewayLifecycle(params: { let drainTimeout: ReturnType | undefined; let terminateCloseTimeout: ReturnType | undefined; const ignoreSocketError = () => {}; - const cleanup = () => { + const shouldStopWaiting = () => lifecycleStopping || params.abortSignal?.aborted; + const clearPendingTimers = () => { if (drainTimeout) { clearTimeout(drainTimeout); drainTimeout = undefined; @@ -228,6 +229,9 @@ export async function runDiscordGatewayLifecycle(params: { clearTimeout(terminateCloseTimeout); terminateCloseTimeout = undefined; } + }; + const cleanup = () => { + clearPendingTimers(); socket.removeListener("close", onClose); socket.removeListener("error", ignoreSocketError); }; @@ -239,19 +243,28 @@ export async function runDiscordGatewayLifecycle(params: { settled = true; resolve(); }; - const rejectClose = (error: Error) => { + const resolveStoppedWait = () => { if (settled) { return; } settled = true; - if (drainTimeout) { - clearTimeout(drainTimeout); - drainTimeout = undefined; + clearPendingTimers(); + + // Keep suppressing late ws errors until the socket actually closes. + // The original Carbon listeners were removed above, and `terminate()` + // can still asynchronously emit "error" before "close". + resolve(); + }; + const rejectClose = (error: Error) => { + if (shouldStopWaiting()) { + resolveStoppedWait(); + return; } - if (terminateCloseTimeout) { - clearTimeout(terminateCloseTimeout); - terminateCloseTimeout = undefined; + if (settled) { + return; } + settled = true; + clearPendingTimers(); // Keep suppressing late ws errors until the socket actually closes. // The original Carbon listeners were removed above, and `terminate()` @@ -263,6 +276,10 @@ export async function runDiscordGatewayLifecycle(params: { if (settled) { return; } + if (shouldStopWaiting()) { + resolveStoppedWait(); + return; + } params.runtime.error?.( danger( `discord: gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect; attempting forced terminate before giving up`, @@ -298,6 +315,10 @@ export async function runDiscordGatewayLifecycle(params: { if (settled) { return; } + if (shouldStopWaiting()) { + resolveStoppedWait(); + return; + } params.runtime.error?.( danger( `discord: gateway socket did not close ${DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS}ms after forced terminate; force-stopping instead of opening a parallel socket`,