From 243e28df4fb087869bc80946a2ad23b5db4b5075 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Wed, 25 Feb 2026 21:06:44 +0800 Subject: [PATCH] fix(line): keep startAccount pending until abort signal to prevent restart loop monitorLineProvider() registers the webhook HTTP route and returns immediately. Because startAccount() directly returned that resolved promise, the channel supervisor interpreted it as "provider exited" and triggered auto-restart up to 10 times. Await a promise gated on ctx.abortSignal so startAccount stays alive for the full provider lifecycle, matching the contract expected by the channel supervisor. Closes #26478 Co-authored-by: Cursor --- extensions/line/src/channel.startup.test.ts | 59 ++++++++++++++++++++- extensions/line/src/channel.ts | 16 +++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index e5b0ce333f5..11ba80bda12 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -37,6 +37,7 @@ function createStartAccountCtx(params: { token: string; secret: string; runtime: ReturnType; + abortSignal?: AbortSignal; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", @@ -56,7 +57,7 @@ function createStartAccountCtx(params: { }, cfg: {} as OpenClawConfig, runtime: params.runtime, - abortSignal: new AbortController().signal, + abortSignal: params.abortSignal ?? new AbortController().signal, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, getStatus: () => snapshot, setStatus: vi.fn(), @@ -104,14 +105,19 @@ describe("linePlugin gateway.startAccount", () => { const { runtime, monitorLineProvider } = createRuntime(); setLineRuntime(runtime); - await linePlugin.gateway!.startAccount!( + const abort = new AbortController(); + const task = linePlugin.gateway!.startAccount!( createStartAccountCtx({ token: "token", secret: "secret", runtime: createRuntimeEnv(), + abortSignal: abort.signal, }), ); + // Allow async internals (probeLineBot await) to flush + await new Promise((r) => setTimeout(r, 20)); + expect(monitorLineProvider).toHaveBeenCalledWith( expect.objectContaining({ channelAccessToken: "token", @@ -119,5 +125,54 @@ describe("linePlugin gateway.startAccount", () => { accountId: "default", }), ); + + abort.abort(); + await task; + }); + + it("stays pending until abort signal fires (no premature exit)", async () => { + const { runtime, monitorLineProvider } = createRuntime(); + setLineRuntime(runtime); + + const abort = new AbortController(); + let resolved = false; + + const task = linePlugin.gateway!.startAccount!( + createStartAccountCtx({ + token: "token", + secret: "secret", + runtime: createRuntimeEnv(), + abortSignal: abort.signal, + }), + ).then(() => { + resolved = true; + }); + + // Allow async internals to flush + await new Promise((r) => setTimeout(r, 50)); + + expect(monitorLineProvider).toHaveBeenCalled(); + expect(resolved).toBe(false); + + abort.abort(); + await task; + expect(resolved).toBe(true); + }); + + it("resolves immediately when abortSignal is already aborted", async () => { + const { runtime } = createRuntime(); + setLineRuntime(runtime); + + const abort = new AbortController(); + abort.abort(); + + await linePlugin.gateway!.startAccount!( + createStartAccountCtx({ + token: "token", + secret: "secret", + runtime: createRuntimeEnv(), + abortSignal: abort.signal, + }), + ); }); }); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index a260d96c961..f37a86aa0c4 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -651,7 +651,7 @@ export const linePlugin: ChannelPlugin = { ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`); - return getLineRuntime().channel.line.monitorLineProvider({ + const monitor = await getLineRuntime().channel.line.monitorLineProvider({ channelAccessToken: token, channelSecret: secret, accountId: account.accountId, @@ -660,6 +660,20 @@ export const linePlugin: ChannelPlugin = { abortSignal: ctx.abortSignal, webhookPath: account.config.webhookPath, }); + + // Keep the provider alive until the abort signal fires. Without this, + // the startAccount promise resolves immediately after webhook registration + // and the channel supervisor treats the provider as "exited", triggering an + // auto-restart loop (up to 10 attempts). + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + + return monitor; }, logoutAccount: async ({ accountId, cfg }) => { const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";