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() ?? "";