mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:30:44 +00:00
fix(discord): configure gateway ready timeouts
This commit is contained in:
@@ -168,6 +168,33 @@ describe("discordPlugin outbound", () => {
|
||||
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
|
||||
});
|
||||
|
||||
it("inherits Discord gateway READY timeout settings per account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-token",
|
||||
gatewayReadyTimeoutMs: 90_000,
|
||||
gatewayRuntimeReadyTimeoutMs: 120_000,
|
||||
accounts: {
|
||||
work: {
|
||||
token: "discord-token-work",
|
||||
gatewayReadyTimeoutMs: 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveAccount(cfg).config).toMatchObject({
|
||||
gatewayReadyTimeoutMs: 90_000,
|
||||
gatewayRuntimeReadyTimeoutMs: 120_000,
|
||||
});
|
||||
expect(resolveAccount(cfg, "work").config).toMatchObject({
|
||||
gatewayReadyTimeoutMs: 60_000,
|
||||
gatewayRuntimeReadyTimeoutMs: 120_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards full media send context to sendMessageDiscord", async () => {
|
||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("media"));
|
||||
|
||||
@@ -141,6 +141,14 @@ export const discordChannelConfigUiHints = {
|
||||
label: "Discord Gateway Metadata Timeout (ms)",
|
||||
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
|
||||
},
|
||||
gatewayReadyTimeoutMs: {
|
||||
label: "Discord Gateway READY Timeout (ms)",
|
||||
help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.",
|
||||
},
|
||||
gatewayRuntimeReadyTimeoutMs: {
|
||||
label: "Discord Gateway Runtime READY Timeout (ms)",
|
||||
help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.",
|
||||
},
|
||||
"voice.enabled": {
|
||||
label: "Discord Voice Enabled",
|
||||
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",
|
||||
|
||||
@@ -59,9 +59,15 @@ vi.mock("./gateway-registry.js", () => ({
|
||||
|
||||
describe("runDiscordGatewayLifecycle", () => {
|
||||
let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle;
|
||||
let resolveDiscordGatewayReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayReadyTimeoutMs;
|
||||
let resolveDiscordGatewayRuntimeReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayRuntimeReadyTimeoutMs;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"));
|
||||
({
|
||||
runDiscordGatewayLifecycle,
|
||||
resolveDiscordGatewayReadyTimeoutMs,
|
||||
resolveDiscordGatewayRuntimeReadyTimeoutMs,
|
||||
} = await import("./provider.lifecycle.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -143,24 +149,25 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
error: runtimeError,
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const lifecycleParams: LifecycleParams = {
|
||||
accountId: "default",
|
||||
gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined,
|
||||
runtime,
|
||||
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
threadBindings: { stop: threadStop },
|
||||
gatewaySupervisor,
|
||||
statusSink,
|
||||
abortSignal: undefined,
|
||||
};
|
||||
return {
|
||||
threadStop,
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
gatewaySupervisor,
|
||||
statusSink,
|
||||
lifecycleParams: {
|
||||
accountId: "default",
|
||||
gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined,
|
||||
runtime,
|
||||
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
threadBindings: { stop: threadStop },
|
||||
gatewaySupervisor,
|
||||
statusSink,
|
||||
abortSignal: undefined as AbortSignal | undefined,
|
||||
} satisfies LifecycleParams,
|
||||
lifecycleParams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +183,26 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("resolves gateway READY timeouts from config, env, then defaults", () => {
|
||||
expect(resolveDiscordGatewayReadyTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
|
||||
expect(
|
||||
resolveDiscordGatewayReadyTimeoutMs({
|
||||
env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "90000" },
|
||||
}),
|
||||
).toBe(90_000);
|
||||
expect(resolveDiscordGatewayReadyTimeoutMs({ env: {} })).toBe(15_000);
|
||||
|
||||
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ configuredTimeoutMs: 60_000 })).toBe(
|
||||
60_000,
|
||||
);
|
||||
expect(
|
||||
resolveDiscordGatewayRuntimeReadyTimeoutMs({
|
||||
env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "120000" },
|
||||
}),
|
||||
).toBe(120_000);
|
||||
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000);
|
||||
});
|
||||
|
||||
it("cleans up thread bindings when gateway wait fails before READY", async () => {
|
||||
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed"));
|
||||
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
|
||||
@@ -228,14 +255,15 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
gateway: null,
|
||||
},
|
||||
);
|
||||
lifecycleParams.gatewayReadyTimeoutMs = 5_000;
|
||||
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
lifecyclePromise.catch(() => {});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(15_500);
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
|
||||
await expect(lifecyclePromise).rejects.toThrow(
|
||||
"discord gateway did not reach READY within 15000ms",
|
||||
"discord gateway did not reach READY within 5000ms",
|
||||
);
|
||||
expect(statusSink).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -606,15 +634,16 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
);
|
||||
|
||||
const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway });
|
||||
lifecycleParams.gatewayRuntimeReadyTimeoutMs = 5_000;
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
lifecyclePromise.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
await expect(lifecyclePromise).rejects.toThrow(
|
||||
"discord gateway opened but did not reach READY within 30000ms",
|
||||
"discord gateway opened but did not reach READY within 5000ms",
|
||||
);
|
||||
expect(runtimeError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("did not reach READY within 30000ms"),
|
||||
expect.stringContaining("did not reach READY within 5000ms"),
|
||||
);
|
||||
expect(statusSink).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
} from "./gateway-supervisor.js";
|
||||
import type { DiscordMonitorStatusSink } from "./status.js";
|
||||
|
||||
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
|
||||
const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000;
|
||||
const MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS = 120_000;
|
||||
const DISCORD_GATEWAY_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_READY_TIMEOUT_MS";
|
||||
const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS";
|
||||
const DISCORD_GATEWAY_READY_POLL_MS = 250;
|
||||
const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000;
|
||||
const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000;
|
||||
@@ -28,6 +31,37 @@ const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000;
|
||||
|
||||
type GatewayReadyWaitResult = "ready" | "stopped" | "timeout";
|
||||
|
||||
function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined {
|
||||
const numeric =
|
||||
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function resolveDiscordGatewayReadyTimeoutMs(params?: {
|
||||
configuredTimeoutMs?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
return (
|
||||
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
|
||||
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_READY_TIMEOUT_ENV]) ??
|
||||
DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: {
|
||||
configuredTimeoutMs?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
return (
|
||||
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
|
||||
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV]) ??
|
||||
DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function restartGatewayAfterReadyTimeout(params: {
|
||||
gateway?: Pick<MutableDiscordGateway, "connect" | "disconnect" | "ws">;
|
||||
abortSignal?: AbortSignal;
|
||||
@@ -158,6 +192,7 @@ function createGatewayStatusObserver(params: {
|
||||
runtime: RuntimeEnv;
|
||||
pushStatus: (patch: Parameters<DiscordMonitorStatusSink>[0]) => void;
|
||||
isLifecycleStopping: () => boolean;
|
||||
runtimeReadyTimeoutMs: number;
|
||||
}) {
|
||||
let forceStopHandler: ((err: unknown) => void) | undefined;
|
||||
let queuedForceStopError: unknown;
|
||||
@@ -214,7 +249,7 @@ function createGatewayStatusObserver(params: {
|
||||
}
|
||||
const at = Date.now();
|
||||
const error = new Error(
|
||||
`discord gateway opened but did not reach READY within ${DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS}ms`,
|
||||
`discord gateway opened but did not reach READY within ${params.runtimeReadyTimeoutMs}ms`,
|
||||
);
|
||||
params.pushStatus({
|
||||
connected: false,
|
||||
@@ -227,7 +262,7 @@ function createGatewayStatusObserver(params: {
|
||||
});
|
||||
params.runtime.error?.(danger(error.message));
|
||||
triggerForceStop(error);
|
||||
}, DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS);
|
||||
}, params.runtimeReadyTimeoutMs);
|
||||
readyTimeoutId.unref?.();
|
||||
}
|
||||
};
|
||||
@@ -292,9 +327,10 @@ async function waitForGatewayReady(params: {
|
||||
pushStatus?: (patch: Parameters<DiscordMonitorStatusSink>[0]) => void;
|
||||
runtime: RuntimeEnv;
|
||||
beforeRestart?: () => Promise<void> | void;
|
||||
readyTimeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const waitUntilReady = async (): Promise<GatewayReadyWaitResult> => {
|
||||
const deadlineAt = Date.now() + DISCORD_GATEWAY_READY_TIMEOUT_MS;
|
||||
const deadlineAt = Date.now() + params.readyTimeoutMs;
|
||||
while (!params.abortSignal?.aborted) {
|
||||
if ((await params.beforePoll?.()) === "stop") {
|
||||
return "stopped";
|
||||
@@ -324,16 +360,12 @@ async function waitForGatewayReady(params: {
|
||||
return;
|
||||
}
|
||||
if (!params.gateway) {
|
||||
throw new Error(
|
||||
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms`,
|
||||
);
|
||||
throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`);
|
||||
}
|
||||
|
||||
const restartAt = Date.now();
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; restarting gateway`,
|
||||
),
|
||||
danger(`discord: gateway was not ready after ${params.readyTimeoutMs}ms; restarting gateway`),
|
||||
);
|
||||
params.pushStatus?.({
|
||||
connected: false,
|
||||
@@ -356,7 +388,7 @@ async function waitForGatewayReady(params: {
|
||||
|
||||
if ((await waitUntilReady()) === "timeout") {
|
||||
throw new Error(
|
||||
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after restart`,
|
||||
`discord gateway did not reach READY within ${params.readyTimeoutMs}ms after restart`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -372,6 +404,8 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
threadBindings: { stop: () => void };
|
||||
gatewaySupervisor: DiscordGatewaySupervisor;
|
||||
statusSink?: DiscordMonitorStatusSink;
|
||||
gatewayReadyTimeoutMs?: number;
|
||||
gatewayRuntimeReadyTimeoutMs?: number;
|
||||
}) {
|
||||
const gateway = params.gateway;
|
||||
if (gateway) {
|
||||
@@ -387,12 +421,21 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
const pushStatus = (patch: Parameters<DiscordMonitorStatusSink>[0]) => {
|
||||
params.statusSink?.(patch);
|
||||
};
|
||||
const gatewayReadyTimeoutMs = resolveDiscordGatewayReadyTimeoutMs({
|
||||
configuredTimeoutMs: params.gatewayReadyTimeoutMs,
|
||||
env: process.env,
|
||||
});
|
||||
const gatewayRuntimeReadyTimeoutMs = resolveDiscordGatewayRuntimeReadyTimeoutMs({
|
||||
configuredTimeoutMs: params.gatewayRuntimeReadyTimeoutMs,
|
||||
env: process.env,
|
||||
});
|
||||
const statusObserver = createGatewayStatusObserver({
|
||||
gateway,
|
||||
abortSignal: params.abortSignal,
|
||||
runtime: params.runtime,
|
||||
pushStatus,
|
||||
isLifecycleStopping: () => lifecycleStopping,
|
||||
runtimeReadyTimeoutMs: gatewayRuntimeReadyTimeoutMs,
|
||||
});
|
||||
gatewayEmitter?.on("debug", statusObserver.onGatewayDebug);
|
||||
let lastTransportActivityStatusAt: number | undefined;
|
||||
@@ -460,6 +503,7 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
pushStatus,
|
||||
runtime: params.runtime,
|
||||
beforeRestart: statusObserver.clearReadyWatch,
|
||||
readyTimeoutMs: gatewayReadyTimeoutMs,
|
||||
});
|
||||
|
||||
if (drainPendingGatewayErrors() === "stop") {
|
||||
|
||||
@@ -382,6 +382,33 @@ describe("monitorDiscordProvider", () => {
|
||||
expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes configured gateway READY timeouts to the lifecycle monitor", async () => {
|
||||
resolveDiscordAccountMock.mockReturnValueOnce({
|
||||
accountId: "default",
|
||||
token: "cfg-token",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: false },
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
gatewayReadyTimeoutMs: 90_000,
|
||||
gatewayRuntimeReadyTimeoutMs: 120_000,
|
||||
},
|
||||
});
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
});
|
||||
|
||||
expect(monitorLifecycleMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayReadyTimeoutMs: 90_000,
|
||||
gatewayRuntimeReadyTimeoutMs: 120_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load the Discord voice runtime when voice is disabled", async () => {
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
|
||||
@@ -626,6 +626,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
voiceManagerRef,
|
||||
threadBindings,
|
||||
gatewaySupervisor,
|
||||
gatewayReadyTimeoutMs: account.config.gatewayReadyTimeoutMs,
|
||||
gatewayRuntimeReadyTimeoutMs: account.config.gatewayRuntimeReadyTimeoutMs,
|
||||
});
|
||||
} finally {
|
||||
cleanupDiscordProviderStartup({
|
||||
|
||||
Reference in New Issue
Block a user