mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(clickclack): stop promptly during reconnect delay (#108145)
* fix(clickclack): cancel reconnect delay on gateway abort * fix(clickclack): clean up failed websocket cycles Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
4aa1a29b3d
commit
61b85aa1d1
@@ -472,6 +472,37 @@ describe("ClickClack gateway", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("cancels the reconnect delay when the gateway aborts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const firstSocket = new FakeSocket();
|
||||
const secondSocket = new FakeSocket();
|
||||
mocks.client.websocket.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
const run = startClickClackGatewayAccount(ctx);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstSocket.emit("close");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.setStatus).toHaveBeenLastCalledWith({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not log reconnect warnings when abort closes a connecting websocket", async () => {
|
||||
const socket = new FakeSocket();
|
||||
socket.emitErrorOnClose = true;
|
||||
@@ -542,6 +573,7 @@ describe("ClickClack gateway", () => {
|
||||
message: 'ClickClack ws message failed: {"code":"ECONNRESET","retryable":true}',
|
||||
cause: rejection,
|
||||
});
|
||||
expect(socket.close).toHaveBeenCalledOnce();
|
||||
expect(ctx.setStatus).toHaveBeenLastCalledWith({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RawData } from "ws";
|
||||
import { resolveClickClackInboundAccess } from "./access.js";
|
||||
import { resolveClickClackAccount } from "./accounts.js";
|
||||
@@ -187,6 +188,14 @@ export async function startClickClackGatewayAccount(
|
||||
workspace: workspaceId,
|
||||
botUserId: configuredAccount.botUserId ?? me.id,
|
||||
};
|
||||
const processIncomingEvent = (event: ClickClackEvent) =>
|
||||
processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId,
|
||||
});
|
||||
if (account.commandMenu) {
|
||||
await syncClickClackCommandMenu({ cfg: ctx.cfg, client, log: ctx.log });
|
||||
}
|
||||
@@ -221,15 +230,7 @@ export async function startClickClackGatewayAccount(
|
||||
workspaceId,
|
||||
afterCursor,
|
||||
abortSignal: ctx.abortSignal,
|
||||
onEvent: async (event) => {
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId,
|
||||
});
|
||||
},
|
||||
onEvent: processIncomingEvent,
|
||||
});
|
||||
}
|
||||
if (ctx.abortSignal.aborted) {
|
||||
@@ -239,14 +240,27 @@ export async function startClickClackGatewayAccount(
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let removeAbortListener: (() => void) | undefined;
|
||||
const finishSocketCycle = () => {
|
||||
const finishSocketCycle = (error?: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
removeAbortListener?.();
|
||||
removeAbortListener = undefined;
|
||||
resolve();
|
||||
if (error === undefined) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// A failed message ends this socket's ownership. Closing it prevents
|
||||
// the old connection from surviving beside the supervisor's restart.
|
||||
socket.close();
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`ClickClack ws message failed: ${formatErrorMessage(error)}`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
@@ -264,24 +278,10 @@ export async function startClickClackGatewayAccount(
|
||||
return;
|
||||
}
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId ?? "",
|
||||
});
|
||||
})().catch((e: unknown) =>
|
||||
reject(
|
||||
e instanceof Error
|
||||
? e
|
||||
: new Error(`ClickClack ws message failed: ${formatErrorMessage(e)}`, {
|
||||
cause: e,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await processIncomingEvent(event);
|
||||
})().catch(finishSocketCycle);
|
||||
});
|
||||
socket.on("close", finishSocketCycle);
|
||||
socket.on("close", () => finishSocketCycle());
|
||||
socket.on("error", (error) => {
|
||||
if (settled || ctx.abortSignal.aborted) {
|
||||
finishSocketCycle();
|
||||
@@ -297,9 +297,15 @@ export async function startClickClackGatewayAccount(
|
||||
});
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
try {
|
||||
// The gateway abort owns both the active socket and its reconnect delay;
|
||||
// otherwise shutdown can remain pending for the full configured backoff.
|
||||
await sleepWithAbort(account.reconnectMs, ctx.abortSignal);
|
||||
} catch (error) {
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user