fix(browser): observe fetch settlement after synchronous abort

This commit is contained in:
morluto
2026-10-22 09:03:51 +08:00
parent 09b29436f4
commit 13a8640204
2 changed files with 81 additions and 1 deletions
+80
View File
@@ -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<Response>((resolve) => {
resolveFetch = resolve;
});
let markCancelled!: () => void;
const cancelled = new Promise<void>((resolve) => {
markCancelled = resolve;
});
let cancellationReason: unknown;
const body = new ReadableStream<Uint8Array>({
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<Response>((_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<void>((resolve) => setImmediate(resolve));
expect(unhandled).toBeUndefined();
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
+1 -1
View File
@@ -522,7 +522,6 @@ const promiseWithAbort = (
signal: AbortSignal | undefined,
): Promise<Response> => {
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();
});
};