From d4a8fdb6cea510d46e9c281887b79976cbdc5ae1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 23:10:53 +0100 Subject: [PATCH] fix(discord): supervise gateway registration failures --- CHANGELOG.md | 1 + .../discord/src/monitor/gateway-plugin.ts | 22 ++- .../src/monitor/provider.proxy.test.ts | 78 +++++++++- .../src/monitor/provider.startup.test.ts | 137 ++++++++++++++---- .../discord/src/monitor/provider.startup.ts | 8 +- extensions/discord/src/monitor/provider.ts | 6 +- .../src/test-support/provider.test-support.ts | 1 + 7 files changed, 221 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e214704c9..4b7f011d65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - Plugin SDK/tool-result transforms: bound middleware `details`, validate in-place result mutations, and mark fail-closed middleware fallbacks with canonical `error` status. Thanks @vincentkoc. - Discord/gateway: prevent startup from getting stuck at `awaiting gateway readiness` when Carbon gateway registration races with a lifecycle reconnect. Fixes #52372. (#68159) Thanks @IVY-AI-gif. +- Discord/gateway: supervise Carbon's async gateway registration promise so fatal Discord metadata failures surface through startup instead of process-level unhandled rejections. (#62451) Thanks @safzanpirani. - Plugins/cache: restore plugin command and interactive handler registries on loader cache hits without resetting interactive callback dedupe, so cached external plugins keep slash commands and callback handlers available after reloads. Fixes #71100. Thanks @BomBastikDE. - Gateway/OpenAI-compatible: report non-zero token usage for `/v1/chat/completions` when the agent run has only last-call usage metadata available. Fixes #71118. (#71242) Thanks @RenzoMXD. - Plugin SDK/tool-result transforms: restrict harness tool-result middleware to bundled plugins, fail closed on middleware errors, validate rewritten result shapes, preserve Pi per-call ids, and keep Codex media trust checks anchored to raw tool provenance. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 0aea4942ce6..48091beef01 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -32,6 +32,7 @@ type DiscordGatewayFetch = ( type DiscordGatewayMetadataError = Error & { transient?: boolean }; type DiscordGatewayWebSocketCtor = new (url: string, options?: { agent?: unknown }) => ws.WebSocket; +const registrationPromises = new WeakMap>(); type CarbonGatewayRegistrationState = { client?: Parameters[0]; ws?: unknown; @@ -288,7 +289,17 @@ function createGatewayPlugin(params: { super.connect(resume); } - override async registerClient( + override registerClient(client: Parameters[0]) { + const registration = this.registerClientInternal(client); + // Carbon 0.16 invokes async plugin hooks from Client construction without + // awaiting them. Mark the promise handled immediately, then let OpenClaw + // startup await the original promise explicitly. + registration.catch(() => {}); + registrationPromises.set(this, registration); + return registration; + } + + private async registerClientInternal( client: Parameters[0], ) { // Carbon's Client constructor does not await plugin registerClient(). @@ -387,6 +398,15 @@ function createGatewayPlugin(params: { return new SafeGatewayPlugin(); } +export function waitForDiscordGatewayPluginRegistration( + plugin: unknown, +): Promise | undefined { + if (typeof plugin !== "object" || plugin === null) { + return undefined; + } + return registrationPromises.get(plugin as carbonGateway.GatewayPlugin); +} + export function createDiscordGatewayPlugin(params: { discordConfig: DiscordAccountConfig; runtime: RuntimeEnv; diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 8965230462d..e6e8a85f87c 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -140,9 +140,11 @@ vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({ describe("createDiscordGatewayPlugin", () => { let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin; + let waitForDiscordGatewayPluginRegistration: typeof import("./gateway-plugin.js").waitForDiscordGatewayPluginRegistration; beforeAll(async () => { - ({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js")); + ({ createDiscordGatewayPlugin, waitForDiscordGatewayPluginRegistration } = + await import("./gateway-plugin.js")); }); function createRuntime() { @@ -190,6 +192,22 @@ describe("createDiscordGatewayPlugin", () => { }); } + function startIgnoredGatewayRegistration(plugin: unknown) { + void ( + plugin as { + registerClient: (client: { + options: { token: string }; + registerListener: typeof baseRegisterClientSpy; + unregisterListener: ReturnType; + }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + registerListener: baseRegisterClientSpy, + unregisterListener: vi.fn(), + }); + } + async function expectGatewayRegisterFetchFailure(response: Response) { const runtime = createRuntime(); globalFetchMock.mockResolvedValue(response); @@ -326,6 +344,64 @@ describe("createDiscordGatewayPlugin", () => { } as Response); }); + it("keeps Carbon-ignored fatal metadata failures handled for supervised startup", async () => { + const runtime = createRuntime(); + const unhandledReasons: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledReasons.push(reason); + }; + globalFetchMock.mockResolvedValue({ + ok: false, + status: 401, + text: async () => "401: Unauthorized", + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + process.on("unhandledRejection", onUnhandledRejection); + try { + startIgnoredGatewayRegistration(plugin); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandledReasons).toHaveLength(0); + const registration = waitForDiscordGatewayPluginRegistration(plugin); + if (!registration) { + throw new Error("expected Discord gateway registration promise"); + } + await expect(registration).rejects.toThrow("Failed to get gateway information from Discord"); + expect(baseRegisterClientSpy).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("exposes Carbon-ignored successful registrations for startup await", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + startIgnoredGatewayRegistration(plugin); + const registration = waitForDiscordGatewayPluginRegistration(plugin); + if (!registration) { + throw new Error("expected Discord gateway registration promise"); + } + await registration; + + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe( + "wss://gateway.discord.gg", + ); + }); + it("uses proxy agent for gateway WebSocket when configured", async () => { const runtime = createRuntime(); diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index 7f0cd821193..20719e62765 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -1,8 +1,9 @@ import type { Client, Plugin } from "@buape/carbon"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { registerVoiceClientSpy } = vi.hoisted(() => ({ +const { registerVoiceClientSpy, waitForDiscordGatewayPluginRegistrationMock } = vi.hoisted(() => ({ registerVoiceClientSpy: vi.fn(), + waitForDiscordGatewayPluginRegistrationMock: vi.fn(), })); vi.mock("@buape/carbon/voice", () => ({ @@ -51,6 +52,7 @@ vi.mock("./auto-presence.js", () => ({ vi.mock("./gateway-plugin.js", () => ({ createDiscordGatewayPlugin: vi.fn(), + waitForDiscordGatewayPluginRegistration: waitForDiscordGatewayPluginRegistrationMock, })); vi.mock("./gateway-supervisor.js", () => ({ @@ -73,16 +75,50 @@ vi.mock("./presence.js", () => ({ import { createDiscordMonitorClient } from "./provider.startup.js"; describe("createDiscordMonitorClient", () => { - it("adds listener compat for legacy voice plugins", () => { + beforeEach(() => { registerVoiceClientSpy.mockReset(); + waitForDiscordGatewayPluginRegistrationMock.mockReset().mockReturnValue(undefined); + }); + function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + } + + function createClientWithPlugins( + _options: ConstructorParameters[0], + handlers: ConstructorParameters[1], + plugins: Plugin[] = [], + ) { + const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin })); + return { + listeners: [...(handlers.listeners ?? [])], + plugins: pluginRegistry, + getPlugin: (id: string) => pluginRegistry.find((entry) => entry.id === id)?.plugin, + } as Client; + } + + function createAutoPresenceController() { + return { + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + }; + } + + it("adds listener compat for legacy voice plugins", async () => { const gatewayPlugin = { id: "gateway", registerClient: vi.fn(), registerRoutes: vi.fn(), } as Plugin; - const result = createDiscordMonitorClient({ + const result = await createDiscordMonitorClient({ accountId: "default", applicationId: "app-1", token: "token-1", @@ -91,29 +127,11 @@ describe("createDiscordMonitorClient", () => { modals: [], voiceEnabled: true, discordConfig: {}, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }, - createClient: (_options, handlers, plugins = []) => { - const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin })); - return { - listeners: [...(handlers.listeners ?? [])], - plugins: pluginRegistry, - getPlugin: (id: string) => pluginRegistry.find((entry) => entry.id === id)?.plugin, - } as Client; - }, + runtime: createRuntime(), + createClient: createClientWithPlugins, createGatewayPlugin: () => gatewayPlugin as never, createGatewaySupervisor: () => ({ shutdown: vi.fn(), handleError: vi.fn() }) as never, - createAutoPresenceController: () => - ({ - enabled: false, - start: vi.fn(), - stop: vi.fn(), - refresh: vi.fn(), - runNow: vi.fn(), - }) as never, + createAutoPresenceController: () => createAutoPresenceController() as never, isDisallowedIntentsError: () => false, }); @@ -122,4 +140,73 @@ describe("createDiscordMonitorClient", () => { expect.arrayContaining([expect.objectContaining({ type: "legacy-voice-listener" })]), ); }); + + it("waits for gateway registration before creating the supervisor", async () => { + const gatewayPlugin = { id: "gateway" } as Plugin; + let resolveRegistration: (() => void) | undefined; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + waitForDiscordGatewayPluginRegistrationMock.mockReturnValue(registration); + const gatewaySupervisor = { shutdown: vi.fn(), handleError: vi.fn() }; + const createGatewaySupervisor = vi.fn(() => gatewaySupervisor); + + const resultPromise = createDiscordMonitorClient({ + accountId: "default", + applicationId: "app-1", + token: "token-1", + commands: [], + components: [], + modals: [], + voiceEnabled: false, + discordConfig: {}, + runtime: createRuntime(), + createClient: createClientWithPlugins, + createGatewayPlugin: () => gatewayPlugin as never, + createGatewaySupervisor: createGatewaySupervisor as never, + createAutoPresenceController: () => createAutoPresenceController() as never, + isDisallowedIntentsError: () => false, + }); + await Promise.resolve(); + + expect(waitForDiscordGatewayPluginRegistrationMock).toHaveBeenCalledWith(gatewayPlugin); + expect(createGatewaySupervisor).not.toHaveBeenCalled(); + + resolveRegistration?.(); + const result = await resultPromise; + + expect(createGatewaySupervisor).toHaveBeenCalledTimes(1); + expect(result.gatewaySupervisor).toBe(gatewaySupervisor); + }); + + it("propagates gateway registration failures before supervisor startup", async () => { + const gatewayPlugin = { id: "gateway" } as Plugin; + const createGatewaySupervisor = vi.fn(); + const createAutoPresenceControllerForTest = vi.fn(createAutoPresenceController); + waitForDiscordGatewayPluginRegistrationMock.mockReturnValue( + Promise.reject(new Error("gateway metadata denied")), + ); + + await expect( + createDiscordMonitorClient({ + accountId: "default", + applicationId: "app-1", + token: "token-1", + commands: [], + components: [], + modals: [], + voiceEnabled: false, + discordConfig: {}, + runtime: createRuntime(), + createClient: createClientWithPlugins, + createGatewayPlugin: () => gatewayPlugin as never, + createGatewaySupervisor: createGatewaySupervisor as never, + createAutoPresenceController: createAutoPresenceControllerForTest as never, + isDisallowedIntentsError: () => false, + }), + ).rejects.toThrow("gateway metadata denied"); + + expect(createGatewaySupervisor).not.toHaveBeenCalled(); + expect(createAutoPresenceControllerForTest).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index a8f9947b117..1b59e091a15 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -18,7 +18,10 @@ import type { DiscordGuildEntryResolved } from "./allow-list.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; import type { DiscordDmPolicy } from "./dm-command-auth.js"; import type { MutableDiscordGateway } from "./gateway-handle.js"; -import { createDiscordGatewayPlugin } from "./gateway-plugin.js"; +import { + createDiscordGatewayPlugin, + waitForDiscordGatewayPluginRegistration, +} from "./gateway-plugin.js"; import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js"; import { DiscordMessageListener, @@ -107,7 +110,7 @@ export function createDiscordStatusReadyListener(params: { })(); } -export function createDiscordMonitorClient(params: { +export async function createDiscordMonitorClient(params: { accountId: string; applicationId: string; token: string; @@ -183,6 +186,7 @@ export function createDiscordMonitorClient(params: { }); } const gateway = client.getPlugin("gateway") as MutableDiscordGateway | undefined; + await waitForDiscordGatewayPluginRegistration(gateway); const gatewaySupervisor = params.createGatewaySupervisor({ gateway, isDisallowedIntentsError: params.isDisallowedIntentsError, diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index f731968b325..0a86576a445 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -813,8 +813,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let lifecycleStarted = false; let gatewaySupervisor: ReturnType | undefined; let deactivateMessageHandler: (() => void) | undefined; - let autoPresenceController: ReturnType< - typeof createDiscordMonitorClient + let autoPresenceController: Awaited< + ReturnType >["autoPresenceController"] = null; let lifecycleGateway: MutableDiscordGateway | undefined; let earlyGatewayEmitter = gatewaySupervisor?.emitter; @@ -934,7 +934,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { gatewaySupervisor: createdGatewaySupervisor, autoPresenceController: createdAutoPresenceController, eventQueueOpts, - } = createDiscordMonitorClient({ + } = await createDiscordMonitorClient({ accountId: account.accountId, applicationId, token, diff --git a/extensions/discord/src/test-support/provider.test-support.ts b/extensions/discord/src/test-support/provider.test-support.ts index ae1821dffbb..a17bb325770 100644 --- a/extensions/discord/src/test-support/provider.test-support.ts +++ b/extensions/discord/src/test-support/provider.test-support.ts @@ -470,6 +470,7 @@ vi.mock(buildDiscordSourceModuleId("monitor/exec-approvals.js"), () => ({ vi.mock(buildDiscordSourceModuleId("monitor/gateway-plugin.js"), () => ({ createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), + waitForDiscordGatewayPluginRegistration: () => undefined, })); vi.mock(buildDiscordSourceModuleId("monitor/listeners.js"), () => ({