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 <cursoragent@cursor.com>
This commit is contained in:
SidQin-cyber
2026-02-25 21:06:44 +08:00
committed by Peter Steinberger
parent f55238e72a
commit 243e28df4f
2 changed files with 72 additions and 3 deletions

View File

@@ -37,6 +37,7 @@ function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: ReturnType<typeof createRuntimeEnv>;
abortSignal?: AbortSignal;
}): ChannelGatewayContext<ResolvedLineAccount> {
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,
}),
);
});
});

View File

@@ -651,7 +651,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
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<ResolvedLineAccount> = {
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<void>((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() ?? "";