fix(discord): prevent stale gateway error listeners after restart (#101617)

* fix(discord): remove gateway error listener on dispose

* fix(discord): preserve late gateway error guard

* fix(discord): retain late gateway diagnostics

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-07-09 11:25:37 +08:00
committed by GitHub
parent 1252a3da59
commit 1017ea5bc8
3 changed files with 70 additions and 43 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/runtime-env")>();
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",
);
});
});

View File

@@ -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<EventEmitter, (err: unknown) => 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<string>();
// 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;

View File

@@ -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 () => {