diff --git a/src/browser/WebSourceMapFetcher.test.ts b/src/browser/WebSourceMapFetcher.test.ts index dd5071f70e..c8675cf109 100644 --- a/src/browser/WebSourceMapFetcher.test.ts +++ b/src/browser/WebSourceMapFetcher.test.ts @@ -1,3 +1,5 @@ +import process from "node:process"; + import { expect, it } from "vitest"; import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js"; @@ -9,6 +11,11 @@ const input = analyzeWebBundleInputSchema.parse({ target_id: "page-1", fetch_source_maps: true, }); +const request = { + scriptKey: `scr_${"1".repeat(64)}`, + declaredUrl: "https://app.example.test/app.js.map", + fetchUrl: "https://app.example.test/app.js.map", +}; it("reports an empty selected request set without starting fetch work", async () => { const result = await fetchWebSourceMaps([], input, undefined, { @@ -34,3 +41,76 @@ it("honors cancellation before returning an empty request set", async () => { reason, ); }); + +it("cancels a response body that arrives after its fetch host aborts synchronously", async () => { + const controller = new AbortController(); + const reason = new Error("fetch host aborted during admission"); + let resolveFetch!: (response: Response) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + let markCancelled!: () => void; + const cancelled = new Promise((resolve) => { + markCancelled = resolve; + }); + let cancellationReason: unknown; + const body = new ReadableStream({ + cancel: (cause) => { + cancellationReason = cause; + markCancelled(); + }, + }); + const fetchInput = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [new URL(request.fetchUrl).origin], + target_id: "page-1", + fetch_source_maps: true, + }); + const pending = fetchWebSourceMaps([request], fetchInput, controller.signal, { + fetch: () => { + controller.abort(reason); + return fetchPromise; + }, + }); + + await expect(pending).rejects.toBe(reason); + resolveFetch(new Response(body)); + await cancelled; + expect(cancellationReason).toBe(reason); +}); + +it("observes a fetch rejection that arrives after its host aborts synchronously", async () => { + const controller = new AbortController(); + const reason = new Error("fetch host aborted during admission"); + const lateFailure = new Error("late fetch failure"); + let rejectFetch!: (cause: unknown) => void; + const fetchPromise = new Promise((_resolve, reject) => { + rejectFetch = reject; + }); + const fetchInput = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [new URL(request.fetchUrl).origin], + target_id: "page-1", + fetch_source_maps: true, + }); + const pending = fetchWebSourceMaps([request], fetchInput, controller.signal, { + fetch: () => { + controller.abort(reason); + return fetchPromise; + }, + }); + let unhandled: unknown; + const onUnhandled = (cause: unknown): void => { + unhandled = cause; + }; + process.on("unhandledRejection", onUnhandled); + + try { + await expect(pending).rejects.toBe(reason); + rejectFetch(lateFailure); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toBeUndefined(); + } finally { + process.off("unhandledRejection", onUnhandled); + } +}); diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 2c6208d19a..1d39e5ff8b 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -522,7 +522,6 @@ const promiseWithAbort = ( signal: AbortSignal | undefined, ): Promise => { if (signal === undefined) return promise; - if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { let settled = false; const cleanup = (): void => signal.removeEventListener("abort", abort); @@ -555,6 +554,7 @@ const promiseWithAbort = ( reject(cause); }, ); + if (signal.aborted) abort(); }); };