discord: fix stale-socket reconnect crash from uncaught reconnect-exhausted error

This commit is contained in:
joelnishanth
2026-03-26 13:22:07 -05:00
committed by Peter Steinberger
parent 9f8c4efa9b
commit 5b85d0efa4
3 changed files with 62 additions and 1 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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;