From 921904b3deaf653c4e2bd63adc32b5ed47d20eb0 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 22 Jun 2026 17:59:17 +0000 Subject: [PATCH] fix(web-search): cancelled queued exa waits Raced queued Exa throttle waits against the caller abort signal so requests cancelled behind an earlier throttle wait reject immediately without breaking the serialized throttle chain. Added regression coverage for cancelling a third Exa request queued behind another delayed request. Fixes #3271 --- .../src/web/search/providers/exa.ts | 17 ++++++++-- .../test/tools/web-search-exa.test.ts | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 2c58df630..a26143d67 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -66,12 +66,23 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + const { promise: aborted, reject } = Promise.withResolvers(); + const onAbort = (): void => rejectWithAbortReason(reject, signal); + signal.addEventListener("abort", onAbort, { once: true }); + return Promise.race([promise, aborted]).finally(() => { + signal.removeEventListener("abort", onAbort); + }); +} + async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise { const delayMs = configuredExaSearchDelayMs(); if (delayMs <= 0) return; const prior = exaSearchThrottle.catch(() => {}); - const current = prior.then(async () => { + const queued = prior.then(async () => { signal?.throwIfAborted(); const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now()); if (waitMs > 0) { @@ -80,8 +91,8 @@ async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise {}); - await current; + exaSearchThrottle = queued.catch(() => {}); + await waitUntilDoneOrAborted(queued, signal); } /** Reset Exa request pacing state for isolated provider tests. */ diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index 6df9fba7f..34065411c 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -349,6 +349,38 @@ describe("searchExa", () => { expect(fetchCount).toBe(1); }); + it("aborts while queued behind another Exa throttle wait", async () => { + resetSettingsForTest(); + resetExaSearchThrottleForTest(); + await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 1_000 } }); + let fetchCount = 0; + const fetchMock: FetchImpl = () => { + fetchCount += 1; + return Promise.resolve( + new Response(JSON.stringify(makeMockExaResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }; + + await searchExa({ query: "first request", fetch: fetchMock }); + const secondController = new AbortController(); + const thirdController = new AbortController(); + const second = searchExa({ query: "second request", fetch: fetchMock, signal: secondController.signal }); + const startedAt = Date.now(); + const third = searchExa({ query: "third request", fetch: fetchMock, signal: thirdController.signal }); + await Bun.sleep(0); + thirdController.abort(new Error("cancelled queued Exa throttle wait")); + + await expect(third).rejects.toThrow("cancelled queued Exa throttle wait"); + expect(Date.now() - startedAt).toBeLessThan(250); + expect(fetchCount).toBe(1); + + secondController.abort(new Error("cleanup second Exa throttle wait")); + await expect(second).rejects.toThrow("cleanup second Exa throttle wait"); + }); + it("prefers summary over text for snippet field", async () => { const result = await searchExa({ query: "snippet test",