From 5b85d0efa423d5d0d3d0de424015f2d008d9fd14 Mon Sep 17 00:00:00 2001 From: joelnishanth <140015627+joelnishanth@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:22:07 -0500 Subject: [PATCH] discord: fix stale-socket reconnect crash from uncaught reconnect-exhausted error --- CHANGELOG.md | 1 + .../src/monitor/provider.lifecycle.test.ts | 45 +++++++++++++++++++ .../discord/src/monitor/provider.lifecycle.ts | 17 ++++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0e4629b90..dda5b40980e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet. - Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw. - Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman. +- Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth. ## 2026.3.24 diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index a1cf1b8605f..9438843f95a 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -806,6 +806,51 @@ describe("runDiscordGatewayLifecycle", () => { } }); + it("suppresses reconnect-exhausted as expected during intentional shutdown", async () => { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const pendingGatewayEvents: DiscordGatewayEvent[] = []; + const abortController = new AbortController(); + + const emitter = new EventEmitter(); + const gateway = { + isConnected: true, + options: { reconnect: { maxAttempts: 50 } }, + disconnect: vi.fn(), + connect: vi.fn(), + emitter, + }; + getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); + + const { lifecycleParams, runtimeLog, runtimeError } = createLifecycleHarness({ + gateway, + pendingGatewayEvents, + }); + lifecycleParams.abortSignal = abortController.signal; + + // Start lifecycle; it yields at execApprovalsHandler.start(). We then + // queue a reconnect-exhausted event and abort. The lifecycle resumes, + // drains the event (with lifecycleStopping=true), and exits cleanly + // without reaching waitForDiscordGatewayStop. + const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); + + pendingGatewayEvents.push( + createGatewayEvent( + "reconnect-exhausted", + "Max reconnect attempts (0) reached after code 1005", + ), + ); + abortController.abort(); + + await expect(lifecyclePromise).resolves.toBeUndefined(); + + expect(runtimeLog).toHaveBeenCalledWith( + expect.stringContaining("ignoring expected reconnect-exhausted during shutdown"), + ); + expect(runtimeError).not.toHaveBeenCalledWith( + expect.stringContaining("Max reconnect attempts"), + ); + }); + it("does not push connected: true when abortSignal is already aborted", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const emitter = new EventEmitter(); diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index c6cdd2d60f6..dd8ba184ec2 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -485,6 +485,15 @@ export async function runDiscordGatewayLifecycle(params: { ); return "stop"; } + // When we deliberately set maxAttempts=0 and disconnected (health-monitor + // stale-socket restart), Carbon fires "Max reconnect attempts (0)". This + // is expected — log at info instead of error to avoid false alarms. + if (lifecycleStopping && event.type === "reconnect-exhausted") { + params.runtime.log?.( + `discord: ignoring expected reconnect-exhausted during shutdown: ${event.message}`, + ); + return "stop"; + } params.runtime.error?.(danger(`discord gateway error: ${event.message}`)); return event.shouldStopLifecycle ? "stop" : "continue"; }; @@ -494,7 +503,13 @@ export async function runDiscordGatewayLifecycle(params: { if (decision !== "stop") { return "continue"; } - if (event.type === "disallowed-intents") { + // Don't throw for expected shutdown events — intentional disconnect + // (reconnect-exhausted with maxAttempts=0) and disallowed-intents are + // both handled without crashing the provider. + if ( + event.type === "disallowed-intents" || + (lifecycleStopping && event.type === "reconnect-exhausted") + ) { return "stop"; } throw event.err;