diff --git a/extensions/discord/src/monitor/gateway-supervisor.test.ts b/extensions/discord/src/monitor/gateway-supervisor.test.ts index 5d081ded7b87..1396961bc037 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.test.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.test.ts @@ -1,6 +1,17 @@ // Discord tests cover gateway supervisor plugin behavior. import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; + +const { gatewayLogError } = vi.hoisted(() => ({ gatewayLogError: vi.fn() })); + +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSubsystemLogger: () => ({ error: gatewayLogError }), + }; +}); + import { classifyDiscordGatewayEvent, DiscordGatewayLifecycleError, @@ -115,44 +126,29 @@ describe("createDiscordGatewaySupervisor", () => { supervisor.dispose(); }); - it("keeps suppressing late gateway errors after dispose", () => { + it("keeps a single late error guard after repeated dispose", () => { const emitter = new EventEmitter(); - const runtime = { error: vi.fn() }; - const supervisor = createDiscordGatewaySupervisor({ - gateway: { emitter }, - isDisallowedIntentsError: () => false, - runtime: runtime as never, - }); + gatewayLogError.mockClear(); - supervisor.dispose(); + for (let index = 0; index < 3; index += 1) { + const supervisor = createDiscordGatewaySupervisor({ + gateway: { emitter }, + isDisallowedIntentsError: () => false, + runtime: { error: vi.fn() } as never, + }); - emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005")); - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(String(firstErrorArg(runtime))).toContain( - "suppressed late gateway reconnect-exhausted error after dispose", - ); - }); + expect(emitter.listenerCount("error")).toBe(1); + supervisor.dispose(); + expect(emitter.listenerCount("error")).toBe(1); + const error = new Error(`late gateway error ${index}`); + expect(() => emitter.emit("error", error)).not.toThrow(); + emitter.emit("error", error); + } - 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(); - const first = new TypeError(); - first.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)"; - const second = new TypeError(); - second.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)"; - emitter.emit("error", first); - emitter.emit("error", second); - - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(String(firstErrorArg(runtime))).toContain( - "suppressed late gateway fatal error after dispose: TypeError @ gatewayCrash (discord-gateway.js:12:34)", + expect(emitter.listenerCount("error")).toBe(1); + expect(gatewayLogError).toHaveBeenCalledTimes(3); + expect(gatewayLogError).toHaveBeenLastCalledWith( + "suppressed late gateway error after dispose: Error: late gateway error 2", ); }); }); diff --git a/extensions/discord/src/monitor/gateway-supervisor.ts b/extensions/discord/src/monitor/gateway-supervisor.ts index 3e5eb9c5a214..20b84a1339cd 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.ts @@ -1,6 +1,6 @@ // Discord plugin module implements gateway supervisor behavior. import type { EventEmitter } from "node:events"; -import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger, danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -41,6 +41,37 @@ export type DiscordGatewaySupervisor = { type GatewaySupervisorPhase = "active" | "buffering" | "disposed" | "teardown"; +const discordGatewayLog = createSubsystemLogger("discord/gateway"); +const discordGatewayLateErrorGuards = new WeakMap void>(); + +function removeDiscordGatewayLateErrorGuard(emitter: EventEmitter): void { + const guard = discordGatewayLateErrorGuards.get(emitter); + if (!guard) { + return; + } + emitter.off("error", guard); + discordGatewayLateErrorGuards.delete(emitter); +} + +function ensureDiscordGatewayLateErrorGuard(emitter: EventEmitter): void { + if (emitter.listenerCount("error") > 0) { + return; + } + const seenMessages = new Set(); + // Keep the emitter safe after its supervisor is gone without retaining the disposed runtime. + // A module-owned logger preserves one diagnostic per distinct late error until the next start. + const guard = (err: unknown) => { + const message = formatDiscordGatewayErrorMessage(err); + if (seenMessages.has(message)) { + return; + } + seenMessages.add(message); + discordGatewayLog.error(`suppressed late gateway error after dispose: ${message}`); + }; + discordGatewayLateErrorGuards.set(emitter, guard); + emitter.on("error", guard); +} + function readFirstStackFrame(err: Error): string | undefined { const stack = err.stack; if (!stack) { @@ -169,6 +200,7 @@ export function createDiscordGatewaySupervisor(params: { pending.push(event); } }; + removeDiscordGatewayLateErrorGuard(emitter); emitter.on("error", onGatewayError); return { @@ -198,6 +230,8 @@ export function createDiscordGatewaySupervisor(params: { if (phase === "disposed") { return; } + emitter.off("error", onGatewayError); + ensureDiscordGatewayLateErrorGuard(emitter); lifecycleHandler = undefined; phase = "disposed"; pending.length = 0; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 3dfcc85c698d..596b3651e943 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -364,7 +364,7 @@ describe("monitorDiscordProvider", () => { expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1); }); - it("disconnects the shared gateway and suppresses late gateway errors when startup fails before lifecycle begins", async () => { + it("disconnects the shared gateway and keeps a late error guard when startup fails before lifecycle begins", async () => { const disconnect = vi.fn(); const emitter = new EventEmitter(); const gateway = { emitter, disconnect, isConnected: false }; @@ -385,13 +385,10 @@ describe("monitorDiscordProvider", () => { expect(monitorLifecycleMock).not.toHaveBeenCalled(); expect(disconnect).toHaveBeenCalledTimes(1); - expect( - emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1005")), - ).toBe(true); - expectMockLogContains( - runtime.error, - "suppressed late gateway reconnect-exhausted error after dispose", - ); + expect(emitter.listenerCount("error")).toBe(1); + expect(() => + emitter.emit("error", new Error("late gateway error after cleanup")), + ).not.toThrow(); }); it("fails closed before lifecycle when Discord bot identity fetch rejects", async () => {