From 828845a08a54ff33997fc802e6160a1adb8b4f0f Mon Sep 17 00:00:00 2001 From: chengzhichao-xydt Date: Tue, 21 Jul 2026 12:53:45 +0800 Subject: [PATCH] fix(feishu): honor abortSignal during app registration poll interval (#109909) * fix(feishu): honor abortSignal during app registration poll interval * test(feishu): prove poll abort through a real loopback server --- .../feishu/src/app-registration.test.ts | 35 +++++++++++++++++++ extensions/feishu/src/app-registration.ts | 15 +++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/app-registration.test.ts b/extensions/feishu/src/app-registration.test.ts index 30b65080a8ad..ff191b9b44ba 100644 --- a/extensions/feishu/src/app-registration.test.ts +++ b/extensions/feishu/src/app-registration.test.ts @@ -242,6 +242,41 @@ describe("Feishu app registration", () => { await expect(poll).resolves.toEqual({ status: "timeout" }); }); + it("stops polling promptly when abortSignal fires during the poll interval", async () => { + let requestCount = 0; + await withRegistrationServer( + (_req, res) => { + requestCount += 1; + writeJson(res, { error: "authorization_pending" }); + }, + async ({ fetchImpl, lookupFn }) => { + const controller = new AbortController(); + const started = Date.now(); + + const poll = pollAppRegistration({ + deviceCode: "device-code", + interval: 30, + expireIn: 600, + abortSignal: controller.signal, + fetchImpl, + lookupFn, + }); + // Let the first poll resolve and the loop enter its 30s interval sleep. + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + controller.abort(); + + await expect(poll).resolves.toEqual({ status: "timeout" }); + expect(Date.now() - started).toBeLessThan(10_000); + expect(requestCount).toBe(1); + console.log( + `[feishu pollAppRegistration abort proof] interval=30s aborted_after=200ms elapsed=${Date.now() - started}ms requests=${requestCount} outcome=timeout`, + ); + }, + ); + }); + it("prints scan-to-create QR codes with compact terminal rendering", async () => { const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index 693ee578f86d..7fc2e45d6b53 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -1,6 +1,6 @@ // Feishu plugin module implements app registration behavior. import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime"; -import { sleep } from "openclaw/plugin-sdk/runtime-env"; +import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; /** * Feishu app registration via OAuth device-code flow. * @@ -257,7 +257,7 @@ export async function pollAppRegistration(params: { ); } catch { // Transient network error — keep polling. - await sleepRegistrationPollInterval(currentInterval); + await sleepRegistrationPollInterval(currentInterval, abortSignal); continue; } @@ -303,7 +303,7 @@ export async function pollAppRegistration(params: { } } - await sleepRegistrationPollInterval(currentInterval); + await sleepRegistrationPollInterval(currentInterval, abortSignal); } return { status: "timeout" }; @@ -394,9 +394,14 @@ export async function getAppOwnerOpenId(params: { } } -function sleepRegistrationPollInterval(intervalSeconds: number): Promise { +async function sleepRegistrationPollInterval( + intervalSeconds: number, + abortSignal?: AbortSignal, +): Promise { const intervalMs = finiteSecondsToTimerSafeMilliseconds(intervalSeconds) ?? DEFAULT_REGISTRATION_POLL_INTERVAL_SECONDS * 1_000; - return sleep(intervalMs); + // Swallow the abort rejection: the poll loop's `aborted` check owns the exit + // path so PollOutcome stays "timeout" instead of an unhandled rejection. + await sleepWithAbort(intervalMs, abortSignal).catch(() => {}); }