From f5935bbca15d807e4cf9f6cce5ea7e128e40228e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 12:32:46 +0200 Subject: [PATCH] fix(e2e): cancel timed out response reads --- scripts/e2e/lib/bounded-response-text.mjs | 27 ++++++- test/scripts/bounded-response-text.test.ts | 85 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 test/scripts/bounded-response-text.test.ts diff --git a/scripts/e2e/lib/bounded-response-text.mjs b/scripts/e2e/lib/bounded-response-text.mjs index 5256040a1ce..0d026c853fe 100644 --- a/scripts/e2e/lib/bounded-response-text.mjs +++ b/scripts/e2e/lib/bounded-response-text.mjs @@ -5,6 +5,12 @@ function bodyTooLargeError(label, byteLimit) { }); } +function cancelReaderSoon(reader) { + void Promise.resolve() + .then(() => reader.cancel()) + .catch(() => {}); +} + export async function readBoundedResponseText(response, label, byteLimit, timeoutPromise) { const contentLength = response.headers.get("content-length"); if (contentLength) { @@ -22,22 +28,35 @@ export async function readBoundedResponseText(response, label, byteLimit, timeou const decoder = new TextDecoder(); let byteCount = 0; let text = ""; + let canceled = false; try { while (true) { - const { done, value } = await (timeoutPromise - ? Promise.race([reader.read(), timeoutPromise]) - : reader.read()); + const read = reader.read(); + const readWithTimeout = timeoutPromise + ? Promise.race([ + read, + timeoutPromise.catch((error) => { + canceled = true; + cancelReaderSoon(reader); + throw error; + }), + ]) + : read; + const { done, value } = await readWithTimeout; if (done) { return text + decoder.decode(); } byteCount += value.byteLength; if (byteCount > byteLimit) { + canceled = true; await reader.cancel().catch(() => {}); throw bodyTooLargeError(label, byteLimit); } text += decoder.decode(value, { stream: true }); } } finally { - reader.releaseLock(); + if (!canceled) { + reader.releaseLock(); + } } } diff --git a/test/scripts/bounded-response-text.test.ts b/test/scripts/bounded-response-text.test.ts new file mode 100644 index 00000000000..e67ca9304ce --- /dev/null +++ b/test/scripts/bounded-response-text.test.ts @@ -0,0 +1,85 @@ +// E2E bounded response text tests cover shared E2E HTTP body limits. +import { describe, expect, it } from "vitest"; +import { readBoundedResponseText } from "../../scripts/e2e/lib/bounded-response-text.mjs"; + +describe("scripts/e2e/lib/bounded-response-text.mjs", () => { + it("cancels pending response body reads when the timeout wins", async () => { + let canceled = false; + const response = { + headers: new Headers(), + body: { + getReader() { + return { + read() { + return new Promise>(() => {}); + }, + async cancel() { + canceled = true; + }, + releaseLock() { + throw new Error("releaseLock should not run while a read is pending"); + }, + }; + }, + }, + }; + + await expect( + readBoundedResponseText( + response, + "probe", + 1024, + Promise.reject(new Error("probe timed out")), + ), + ).rejects.toThrow("probe timed out"); + + expect(canceled).toBe(true); + }); + + it("keeps timeout rejection ahead of cancel-unblocked stream reads", async () => { + let canceled = false; + const response = new Response( + new ReadableStream({ + pull() { + return new Promise(() => {}); + }, + cancel() { + canceled = true; + }, + }), + { headers: new Headers() }, + ); + + await expect( + readBoundedResponseText( + response, + "probe", + 1024, + Promise.reject(new Error("probe timed out")), + ), + ).rejects.toThrow("probe timed out"); + + expect(canceled).toBe(true); + }); + + it("cancels oversized streamed response bodies", async () => { + let canceled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(17)); + }, + cancel() { + canceled = true; + }, + }), + { headers: new Headers() }, + ); + + await expect(readBoundedResponseText(response, "probe", 16)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "probe response body exceeded 16 bytes", + }); + expect(canceled).toBe(true); + }); +});