From 582caf32c81dbcdc30170614e3d112e0a53c7d23 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 1 Jun 2026 16:50:17 -0700 Subject: [PATCH 1/8] fetch: hand off buffered response bodies instead of copying them Two full-body copies on the buffered fetch response path are avoidable: - FetchTasklet::callback copied the accumulated response bytes into scheduled_response_buffer on every delivery. For the final delivery into an empty scheduled buffer - the common buffered case, where one callback carries the complete body - swap the two vectors instead. Intermediate streaming chunks keep the copy so response_buffer retains its capacity for reuse, and close-delimited bodies that deliver across multiple callbacks keep appending. - The libdeflate gzip path decompressed bodies up to the shared scratch buffer's size (512KB) into that scratch and then copied the result into the response buffer. Use the gzip ISIZE trailer to reserve the exact size and decompress directly into the response buffer for all sizes (the >512KB branch already worked this way). The reservation stays capped at 32MB; a trailer too small for the actual data makes decompress_to_vec fail with InsufficientSpace and fall through to the existing streaming slow path, and the multi-chunk paths still disable this fast path before any accumulation happens. Differential-tested against the previous behavior: honest gzip (small/large), multi-member, corrupted ISIZE/CRC, truncated streams, empty gzip, uncompressed, close-delimited multi-callback bodies, and streamed chunked consumption all produce byte-identical results or the same error codes. --- src/http/InternalState.rs | 13 +++---- src/runtime/webcore/fetch/FetchTasklet.rs | 23 ++++++++++--- test/js/web/fetch/fetch-gzip.test.ts | 42 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 0177fe18e7e0..ca1c6746069e 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -277,9 +277,12 @@ impl<'a> InternalState<'a> { // gzip stores the size of the uncompressed data in the last 4 bytes of the stream // But it's only valid if the stream is less than 4.7 GB, since it's 4 bytes. - // If we know that the stream is going to be larger than our - // pre-allocated buffer, then let's dynamically allocate the exact - // size. + // When the trailer gives us a plausible size, decompress straight + // into the caller's buffer instead of going through the shared + // scratch buffer and copying the whole body a second time. A lying + // trailer is harmless: the reservation is capped at 32 MB, and an + // undersized reservation makes decompress_to_vec fail with + // InsufficientSpace, which falls through to the streaming slow path. if self.encoding == Encoding::Gzip && buffer.len() > 16 && buffer.len() < 1024 * 1024 * 1024 @@ -290,9 +293,7 @@ impl<'a> InternalState<'a> { .expect("infallible: size matches"), ); // Since this is arbtirary input from the internet, let's set an upper bound of 32 MB for the allocation size. - if (estimated_size as usize) > deflater.shared_buffer.len() - && estimated_size < 32 * 1024 * 1024 - { + if estimated_size > 0 && estimated_size < 32 * 1024 * 1024 { body_out_str.list.reserve_exact( (estimated_size as usize).saturating_sub(body_out_str.list.len()), ); diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 2af0b7bba9fb..68f19d031a49 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2275,11 +2275,24 @@ impl FetchTasklet { } } else { if success { - bun_core::handle_oom( - task_ref - .scheduled_response_buffer - .write(task_ref.response_buffer.list.as_slice()), - ); + if task_ref.scheduled_response_buffer.list.is_empty() && !task_ref.result.has_more + { + // Final delivery into an empty scheduled buffer — the common + // buffered-response case, where this callback carries the + // complete body. Hand the accumulated bytes over instead of + // copying them. Intermediate streaming chunks keep the copy + // below so `response_buffer` retains its capacity for reuse. + core::mem::swap( + &mut task_ref.scheduled_response_buffer.list, + &mut task_ref.response_buffer.list, + ); + } else { + bun_core::handle_oom( + task_ref + .scheduled_response_buffer + .write(task_ref.response_buffer.list.as_slice()), + ); + } } // reset for reuse task_ref.response_buffer.reset(); diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index abfe0a1f7eba..7028df738b3a 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -293,3 +293,45 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don server.stop(); done(); }); + +describe("gzip response edge cases", () => { + // Behavior pins for the libdeflate fast path and its fallbacks: honest + // streams decode byte-exactly; integrity violations (which both libdeflate + // and zlib verify) reject with ZlibError regardless of which decode path + // ran. The corrupted-trailer cases also exercise the + // exact-size-reservation branch falling through to the streaming path. + const payload = Buffer.alloc(300 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = (i * 13) & 0xff; + + function corrupt(data: Buffer, offsetFromEnd: number) { + const gz = Buffer.from(Bun.gzipSync(data)); + gz[gz.length - offsetFromEnd] ^= 0xff; + return gz; + } + + const cases: Record = { + "honest-large": { body: Bun.gzipSync(payload), expected: payload }, + "honest-small": { body: Bun.gzipSync(Buffer.from("hello gzip world")), expected: Buffer.from("hello gzip world") }, + "empty": { body: Bun.gzipSync(Buffer.alloc(0)), expected: Buffer.alloc(0) }, + "isize-corrupt": { body: corrupt(payload, 1), expected: "error" }, + "crc-corrupt": { body: corrupt(payload, 8), expected: "error" }, + "truncated": { body: Buffer.from(Bun.gzipSync(payload)).subarray(0, 1000), expected: "error" }, + }; + + for (const [name, c] of Object.entries(cases)) { + it(`decodes or rejects: ${name}`, async () => { + using server = Bun.serve({ + port: 0, + fetch: () => new Response(c.body, { headers: { "Content-Encoding": "gzip" } }), + }); + if (c.expected === "error") { + expect(async () => { + await (await fetch(server.url)).arrayBuffer(); + }).toThrow(); + } else { + const got = Buffer.from(await (await fetch(server.url)).arrayBuffer()); + expect(Buffer.compare(got, c.expected)).toBe(0); + } + }); + } +}); From 8101f1c5e9ebb4945093b970eaf2a8a6f4ad0644 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:01:54 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- src/runtime/webcore/fetch/FetchTasklet.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 68f19d031a49..786eb773b581 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2275,8 +2275,7 @@ impl FetchTasklet { } } else { if success { - if task_ref.scheduled_response_buffer.list.is_empty() && !task_ref.result.has_more - { + if task_ref.scheduled_response_buffer.list.is_empty() && !task_ref.result.has_more { // Final delivery into an empty scheduled buffer — the common // buffered-response case, where this callback carries the // complete body. Hand the accumulated bytes over instead of From 4adc39b79fae8173b23e2cddf8d0af5ea639fa5b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:19:31 +0000 Subject: [PATCH 3/8] test: assert gzip error cases via rejects, cover undersized-ISIZE fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error assertions now await the full fetch+read chain with .rejects.toThrow("ZlibError") — the rejection surfaces from fetch() itself for fully-buffered bodies, which the arrayBuffer()-only form would leak. Rename isize-corrupt to isize-oversized (its ~4.28 GB trailer is rejected by the 32 MB cap and never enters the exact-size reservation) and add isize-undersized, whose in-cap but too-small trailer makes decompress_to_vec fail with InsufficientSpace and fall through to the streaming slow path. --- test/js/web/fetch/fetch-gzip.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 7028df738b3a..3559684714c3 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -298,8 +298,12 @@ describe("gzip response edge cases", () => { // Behavior pins for the libdeflate fast path and its fallbacks: honest // streams decode byte-exactly; integrity violations (which both libdeflate // and zlib verify) reject with ZlibError regardless of which decode path - // ran. The corrupted-trailer cases also exercise the - // exact-size-reservation branch falling through to the streaming path. + // ran. The corrupted-trailer cases cover each exit from the exact-size + // reservation: `crc-corrupt` enters it and falls through on BadData, + // `isize-undersized` enters it with a reservation too small for the actual + // data and falls through on InsufficientSpace, and `isize-oversized` + // (~4.28 GB trailer) is rejected by the 32 MB cap and takes the shared + // scratch-buffer path instead. const payload = Buffer.alloc(300 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = (i * 13) & 0xff; @@ -313,7 +317,12 @@ describe("gzip response edge cases", () => { "honest-large": { body: Bun.gzipSync(payload), expected: payload }, "honest-small": { body: Bun.gzipSync(Buffer.from("hello gzip world")), expected: Buffer.from("hello gzip world") }, "empty": { body: Bun.gzipSync(Buffer.alloc(0)), expected: Buffer.alloc(0) }, - "isize-corrupt": { body: corrupt(payload, 1), expected: "error" }, + // ISIZE trailer is the last 4 bytes, little-endian; 300 KiB = 0x0004B000. + // Flipping the MSB yields 0xFF04B000 (> 32 MB cap); flipping the second + // byte yields 0x00044F00 = 282368 (< actual 307200, so the exact-size + // reservation comes up short). + "isize-oversized": { body: corrupt(payload, 1), expected: "error" }, + "isize-undersized": { body: corrupt(payload, 3), expected: "error" }, "crc-corrupt": { body: corrupt(payload, 8), expected: "error" }, "truncated": { body: Buffer.from(Bun.gzipSync(payload)).subarray(0, 1000), expected: "error" }, }; @@ -325,9 +334,9 @@ describe("gzip response edge cases", () => { fetch: () => new Response(c.body, { headers: { "Content-Encoding": "gzip" } }), }); if (c.expected === "error") { - expect(async () => { - await (await fetch(server.url)).arrayBuffer(); - }).toThrow(); + // Depending on delivery, the rejection can surface from fetch() + // itself (fully-buffered body) or from reading the body. + await expect(fetch(server.url).then(r => r.arrayBuffer())).rejects.toThrow("ZlibError"); } else { const got = Buffer.from(await (await fetch(server.url)).arrayBuffer()); expect(Buffer.compare(got, c.expected)).toBe(0); From 8a8dcd7ce8dd6ff6fe2e85ac4dd256f3dd8f3f50 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:30:10 +0000 Subject: [PATCH 4/8] test: bind the gzip TCP-server test to 127.0.0.1 explicitly Bun.listen({ hostname: "localhost" }) can bind only ::1 while fetch() resolves localhost to 127.0.0.1, making the test fail with ConnectionRefused depending on the environment's resolver ordering. Pin both sides to the same loopback address. --- test/js/web/fetch/fetch-gzip.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 3559684714c3..28b067241bbf 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -197,7 +197,9 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don let pending, pendingChunks = []; const server = Bun.listen({ - hostname: "localhost", + // Explicit loopback IP: "localhost" can resolve to ::1 for the listener + // while fetch() connects to 127.0.0.1, yielding ConnectionRefused. + hostname: "127.0.0.1", port: 0, socket: { drain(socket) { From f1322c4e773f2b2b97e5c6183ab87f3f2a26a3bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:14:21 +0000 Subject: [PATCH 5/8] ci: retrigger From 01dbffbcc9a1b5159d79c72c492092ba6d3e70c1 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 4 Jun 2026 21:53:17 -0700 Subject: [PATCH 6/8] fetch: right-size lying gzip ISIZE reservations and fall back on trailing data The reservation fast path reads the buffer's last 4 bytes as the gzip ISIZE trailer. With trailing data after the stream those bytes are attacker-chosen: a tiny body could reserve up to the 32 MB cap, and the oversized Vec is later adopted as-is into JS objects whose GC accounting sees only len - the capacity is invisible to the collector and accumulates across requests. Treat a decode that does not consume the whole buffer as a miss for the fast path (the streaming slow path already handles trailing data the same way released versions do), and shrink grossly oversized reservations - capacity more than twice len with at least 64 KB of excess - before the buffer leaves the HTTP layer. Tests: trailing junk spelling a huge ISIZE must decode the real body with bounded RSS across 64 requests; gzip with trailing data decodes without corruption, matching released behavior. --- src/http/InternalState.rs | 20 ++++++++- test/js/web/fetch/fetch-gzip.test.ts | 63 +++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 9caaf8447444..33b9e32dcd38 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -312,8 +312,26 @@ impl<'a> InternalState<'a> { &mut body_out_str.list, bun_libdeflate::Encoding::Gzip, ); - if result.status == bun_libdeflate::Status::Success { + // Trailing bytes after the gzip stream mean the ISIZE + // we reserved from wasn't the real trailer — fall to + // the streaming slow path, which handles multi-member + // streams and rejects garbage. + if result.status == bun_libdeflate::Status::Success + && result.read == buffer.len() + { still_needs_to_decompress = false; + // The ISIZE trailer is attacker-controlled: a + // lying value reserves up to 32 MB for a tiny + // body, and the buffer is later adopted as-is + // into JS objects whose GC accounting sees only + // `len`. Right-size grossly oversized + // reservations before they leave the HTTP layer. + let list = &mut body_out_str.list; + if list.capacity() > list.len().saturating_mul(2) + && list.capacity() - list.len() > 64 * 1024 + { + list.shrink_to_fit(); + } } break 'libdeflate; diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 5421d3bc39f8..96da43b3c841 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -1,6 +1,6 @@ import { Socket } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; -import { gcTick } from "harness"; +import { bunEnv, bunExe, gcTick } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; import { createServer as createNetServer } from "node:net"; @@ -371,3 +371,64 @@ describe("empty compressed responses", () => { }); } }); + +describe("gzip ISIZE trailer handling", () => { + // The 4-byte ISIZE trailer is attacker-controlled input that sizes the + // decompress reservation. A lying value must neither break decoding nor + // leave a grossly oversized allocation pinned behind the body's bytes. + it("gzip with trailing data decodes the first member without corruption", async () => { + // Trailing bytes (a second gzip member) make the first member's ISIZE + // trailer not the buffer's last 4 bytes — the reservation fast path must + // fall through without duplicating or corrupting output. Decoding only + // the first member matches released Bun's (and the slow path's) behavior. + const a = Buffer.from("first-member "); + const b = Buffer.from("second-member"); + const body = Buffer.concat([Buffer.from(Bun.gzipSync(a)), Buffer.from(Bun.gzipSync(b))]); + using server = Bun.serve({ + port: 0, + fetch: () => new Response(body, { headers: { "Content-Encoding": "gzip" } }), + }); + const text = await (await fetch(server.url)).text(); + expect(text).toBe("first-member "); + }); + + it("a lying huge ISIZE on a tiny body does not retain the reservation", async () => { + // 30 MB ISIZE on a ~40-byte body: the response must decode correctly and + // the process must not accumulate ~30 MB per request of GC-invisible + // capacity behind the adopted bytes. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // valid tiny gzip + trailing junk whose last 4 bytes spell a huge + // ISIZE: the reservation reads the buffer's final 4 bytes, which with + // trailing data are attacker-chosen (the real trailer is intact, so + // the stream itself still decodes) + const lie = Buffer.alloc(4); + lie.writeUInt32LE(30 * 1024 * 1024, 0); + const gz = Buffer.concat([Buffer.from(Bun.gzipSync(Buffer.from("tiny body payload"))), lie]); + using server = Bun.serve({ + port: 0, + fetch: () => new Response(gz, { headers: { "Content-Encoding": "gzip" } }), + }); + const held = []; + for (let i = 0; i < 64; i++) { + held.push(await (await fetch(server.url)).bytes()); + if (held[i].length !== 17) throw new Error("bad decode: " + held[i].length); + } + Bun.gc(true); + // 64 lying responses x 30MB capacity would be ~1.9GB; bounded means fixed + console.log(JSON.stringify({ rssMB: Math.round(process.memoryUsage.rss() / 1048576) })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const { rssMB } = JSON.parse(stdout.trim().split("\n").at(-1)); + expect(rssMB).toBeLessThan(700); + expect(exitCode).toBe(0); + }); +}); From 53f2bc7d898fcf2018baabbd1d4a7a51e421d262 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:40:31 +0000 Subject: [PATCH 7/8] fetch: drop the lying-ISIZE reservation on the fast path's miss exit The shrink added in 01dbffbc sat inside the read == buffer.len() branch, where libdeflate has just verified the trailer equals the output length, so capacity never grossly exceeds len there. The lying-trailer case it was written for takes the other exit: Success with trailing data skips the shrink, the slow path decodes into the same Vec without reallocating, and the swap hands up to 32 MB of capacity to JS behind a tiny body. The reservation's pages are never touched, so RSS cannot see it; VmSize grows ~34 MB per held response. Clear the partial output and drop oversized capacity on that exit instead, and keep the success-path shrink for capacity carried over from a reused connection. The subprocess test now measures the VmSize delta on Linux (fails at 2198 MB without this fix, 517 MB with it) and drains stderr so a child failure surfaces the real error instead of an empty-JSON parse error. --- src/http/InternalState.rs | 24 ++++++++++++++++++------ test/js/web/fetch/fetch-gzip.test.ts | 24 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 33b9e32dcd38..dc3b875eba10 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -320,18 +320,30 @@ impl<'a> InternalState<'a> { && result.read == buffer.len() { still_needs_to_decompress = false; - // The ISIZE trailer is attacker-controlled: a - // lying value reserves up to 32 MB for a tiny - // body, and the buffer is later adopted as-is - // into JS objects whose GC accounting sees only - // `len`. Right-size grossly oversized - // reservations before they leave the HTTP layer. + // Right-size before the buffer leaves the HTTP + // layer: the list can carry large capacity from a + // previous response on a reused connection, and + // it is later adopted as-is into JS objects whose + // GC accounting sees only `len`. let list = &mut body_out_str.list; if list.capacity() > list.len().saturating_mul(2) && list.capacity() - list.len() > 64 * 1024 { list.shrink_to_fit(); } + } else { + // The buffer's last 4 bytes weren't the real + // trailer (trailing data) or the decode failed, + // so the reservation was sized from + // attacker-chosen bytes. Discard the partial + // output and the oversized reservation before the + // slow path reuses this list; the capacity would + // otherwise ride along behind the body's bytes + // (up to 32 MB pinned per tiny response). + body_out_str.list.clear(); + if body_out_str.list.capacity() > 64 * 1024 { + body_out_str.list.shrink_to_fit(); + } } break 'libdeflate; diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 96da43b3c841..6cb989921f25 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -395,7 +395,9 @@ describe("gzip ISIZE trailer handling", () => { it("a lying huge ISIZE on a tiny body does not retain the reservation", async () => { // 30 MB ISIZE on a ~40-byte body: the response must decode correctly and // the process must not accumulate ~30 MB per request of GC-invisible - // capacity behind the adopted bytes. + // capacity behind the adopted bytes. The reservation's pages are never + // touched, so retained capacity is invisible to RSS — on Linux, measure + // the VmSize delta across the loop, which it cannot hide from. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -412,6 +414,12 @@ describe("gzip ISIZE trailer handling", () => { port: 0, fetch: () => new Response(gz, { headers: { "Content-Encoding": "gzip" } }), }); + function vmSizeMB() { + if (process.platform !== "linux") return null; + const status = require("node:fs").readFileSync("/proc/self/status", "utf8"); + return Number(status.match(/VmSize:\\s+(\\d+) kB/)[1]) / 1024; + } + const vszBefore = vmSizeMB(); const held = []; for (let i = 0; i < 64; i++) { held.push(await (await fetch(server.url)).bytes()); @@ -419,16 +427,24 @@ describe("gzip ISIZE trailer handling", () => { } Bun.gc(true); // 64 lying responses x 30MB capacity would be ~1.9GB; bounded means fixed - console.log(JSON.stringify({ rssMB: Math.round(process.memoryUsage.rss() / 1048576) })); + console.log(JSON.stringify({ + rssMB: Math.round(process.memoryUsage.rss() / 1048576), + vszDeltaMB: vszBefore === null ? null : Math.round(vmSizeMB() - vszBefore), + })); `, ], env: bunEnv, stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - const { rssMB } = JSON.parse(stdout.trim().split("\n").at(-1)); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { rssMB, vszDeltaMB } = JSON.parse(stdout.trim().split("\n").at(-1)); expect(rssMB).toBeLessThan(700); + if (process.platform === "linux") { + // retained reservations would be 64 x ~30MB ≈ 1.9GB of address space + expect(vszDeltaMB).toBeLessThan(1024); + } expect(exitCode).toBe(0); }); }); From 9c791c886f257cb00650d9947f932dfa6785c00c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:59:37 +0000 Subject: [PATCH 8/8] test: bind the empty-compressed-response servers to 127.0.0.1 A bare listen(0) can bind only the IPv6 unspecified address on some hosts while the test fetches 127.0.0.1, the same mismatch already pinned for the TCP-server test in this file. --- test/js/web/fetch/fetch-gzip.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 6cb989921f25..f76d5ed08072 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -360,7 +360,9 @@ describe("empty compressed responses", () => { // end() rather than write(): FIN the connection after the response so // nothing is left parked in the keep-alive pool when the server closes. const raw = createNetServer(socket => void socket.end(write)); - await new Promise(resolve => raw.listen(0, () => resolve())); + // Explicit IPv4 loopback: a bare listen(0) can bind only the IPv6 + // unspecified address on some hosts while the fetch targets 127.0.0.1. + await new Promise(resolve => raw.listen(0, "127.0.0.1", () => resolve())); const port = (raw.address() as { port: number }).port; try { const res = await fetch(`http://127.0.0.1:${port}/`);