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
This commit is contained in:
roboomp
2026-06-22 17:59:17 +00:00
parent 022010ad97
commit 921904b3de
2 changed files with 46 additions and 3 deletions
@@ -66,12 +66,23 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<vo
return promise;
}
function waitUntilDoneOrAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
signal.throwIfAborted();
const { promise: aborted, reject } = Promise.withResolvers<never>();
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<void> {
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<vo
signal?.throwIfAborted();
nextExaSearchRequestAt = Date.now() + delayMs;
});
exaSearchThrottle = current.catch(() => {});
await current;
exaSearchThrottle = queued.catch(() => {});
await waitUntilDoneOrAborted(queued, signal);
}
/** Reset Exa request pacing state for isolated provider tests. */
@@ -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",