fix(discord): supervise gateway registration failures

This commit is contained in:
Peter Steinberger
2026-04-24 23:10:53 +01:00
parent 4de80807b9
commit d4a8fdb6ce
7 changed files with 221 additions and 32 deletions

View File

@@ -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.

View File

@@ -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<carbonGateway.GatewayPlugin, Promise<void>>();
type CarbonGatewayRegistrationState = {
client?: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0];
ws?: unknown;
@@ -288,7 +289,17 @@ function createGatewayPlugin(params: {
super.connect(resume);
}
override async registerClient(
override registerClient(client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[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<carbonGateway.GatewayPlugin["registerClient"]>[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<void> | undefined {
if (typeof plugin !== "object" || plugin === null) {
return undefined;
}
return registrationPromises.get(plugin as carbonGateway.GatewayPlugin);
}
export function createDiscordGatewayPlugin(params: {
discordConfig: DiscordAccountConfig;
runtime: RuntimeEnv;

View File

@@ -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<typeof vi.fn>;
}) => Promise<void>;
}
).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();

View File

@@ -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<typeof import("@buape/carbon").Client>[0],
handlers: ConstructorParameters<typeof import("@buape/carbon").Client>[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<void>((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();
});
});

View File

@@ -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<GatewayPlugin>("gateway") as MutableDiscordGateway | undefined;
await waitForDiscordGatewayPluginRegistration(gateway);
const gatewaySupervisor = params.createGatewaySupervisor({
gateway,
isDisallowedIntentsError: params.isDisallowedIntentsError,

View File

@@ -813,8 +813,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let lifecycleStarted = false;
let gatewaySupervisor: ReturnType<typeof createDiscordGatewaySupervisor> | undefined;
let deactivateMessageHandler: (() => void) | undefined;
let autoPresenceController: ReturnType<
typeof createDiscordMonitorClient
let autoPresenceController: Awaited<
ReturnType<typeof createDiscordMonitorClient>
>["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,

View File

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