mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 23:21:38 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user