From 718fa54aac573eea3757a8d7e8bbb40be584f1e0 Mon Sep 17 00:00:00 2001 From: Netcup-Clawd Admin Date: Mon, 16 Feb 2026 06:03:29 -0500 Subject: [PATCH] fix(infra): avoid detached finally unhandled rejection in fetch wrapper --- src/infra/fetch.test.ts | 70 +++++++++++++++++++++++++++++++++++++++++ src/infra/fetch.ts | 15 ++++++--- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts index 6fb471106d4..e7dea0f7e5e 100644 --- a/src/infra/fetch.test.ts +++ b/src/infra/fetch.test.ts @@ -50,4 +50,74 @@ describe("wrapFetchWithAbortSignal", () => { await promise; }); + + it("does not emit an extra unhandled rejection when wrapped fetch rejects", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + const fetchError = new TypeError("fetch failed"); + const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => + Promise.reject(fetchError), + ); + const wrapped = wrapFetchWithAbortSignal(fetchImpl); + + let abortHandler: (() => void) | null = null; + const removeEventListener = vi.fn((event: string, handler: () => void) => { + if (event === "abort" && abortHandler === handler) { + abortHandler = null; + } + }); + + const fakeSignal = { + aborted: false, + addEventListener: (event: string, handler: () => void) => { + if (event === "abort") { + abortHandler = handler; + } + }, + removeEventListener, + } as AbortSignal; + + try { + await expect(wrapped("https://example.com", { signal: fakeSignal })).rejects.toBe(fetchError); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandled).toEqual([]); + expect(removeEventListener).toHaveBeenCalledOnce(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("cleans up listener and rethrows when fetch throws synchronously", () => { + const syncError = new TypeError("sync fetch failure"); + const fetchImpl = vi.fn(() => { + throw syncError; + }); + const wrapped = wrapFetchWithAbortSignal(fetchImpl); + + let abortHandler: (() => void) | null = null; + const removeEventListener = vi.fn((event: string, handler: () => void) => { + if (event === "abort" && abortHandler === handler) { + abortHandler = null; + } + }); + + const fakeSignal = { + aborted: false, + addEventListener: (event: string, handler: () => void) => { + if (event === "abort") { + abortHandler = handler; + } + }, + removeEventListener, + } as AbortSignal; + + expect(() => wrapped("https://example.com", { signal: fakeSignal })).toThrow(syncError); + expect(removeEventListener).toHaveBeenCalledOnce(); + }); }); diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index fe4c7c351ab..ee0fb9998c3 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -50,13 +50,18 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch } else { signal.addEventListener("abort", onAbort, { once: true }); } - const response = fetchImpl(input, { ...patchedInit, signal: controller.signal }); - if (typeof signal.removeEventListener === "function") { - void response.finally(() => { + const cleanup = () => { + if (typeof signal.removeEventListener === "function") { signal.removeEventListener("abort", onAbort); - }); + } + }; + try { + const response = fetchImpl(input, { ...patchedInit, signal: controller.signal }); + return response.finally(cleanup); + } catch (error) { + cleanup(); + throw error; } - return response; }) as FetchWithPreconnect; const fetchWithPreconnect = fetchImpl as FetchWithPreconnect;