fix(discord): honor abortSignal during gateway READY retry backoff (#108561)

* fix(discord): honor abortSignal during gateway READY retry backoff

* fix(discord): avoid __testing alias to satisfy Knip production scan

* test(discord): make READY abort proof deterministic

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>

* fix(retry): support unref abort sleeps

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wangmiao0668000666
2026-07-16 03:14:46 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 27065a65b7
commit 8090cb1c64
5 changed files with 85 additions and 6 deletions
@@ -741,6 +741,52 @@ describe("runDiscordGatewayLifecycle", () => {
});
});
describe("waitForGatewayReady", () => {
let waitForGatewayReady: (typeof import("../../test-api.js"))["discordGatewayLifecycleTesting"]["waitForGatewayReady"];
beforeAll(async () => {
waitForGatewayReady = (await import("../../test-api.js")).discordGatewayLifecycleTesting
.waitForGatewayReady;
});
it("returns promptly when abortSignal fires during the READY retry backoff", async () => {
vi.useFakeTimers();
try {
const controller = new AbortController();
const gateway = {
isConnected: false,
connect: vi.fn(),
disconnect: vi.fn(),
ws: null,
};
const runtime: RuntimeEnv = {
log: () => {},
error: () => {},
exit: () => {},
};
const readyPromise = waitForGatewayReady({
gateway,
abortSignal: controller.signal,
readyTimeoutMs: 200,
runtime,
});
await vi.advanceTimersByTimeAsync(250);
expect(gateway.connect).toHaveBeenCalledTimes(1);
controller.abort();
await expect(readyPromise).resolves.toBeUndefined();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.connect).toHaveBeenCalledTimes(1);
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
@@ -4,7 +4,7 @@ import {
createTransportActivityStatusPatch,
} from "openclaw/plugin-sdk/gateway-runtime";
import { asDateTimestampMs, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { danger, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
@@ -399,10 +399,14 @@ async function waitForGatewayReady(params: {
if (params.abortSignal?.aborted) {
return;
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS);
timeout.unref?.();
});
try {
await sleepWithAbort(DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS, params.abortSignal, {
ref: false,
});
} catch {
// Abort is normal lifecycle shutdown; do not enter another reconnect attempt.
return;
}
}
}
@@ -581,3 +585,7 @@ export async function runDiscordGatewayLifecycle(params: {
params.threadBindings.stop();
}
}
// Test-only surface. Re-exported from the plugin root `test-api.ts` entry so Knip's
// production scan sees the consumer; tests import `testing` from `test-api.js`.
export const testing = { waitForGatewayReady };
+1
View File
@@ -1,5 +1,6 @@
// Discord API module exposes the plugin public contract.
export { discordPlugin } from "./src/channel.js";
export { buildFinalizedDiscordDirectInboundContext } from "./src/monitor/inbound-context.test-helpers.js";
export { testing as discordGatewayLifecycleTesting } from "./src/monitor/provider.lifecycle.js";
export { testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
export { discordOutbound } from "./src/outbound-adapter.js";
+16
View File
@@ -59,6 +59,22 @@ describe("RetrySupervisor", () => {
vi.useRealTimers();
}
});
it("can unref the scheduled timer", async () => {
const controller = new AbortController();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const sleeper = sleepWithAbort(60_000, controller.signal, { ref: false });
const timer = setTimeoutSpy.mock.results.at(-1)?.value as NodeJS.Timeout | undefined;
expect(timer?.hasRef()).toBe(false);
controller.abort();
await expect(sleeper).rejects.toMatchObject({ message: "aborted" });
} finally {
controller.abort();
setTimeoutSpy.mockRestore();
}
});
});
describe("retryAsync", () => {
+9 -1
View File
@@ -18,7 +18,11 @@ export function computeBackoffSchedule(scheduleMs: readonly number[], attempt: n
return attempt <= 0 ? 0 : (scheduleMs[index] ?? 0);
}
export async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise<void> {
export async function sleepWithAbort(
ms: number,
abortSignal?: AbortSignal,
options: { ref?: boolean } = {},
): Promise<void> {
if (!Number.isFinite(ms) || ms <= 0) {
return;
}
@@ -50,6 +54,10 @@ export async function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Pro
timer = null;
resolve();
}, delayMs);
// Retry loops can stay abortable without keeping an otherwise idle process alive.
if (options.ref === false) {
timer.unref?.();
}
if (abortSignal?.aborted) {
onAbort();
}