diff --git a/extensions/discord/src/monitor.gateway.test.ts b/extensions/discord/src/monitor.gateway.test.ts index cde3427965a..424165fd27b 100644 --- a/extensions/discord/src/monitor.gateway.test.ts +++ b/extensions/discord/src/monitor.gateway.test.ts @@ -89,7 +89,7 @@ describe("waitForDiscordGatewayStop", () => { emitGatewayEvent(fatalEvent); - await expect(promise).rejects.toThrow("boom"); + await expect(promise).rejects.toThrow("discord gateway fatal: Error: boom"); expect(disconnect).toHaveBeenCalledTimes(1); expect(detachLifecycle).toHaveBeenCalledTimes(1); }); @@ -178,7 +178,7 @@ describe("waitForDiscordGatewayStop", () => { emitGatewayEvent(firstEvent); - await expect(promise).rejects.toThrow("first failure"); + await expect(promise).rejects.toThrow("discord gateway fatal: Error: first failure"); expect(seenEvents.map((event) => event.message)).toEqual([ firstEvent.message, secondEvent.message, diff --git a/extensions/discord/src/monitor.gateway.ts b/extensions/discord/src/monitor.gateway.ts index fee17555e76..3ecf1a2dda5 100644 --- a/extensions/discord/src/monitor.gateway.ts +++ b/extensions/discord/src/monitor.gateway.ts @@ -1,7 +1,8 @@ import type { EventEmitter } from "node:events"; import type { DiscordGatewayHandle } from "./monitor/gateway-handle.js"; -import type { +import { DiscordGatewayEvent, + DiscordGatewayLifecycleError, DiscordGatewaySupervisor, } from "./monitor/gateway-supervisor.js"; @@ -59,7 +60,7 @@ export async function waitForDiscordGatewayStop( const onGatewayEvent = (event: DiscordGatewayEvent) => { const shouldStop = (params.onGatewayEvent?.(event) ?? "stop") === "stop"; if (shouldStop) { - finishReject(event.err); + finishReject(new DiscordGatewayLifecycleError(event)); } }; const onForceStop = (err: unknown) => { diff --git a/extensions/discord/src/monitor/gateway-supervisor.test.ts b/extensions/discord/src/monitor/gateway-supervisor.test.ts index f339cfdaa7d..c35d22d022b 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.test.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { classifyDiscordGatewayEvent, + DiscordGatewayLifecycleError, createDiscordGatewaySupervisor, } from "./gateway-supervisor.js"; @@ -32,6 +33,20 @@ describe("classifyDiscordGatewayEvent", () => { expect(transientEvent.message).toBe("TypeError"); expect(transientEvent.shouldStopLifecycle).toBe(true); }); + + it("wraps fatal lifecycle stops with discord-specific context", () => { + const event = classifyDiscordGatewayEvent({ + err: new TypeError(), + isDisallowedIntentsError: () => false, + }); + + const wrapped = new DiscordGatewayLifecycleError(event); + + expect(wrapped.name).toBe("DiscordGatewayLifecycleError"); + expect(wrapped.message).toBe("discord gateway fatal: TypeError"); + expect(wrapped.eventType).toBe("fatal"); + expect(wrapped.cause).toBeInstanceOf(TypeError); + }); }); describe("createDiscordGatewaySupervisor", () => { @@ -101,4 +116,23 @@ describe("createDiscordGatewaySupervisor", () => { expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"), ); }); + + it("dedupes identical late gateway errors after dispose", () => { + const emitter = new EventEmitter(); + const runtime = { error: vi.fn() }; + const supervisor = createDiscordGatewaySupervisor({ + gateway: { emitter }, + isDisallowedIntentsError: () => false, + runtime: runtime as never, + }); + + supervisor.dispose(); + emitter.emit("error", new TypeError()); + emitter.emit("error", new TypeError()); + + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("suppressed late gateway fatal error after dispose: TypeError"), + ); + }); }); diff --git a/extensions/discord/src/monitor/gateway-supervisor.ts b/extensions/discord/src/monitor/gateway-supervisor.ts index b67b40dcec4..e2ebcbee8ec 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.ts @@ -17,6 +17,18 @@ export type DiscordGatewayEvent = { shouldStopLifecycle: boolean; }; +export class DiscordGatewayLifecycleError extends Error { + readonly eventType: DiscordGatewayEventType; + + constructor(event: Pick) { + super(`discord gateway ${event.type}: ${event.message}`, { + cause: event.err instanceof Error ? event.err : undefined, + }); + this.name = "DiscordGatewayLifecycleError"; + this.eventType = event.type; + } +} + export type DiscordGatewaySupervisor = { emitter?: EventEmitter; attachLifecycle: (handler: (event: DiscordGatewayEvent) => void) => void; @@ -97,9 +109,15 @@ export function createDiscordGatewaySupervisor(params: { let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined; let phase: GatewaySupervisorPhase = "buffering"; + const seenLateEventKeys = new Set(); const logLateEvent = (state: Extract) => (event: DiscordGatewayEvent) => { + const key = `${state}:${event.type}:${event.message}`; + if (seenLateEventKeys.has(key)) { + return; + } + seenLateEventKeys.add(key); params.runtime.error?.( danger( `discord: suppressed late gateway ${event.type} error ${ diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 84e0b0af652..d8f8ff54e50 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -383,7 +383,7 @@ describe("runDiscordGatewayLifecycle", () => { }); await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow( - "Fatal Gateway error: 4000", + "discord gateway fatal: Error: Fatal Gateway error: 4000", ); expectLifecycleCleanup({ @@ -406,7 +406,7 @@ describe("runDiscordGatewayLifecycle", () => { }); await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow( - "Max reconnect attempts (50) reached after code 1005", + "discord gateway reconnect-exhausted: Error: Max reconnect attempts (50) reached after code 1005", ); expectLifecycleCleanup({ @@ -438,9 +438,11 @@ describe("runDiscordGatewayLifecycle", () => { lifecyclePromise.catch(() => {}); await vi.advanceTimersByTimeAsync(1_500); - await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001"); + await expect(lifecyclePromise).rejects.toThrow( + "discord gateway fatal: Error: Fatal Gateway error: 4001", + ); expect(runtimeError).toHaveBeenCalledWith( - expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"), + expect.stringContaining("discord gateway fatal: Error: Fatal Gateway error: 4001"), ); expect(gateway.disconnect).not.toHaveBeenCalled(); expect(gateway.connect).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index 9e6a438c005..7e8820e8bce 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -6,7 +6,11 @@ import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor. import type { DiscordVoiceManager } from "../voice/manager.js"; import type { MutableDiscordGateway } from "./gateway-handle.js"; import { registerGateway, unregisterGateway } from "./gateway-registry.js"; -import type { DiscordGatewayEvent, DiscordGatewaySupervisor } from "./gateway-supervisor.js"; +import { + DiscordGatewayLifecycleError, + type DiscordGatewayEvent, + type DiscordGatewaySupervisor, +} from "./gateway-supervisor.js"; import type { DiscordMonitorStatusSink } from "./status.js"; const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000; @@ -401,7 +405,13 @@ export async function runDiscordGatewayLifecycle(params: { if (event.shouldStopLifecycle) { lifecycleStopping = true; } - params.runtime.error?.(danger(`discord gateway error: ${event.message}`)); + params.runtime.error?.( + danger( + event.shouldStopLifecycle + ? `discord gateway ${event.type}: ${event.message}` + : `discord gateway error: ${event.message}`, + ), + ); return event.shouldStopLifecycle ? "stop" : "continue"; }; const drainPendingGatewayErrors = (): "continue" | "stop" => @@ -413,7 +423,7 @@ export async function runDiscordGatewayLifecycle(params: { if (event.type === "disallowed-intents") { return "stop"; } - throw event.err; + throw new DiscordGatewayLifecycleError(event); }); try { if (params.execApprovalsHandler) {