From 7a5f47e4d3629f87b7d7832bfe0be5fcaaec534a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:27:37 +0000 Subject: [PATCH 1/3] test(fetch): speed up fetch.test.ts and tighten its assertions Release build of this file goes from ~30s to ~12s, debug+ASAN in this container from 225s to 41s. Where the time went and what changed: - explicit-timeout idle test held every request for a fixed 10s; the server now holds them until the control request has been aborted by the 1s default, and the control is only sent once the explicit requests have been seen, so the check stays deterministic. - absolute-deadline idle test: /b now drips its body until /h has timed out and is completed afterwards, so the body re-arm check no longer depends on the sweep phase. Both idle tests run concurrently. - testBlobInterface compared buffers one byte at a time with a forced GC before and after every byte (~6300 Bun.gc(true) calls); compare the whole array after a GC instead. - the four RSS cases in "fetch should allow duplex" run concurrently. - very long redirect URL test runs 20 iterations instead of 100 and also asserts the redirected flag and the final body. Assertions: the idle tests assert the exact child output and an empty stderr, the Response life cycle test pipes and asserts both children's output, #3545 uses a local server and checks the request target instead of fetching example.com, the invalid header test asserts the TypeError and its message, and the 100-continue tests listen on port 0 instead of a hardcoded 8080. --- test/js/web/fetch/fetch.test.ts | 363 +++++++++++++++++--------------- 1 file changed, 191 insertions(+), 172 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 20a0e898e0d8..8c3039980294 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -19,7 +19,6 @@ import { tempDir, tls, tmpdirSync, - withoutAggressiveGC, } from "harness"; import { once } from "events"; @@ -967,14 +966,7 @@ function testBlobInterface(blobbyConstructor: { (..._: any[]): any }, hasBlobFn? const compare = new Uint8Array(await response.arrayBuffer()); if (withGC) gc(); - withoutAggressiveGC(() => { - for (let i = 0; i < compare.length; i++) { - if (withGC) gc(); - - expect(compare[i]).toBe(bytes[i]); - if (withGC) gc(); - } - }); + expect(compare).toEqual(bytes); if (withGC) gc(); }); @@ -990,14 +982,8 @@ function testBlobInterface(blobbyConstructor: { (..._: any[]): any }, hasBlobFn? const compare = await response.bytes(); if (withGC) gc(); - withoutAggressiveGC(() => { - for (let i = 0; i < compare.length; i++) { - if (withGC) gc(); - - expect(compare[i]).toBe(bytes[i]); - if (withGC) gc(); - } - }); + expect(compare).toBeInstanceOf(Uint8Array); + expect(compare).toEqual(bytes); if (withGC) gc(); }); @@ -1015,14 +1001,7 @@ function testBlobInterface(blobbyConstructor: { (..._: any[]): any }, hasBlobFn? const compare = new Uint8Array(await response.arrayBuffer()); if (withGC) gc(); - withoutAggressiveGC(() => { - for (let i = 0; i < compare.length; i++) { - if (withGC) gc(); - - expect(compare[i]).toBe(bytes[i]); - if (withGC) gc(); - } - }); + expect(compare).toEqual(bytes); if (withGC) gc(); }); @@ -1040,14 +1019,8 @@ function testBlobInterface(blobbyConstructor: { (..._: any[]): any }, hasBlobFn? const compare = await response.bytes(); if (withGC) gc(); - withoutAggressiveGC(() => { - for (let i = 0; i < compare.length; i++) { - if (withGC) gc(); - - expect(compare[i]).toBe(bytes[i]); - if (withGC) gc(); - } - }); + expect(compare).toBeInstanceOf(Uint8Array); + expect(compare).toEqual(bytes); if (withGC) gc(); }); @@ -1744,18 +1717,29 @@ it("#2794", () => { expect(typeof Bun.fetch.bind).toBe("function"); }); -it("#3545", () => { - expect(() => fetch("http://example.com?a=b")).not.toThrow(); +it("#3545", async () => { + // A query directly after the host, with no path, used to fail to open the socket. + using server = Bun.serve({ + port: 0, + fetch(req) { + const { pathname, search } = new URL(req.url); + return Response.json({ pathname, search }); + }, + }); + const response = await fetch(`${server.url.origin}?a=b`); + expect(await response.json()).toEqual({ pathname: "/", search: "?a=b" }); + expect(response.url).toBe(`${server.url.origin}/?a=b`); + expect(response.status).toBe(200); }); -it("invalid header doesnt crash", () => { - expect(() => - fetch("http://example.com", { - headers: { - ["lol!!!!!" + "emoji" + "😀"]: "hello", - }, - }), - ).toThrow(); +it("invalid header doesnt crash", async () => { + const promise = fetch("http://127.0.0.1:1/", { + headers: { + ["lol!!!!!" + "emoji" + "😀"]: "hello", + }, + }); + await expect(promise).rejects.toBeInstanceOf(TypeError); + await expect(promise).rejects.toThrow("Invalid header name: 'lol!!!!!emoji😀'"); }); it("new Request(https://example.com, otherRequest) uses url from left instead of right", () => { @@ -1833,7 +1817,7 @@ it("should work with http 100 continue", async () => { }); const { promise: start, resolve } = Promise.withResolvers(); - server.listen(8080, resolve); + server.listen(0, resolve); await start; @@ -1863,7 +1847,7 @@ it("should work with http 100 continue on the same buffer", async () => { }); const { promise: start, resolve } = Promise.withResolvers(); - server.listen(8080, resolve); + server.listen(0, resolve); await start; @@ -2121,11 +2105,14 @@ it.concurrent("should allow very long redirect URLS", async () => { }); }, }); - // run it more times to check Malformed_HTTP_Response errors - for (let i = 0; i < 100; i++) { - const { url, status } = await fetch(`${server.url.origin}/redirect`); - expect(url).toBe(`${server.url.origin}${Location}`); - expect(status).toBe(404); + // Sequential iterations reuse the pooled keep-alive connections, so a redirect body left + // undrained (#8874) surfaces as Malformed_HTTP_Response on a following request. + for (let i = 0; i < 20; i++) { + const response = await fetch(`${server.url.origin}/redirect`); + expect(response.url).toBe(`${server.url.origin}${Location}`); + expect(response.redirected).toBe(true); + expect(await response.text()).toBe("Not Found"); + expect(response.status).toBe(404); } }); @@ -2344,9 +2331,8 @@ describe("fetch Response life cycle", () => { await using serverProcess = Bun.spawn({ cmd: [bunExe(), "--smol", fetchFixture3], - stderr: "inherit", - stdout: "inherit", - stdin: "inherit", + stderr: "pipe", + stdout: "pipe", env: bunEnv, ipc(message) { deferred.resolve(message); @@ -2356,12 +2342,24 @@ describe("fetch Response life cycle", () => { const serverUrl = await deferred.promise; await using clientProcess = Bun.spawn({ cmd: [bunExe(), "--smol", fetchFixture4, serverUrl], - stderr: "inherit", - stdout: "inherit", - stdin: "inherit", + stderr: "pipe", + stdout: "pipe", env: bunEnv, }); - expect(await clientProcess.exited).toBe(0); + const [stdout, stderr, exitCode] = await Promise.all([ + clientProcess.stdout.text(), + clientProcess.stderr.text(), + clientProcess.exited, + ]); + // The fixture asserts the post-GC Response/Promise counts itself and reports a + // violation on stderr; it logs one heap summary per iteration on stdout. + expect(stderr).toBe(""); + expect(stdout.match(/Response: \d+/g)).toHaveLength(10); + expect(exitCode).toBe(0); + + serverProcess.kill(); + const [serverStdout, serverStderr] = await Promise.all([serverProcess.stdout.text(), serverProcess.stderr.text()]); + expect({ serverStdout, serverStderr }).toEqual({ serverStdout: "", serverStderr: "" }); }); it("should allow to get promise result after response is GC'd", async () => { using server = Bun.serve({ @@ -2622,12 +2620,15 @@ describe("fetch should allow duplex", () => { }).not.toThrow(); }); + // The remaining cases each spend their time waiting on their own child process or + // servers, so they run concurrently with each other. + // When the download source is faster than the upload target, the response- // body ByteStream must pause the source socket instead of buffering the // rate difference in-process. Before the fix, a chunk arriving before the // upload sink attached flipped the source to BufferAll and RSS grew at the // line rate (several GB in seconds on localhost). - it("bounds memory when the upload target is slower than the download source", async () => { + it.concurrent("bounds memory when the upload target is slower than the download source", async () => { const fixture = ` const net = require("node:net"); const chunk = Buffer.alloc(64 * 1024, 0x47); @@ -2678,7 +2679,7 @@ describe("fetch should allow duplex", () => { // A type:"direct" stream body where pull does `await controller.write(chunk)` // in a loop must suspend when the sink is backpressured: write() returns a // pending Promise once the stream buffer is over the high-water mark. - it("suspends a type:'direct' body's controller.write() when the upload target is backpressured", async () => { + it.concurrent("suspends a type:'direct' body's controller.write() when the upload target is stalled", async () => { const fixture = ` const net = require("node:net"); const sink = net.createServer(sock => sock.pause()); @@ -2729,7 +2730,7 @@ describe("fetch should allow duplex", () => { // Passing a response body as a request body attaches it to a native sink; // the source stream must be marked locked + disturbed so a second consumer // errors instead of hanging on data that will never be delivered to it. - it("locks the response body when it is used as a request body", async () => { + it.concurrent("locks the response body when it is used as a request body", async () => { await using source = Bun.serve({ port: 0, fetch: () => new Response(new ReadableStream({ pull: c => c.enqueue(new Uint8Array(64 * 1024)) })), @@ -2771,7 +2772,7 @@ describe("fetch should allow duplex", () => { // back-pressure the uploading client rather than buffering the difference: // the request-body ByteStream stops reading from the inbound socket while // the outbound sink is over its high-water mark. - it("bounds memory when a handler forwards req.body to a stalled target", async () => { + it.concurrent("bounds memory when a handler forwards req.body to a stalled target", async () => { const fixture = ` const net = require("node:net"); const CHUNK = Buffer.alloc(64 * 1024, 0x47), COUNT = 2048; // 128 MB @@ -3385,134 +3386,152 @@ it("does not reuse a keep-alive connection whose response carried more bytes tha }); // https://github.com/oven-sh/bun/issues/16682 -it("an explicit numeric `timeout` extends the socket idle deadline past the default", async () => { - // The child runs with a 1s idle default (BUN_CONFIG_HTTP_IDLE_TIMEOUT=1) and - // talks to an in-process server whose handler holds every request idle for - // 10s (longer than the worst-case firing window of the 1s idle timer, which - // is swept on uSockets' 4s tick) before responding. - // - // - `timeout: 60_000` must override the 1s idle default and resolve. - // - `timeout: 0` must keep meaning "no timeout" and resolve. - // - no `timeout` at all must still hit the 1s idle default (control that - // proves the env override and the stall are both real). - const script = /* js */ ` - const HOLD_MS = 10_000; +it.concurrent( + "an explicit numeric `timeout` extends the socket idle deadline past the default", + async () => { + // The child runs with a 1s idle default (BUN_CONFIG_HTTP_IDLE_TIMEOUT=1) and + // talks to an in-process server that holds every request idle (no bytes in + // either direction) until a control request carrying no `timeout` has been + // aborted by that default. The idle timer is swept on uSockets' 4s tick, so + // the control fires at the first tick after it is armed (~4s into the test). + // + // - `timeout: 60_000` must override the 1s idle default and resolve. + // - `timeout: 0` / `timeout: Infinity` must keep meaning "no timeout" and resolve. + // - the control must still hit the 1s idle default (proves the env override + // and the stall are both real). It is sent only after the server has seen + // the three explicit requests, so their idle timers were armed no later + // than the control's: a build that ignored the explicit `timeout` would + // have aborted them by the sweep that aborts the control, i.e. before the + // server releases the held responses. + const script = /* js */ ` + const explicitArrived = Promise.withResolvers(); + const release = Promise.withResolvers(); + let arrived = 0; using server = Bun.serve({ port: 0, // Disable Bun.serve's own request idle timeout; only the client-side // idle timer under test may abort anything here. idleTimeout: 0, async fetch(req) { - const arrived = Date.now(); - // Hold the connection idle (no bytes in either direction) until the - // hold window has really elapsed on the server's clock. - while (Date.now() - arrived < HOLD_MS) { - await Bun.sleep(HOLD_MS - (Date.now() - arrived)); - } + if (++arrived === 3) explicitArrived.resolve(); + await release.promise; return new Response("hello"); }, }); - const get = init => fetch(server.url, init).then(r => r.text(), e => "ERR:" + (e?.code ?? e?.name ?? e)); - const [withTimeout, withZero, withInfinity, withDefault] = await Promise.all([ - get({ timeout: 60_000 }), - get({ timeout: 0 }), - get({ timeout: Infinity }), - get(undefined), - ]); + const get = init => fetch(server.url, init).then(r => r.text(), e => e?.name + ": " + e?.message); + const explicit = Promise.all([get({ timeout: 60_000 }), get({ timeout: 0 }), get({ timeout: Infinity })]); + await explicitArrived.promise; + const withDefault = await get(undefined); + release.resolve(); + const [withTimeout, withZero, withInfinity] = await explicit; console.log(JSON.stringify({ withTimeout, withZero, withInfinity, withDefault })); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: { ...bunEnv, BUN_CONFIG_HTTP_IDLE_TIMEOUT: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const out = JSON.parse(stdout.trim().split("\n").pop()!) as Record; - expect({ withTimeout: out.withTimeout, withZero: out.withZero, withInfinity: out.withInfinity }).toEqual({ - withTimeout: "hello", - withZero: "hello", - withInfinity: "hello", - }); - // Control: without an explicit `timeout`, the 1s idle default still aborts - // the stalled request. - expect(out.withDefault).toStartWith("ERR:"); - expect(exitCode).toBe(0); -}, 60_000); - -it("the idle timer is an absolute deadline for the response header block (not re-armed by a byte drip)", async () => { - // A server that trickles one response-header byte at a time, each interval - // shorter than the request's idle timeout, must not be able to keep the - // request alive indefinitely. The idle timer is armed when the request is - // written and is not re-armed on partial header reads, so it bounds how long - // the header block may take to arrive in total (undici `headersTimeout` - // semantics). Once the header block completes the body path re-arms per - // chunk, so a slow-but-steady body is still accepted. - const BODY = "abc"; - const HEAD = `HTTP/1.1 200 OK\r\nContent-Length: ${BODY.length}\r\n\r\n`; - const DRIP_MS = 2_000; - const DRIP_N = 10; // header drip sends this many single bytes, then the rest at once - const IDLE_MS = 5_000; - - const sockets = new Set(); - const intervals = new Set>(); - const server = net.createServer(sock => { - sockets.add(sock); - sock.on("close", () => sockets.delete(sock)); - sock.on("error", () => {}); - sock.once("data", chunk => { - // /h drips DRIP_N header bytes then bursts the rest + body. - // /b bursts the header block then drips the body byte-by-byte. - const headerDrip = chunk.includes("/h "); - if (!headerDrip) sock.write(HEAD); - const dripped = headerDrip ? HEAD.slice(0, DRIP_N) : BODY; - const tail = headerDrip ? HEAD.slice(DRIP_N) + BODY : ""; - let i = 0; - const iv = setInterval(() => { - if (sock.destroyed) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, BUN_CONFIG_HTTP_IDLE_TIMEOUT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + withTimeout: "hello", + withZero: "hello", + withInfinity: "hello", + withDefault: "TimeoutError: The operation timed out.", + }); + expect(exitCode).toBe(0); + }, + 60_000, +); + +it.concurrent( + "the idle timer is an absolute deadline for the response header block (not re-armed by a byte drip)", + async () => { + // A server that trickles one response-header byte at a time, each interval + // shorter than the request's idle timeout, must not be able to keep the + // request alive indefinitely. The idle timer is armed when the request is + // written and is not re-armed on partial header reads, so it bounds how long + // the header block may take to arrive in total (undici `headersTimeout` + // semantics). Once the header block completes the body path re-arms per + // chunk, so a slow-but-steady body is still accepted. + // + // IDLE_MS is 2 ticks of uSockets' 4s timeout sweep, so an armed timer fires + // 4-8s later, at the second sweep after it was (re-)armed. + const BODY = "body bytes that trickle in one at a time"; + const HEAD = `HTTP/1.1 200 OK\r\nContent-Length: ${BODY.length}\r\n\r\n`; + const DRIP_MS = 1_000; + const DRIP_N = 20; // header drip sends this many single bytes, then the rest at once + const IDLE_MS = 5_000; + + const sockets = new Set(); + const intervals = new Set>(); + // Resolves, once /b has been requested, with a function that ends /b's body. + const bodyDrip = Promise.withResolvers<() => void>(); + const server = net.createServer(sock => { + sockets.add(sock); + sock.on("close", () => sockets.delete(sock)); + sock.on("error", () => {}); + sock.once("data", chunk => { + // /h drips DRIP_N header bytes, then bursts the rest of the head + body. + // /b bursts the head, then drips body bytes until the test ends the body. + const headerDrip = chunk.includes("/h "); + if (!headerDrip) sock.write(HEAD); + const dripped = headerDrip ? HEAD.slice(0, DRIP_N) : BODY.slice(0, -1); + let i = 0; + let finished = false; + const finish = () => { + if (finished) return; + finished = true; clearInterval(iv); intervals.delete(iv); - return; - } - if (i < dripped.length) { + if (!sock.destroyed) sock.end(headerDrip ? HEAD.slice(DRIP_N) + BODY : BODY.slice(i)); + }; + const iv = setInterval(() => { + if (sock.destroyed || i === dripped.length) { + finish(); + return; + } sock.write(dripped[i++]); - } else { - clearInterval(iv); - intervals.delete(iv); - sock.end(tail); - } - }, DRIP_MS); - intervals.add(iv); + }, DRIP_MS); + intervals.add(iv); + if (!headerDrip) bodyDrip.resolve(finish); + }); }); - }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as AddressInfo).port; + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; - try { - const settle = (path: string) => - fetch(`http://127.0.0.1:${port}${path}`, { timeout: IDLE_MS }).then( - async r => ({ ok: true as const, status: r.status, body: await r.text() }), - e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) }), - ); + try { + const settle = (path: string) => + fetch(`http://127.0.0.1:${port}${path}`, { timeout: IDLE_MS }).then( + async r => ({ ok: true as const, status: r.status, body: await r.text() }), + e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) }), + ); - // /h: DRIP_N bytes * DRIP_MS = ~20s of drip before the response would - // complete; the 5s idle deadline (uSockets 4s-tick sweep, so ~5-9s) must - // fire first. A build that re-arms on every partial header read resolves - // 200 after the full drip instead. - // /b: headers arrive in one write, then the 3-byte body trickles at - // DRIP_MS/byte (~8s). Each body chunk re-arms the idle timer, so this - // resolves despite taking longer than IDLE_MS overall. - const [hdr, bod] = await Promise.all([settle("/h"), settle("/b")]); - expect({ hdr, bod }).toEqual({ - hdr: { ok: false, name: "TimeoutError", message: "The operation timed out." }, - bod: { ok: true, status: 200, body: BODY }, - }); - } finally { - for (const iv of intervals) clearInterval(iv); - for (const s of sockets) s.destroy(); - await new Promise(r => server.close(() => r())); - } -}, 60_000); + // /b goes first, and /h is only sent once the server has /b's request, so + // /b's idle timer was armed no later than /h's. /h's header drip (DRIP_N * + // DRIP_MS = 20s) outlasts the deadline, so /h is aborted by the second sweep + // after it was armed; a build that re-armed on partial header reads would + // resolve it with a 200 after the full drip instead. That sweep (or an + // earlier one) would also have aborted /b had its body bytes not re-armed + // the timer, so /b still being there to receive the rest of its body once + // /h has failed is what proves the body path re-arms. + const bod = settle("/b"); + const finishBody = await bodyDrip.promise; + const hdr = await settle("/h"); + finishBody(); + expect({ hdr, bod: await bod }).toEqual({ + hdr: { ok: false, name: "TimeoutError", message: "The operation timed out." }, + bod: { ok: true, status: 200, body: BODY }, + }); + } finally { + for (const iv of intervals) clearInterval(iv); + for (const s of sockets) s.destroy(); + await new Promise(r => server.close(() => r())); + } + }, + 60_000, +); it.skipIf(isWindows)("sends the exact Content-Length for a file body of 100 GB", async () => { using dir = tempDir("fetch-large-file-body", { "large.bin": "" }); From c4ccb0397816cd03347626531ca99dc87b000627 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:57:47 +0000 Subject: [PATCH 2/3] test(fetch): report a /b body timeout through the assertion fetch() resolves once the response head is in, so a timeout on /b's body surfaces from text() and was thrown out of the test instead of showing up in the hdr/bod comparison. Also only let the test end /b's body: running out of drip bytes no longer completes the response by itself. --- test/js/web/fetch/fetch.test.ts | 39 ++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 8c3039980294..511d290e1666 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3473,40 +3473,43 @@ it.concurrent( sock.on("close", () => sockets.delete(sock)); sock.on("error", () => {}); sock.once("data", chunk => { - // /h drips DRIP_N header bytes, then bursts the rest of the head + body. - // /b bursts the head, then drips body bytes until the test ends the body. + // /h drips DRIP_N header bytes, then bursts the rest of the head + body so + // that a build which keeps re-arming gets a 200 instead of hanging. + // /b bursts the head, then drips body bytes; only the test ends its body. const headerDrip = chunk.includes("/h "); if (!headerDrip) sock.write(HEAD); const dripped = headerDrip ? HEAD.slice(0, DRIP_N) : BODY.slice(0, -1); let i = 0; - let finished = false; - const finish = () => { - if (finished) return; - finished = true; - clearInterval(iv); - intervals.delete(iv); - if (!sock.destroyed) sock.end(headerDrip ? HEAD.slice(DRIP_N) + BODY : BODY.slice(i)); - }; const iv = setInterval(() => { - if (sock.destroyed || i === dripped.length) { - finish(); + if (!sock.destroyed && i < dripped.length) { + sock.write(dripped[i++]); return; } - sock.write(dripped[i++]); + clearInterval(iv); + intervals.delete(iv); + if (headerDrip && !sock.destroyed) sock.end(HEAD.slice(DRIP_N) + BODY); }, DRIP_MS); intervals.add(iv); - if (!headerDrip) bodyDrip.resolve(finish); + if (!headerDrip) { + bodyDrip.resolve(() => { + clearInterval(iv); + intervals.delete(iv); + if (!sock.destroyed) sock.end(BODY.slice(i)); + }); + } }); }); await new Promise(r => server.listen(0, "127.0.0.1", () => r())); const port = (server.address() as AddressInfo).port; try { + // fetch() resolves once the head is in, so /b's timeout would surface from + // text(); catch() rather than a rejection handler on the same then() so it + // ends up in the assertion below instead of being thrown out of the test. const settle = (path: string) => - fetch(`http://127.0.0.1:${port}${path}`, { timeout: IDLE_MS }).then( - async r => ({ ok: true as const, status: r.status, body: await r.text() }), - e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) }), - ); + fetch(`http://127.0.0.1:${port}${path}`, { timeout: IDLE_MS }) + .then(async r => ({ ok: true as const, status: r.status, body: await r.text() })) + .catch(e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) })); // /b goes first, and /h is only sent once the server has /b's request, so // /b's idle timer was armed no later than /h's. /h's header drip (DRIP_N * From bf6aa70788ddfa4834e9fc7e446055f1a6fbd08a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:15:29 +0000 Subject: [PATCH 3/3] test(fetch): describe what the long redirect loop actually exercises A 302 that carries a body has its connection closed rather than drained and pooled, so repeating the round trip cannot surface an undrained redirect body; the only reuse the loop adds is the second round's /redirect riding the connection pooled by the first round's final response. Run it twice and say so, and point at fetch-keepalive.test.ts for the redirect pooling matrix. --- test/js/web/fetch/fetch.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 511d290e1666..7acb96417428 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -2105,9 +2105,10 @@ it.concurrent("should allow very long redirect URLS", async () => { }); }, }); - // Sequential iterations reuse the pooled keep-alive connections, so a redirect body left - // undrained (#8874) surfaces as Malformed_HTTP_Response on a following request. - for (let i = 0; i < 20; i++) { + // Twice: the second round's /redirect rides the connection pooled by the first round's + // final response. (The 302's own connection is closed because it carries a body; which + // redirect responses get pooled is pinned by connection count in fetch-keepalive.test.ts.) + for (let i = 0; i < 2; i++) { const response = await fetch(`${server.url.origin}/redirect`); expect(response.url).toBe(`${server.url.origin}${Location}`); expect(response.redirected).toBe(true);