From 1564616c7d485df44268423f5c8e3aa64b3ea42f Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 2 Jun 2026 15:16:16 -0700 Subject: [PATCH 1/7] fetch: resolve empty compressed responses as empty bodies A response that declares a Content-Encoding but sends zero body bytes (for example an empty chunked gzip response) failed with ZlibError: the decompressor ran on zero input at stream end and reported a truncated stream. Node resolves these as an empty body. Skip decompression entirely when the response delivered no body bytes. An empty final flush after real data still flushes (total_body_received is non-zero on that path). Fixes #23149 --- src/http/InternalState.rs | 9 +++++++++ test/js/web/fetch/fetch-gzip.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 0177fe18e7e..da68dd6f985 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -243,6 +243,15 @@ impl<'a> InternalState<'a> { body_out_str: &mut MutableString, is_final_chunk: bool, ) -> Result<(), Error> { + // A response that declared a Content-Encoding but sent zero body bytes + // (e.g. an empty chunked gzip response) has nothing to decompress. + // Running the decompressor anyway makes it report a truncated stream + // (ZlibError); Node treats this as an empty body. + if buffer.is_empty() && self.total_body_received == 0 { + self.compressed_body.reset(); + return Ok(()); + } + // PORT NOTE: Zig `defer this.compressed_body.reset()` runs on every exit. scopeguard would // hold &mut self.compressed_body across the body and conflict with &mut self.decompressor, // so each early-return below calls `self.compressed_body.reset()` explicitly. diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index abfe0a1f7eb..2c2d92bb2ca 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -293,3 +293,26 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don server.stop(); done(); }); + +describe("empty compressed responses", () => { + // A response that declares Content-Encoding but sends zero body bytes must + // resolve as an empty body, like Node — not fail with ZlibError. + // https://github.com/oven-sh/bun/issues/23149 + for (const [name, write] of Object.entries({ + "chunked": `HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n`, + "content-length-0": `HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n`, + })) { + it(`empty gzip body via ${name} resolves as empty`, async () => { + const { createServer } = await import("node:net"); + const raw = createServer(socket => void socket.write(write)); + await new Promise(resolve => raw.listen(0, () => resolve())); + const port = (raw.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(await res.text()).toBe(""); + } finally { + raw.close(); + } + }); + } +}); From d0c383c84b62ba64950a5567cbe9448c331a3a63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:39:05 +0000 Subject: [PATCH 2/7] test: use module-scope import for node:net --- test/js/web/fetch/fetch-gzip.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 2c2d92bb2ca..6d775835e51 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { gcTick } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; +import { createServer as createNetServer } from "node:net"; import { brotliCompressSync, deflateSync, gzipSync, zstdCompressSync } from "node:zlib"; import path from "path"; @@ -303,8 +304,7 @@ describe("empty compressed responses", () => { "content-length-0": `HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n`, })) { it(`empty gzip body via ${name} resolves as empty`, async () => { - const { createServer } = await import("node:net"); - const raw = createServer(socket => void socket.write(write)); + const raw = createNetServer(socket => void socket.write(write)); await new Promise(resolve => raw.listen(0, () => resolve())); const port = (raw.address() as { port: number }).port; try { From eec003e493f6b3ce6be7e2b23dd9a6fe276cca00 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:52:29 +0000 Subject: [PATCH 3/7] test: bind gzip TCP-server test to 127.0.0.1 so it cannot miss fetch's localhost resolution --- 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 6d775835e51..14476569f73 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -198,7 +198,9 @@ it("fetch() with a gzip response works (multiple chunks, TCP server)", async don let pending, pendingChunks = []; const server = Bun.listen({ - hostname: "localhost", + // Explicit IPv4 loopback: "localhost" may bind only ::1 while fetch() + // resolves it to 127.0.0.1, giving ConnectionRefused on some hosts. + hostname: "127.0.0.1", port: 0, socket: { drain(socket) { From c59a281bd59534c3d4cb991130e0f60a6c203ddf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:10:43 +0000 Subject: [PATCH 4/7] ci: retrigger From fb320189e432595171ee88afdcb2f97b02423fb9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:36:17 +0000 Subject: [PATCH 5/7] ci: allow CLDEMOTE hints in UCRT strpbrk on windows-x64-baseline Newer UCRT builds on the CI Windows agents contain cldemote (NP 0f 1c /0) encodings in strpbrk's vectorized path, tripping the static baseline scan outside the existing [AVX, AVX2] ceiling. CLDEMOTE is hint-space and architecturally a NOP on CPUs without it, so it is baseline-safe; widen the ceiling. --- scripts/verify-baseline-static/allowlist-x64-windows.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index 9078bd9e2aa..10f59e08652 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -846,7 +846,10 @@ sinf [AVX, FMA] sinh_fma [AVX, FMA] sinhf_fma [AVX, FMA] strnlen [AVX, AVX2] -strpbrk [AVX, AVX2] # UCRT vectorized str routine, runtime-gated on __isa_available +# CLDEMOTE: newer UCRT builds on the CI image surface cldemote cache hints in +# strpbrk's vectorized path. Hint-space encoding (NP 0f 1c /0) — architecturally +# a NOP on CPUs without CLDEMOTE (SDM vol. 2), so baseline-safe with no gate. +strpbrk [AVX, AVX2, CLDEMOTE] # UCRT vectorized str routine, runtime-gated on __isa_available tan [AVX, FMA] tanf [AVX, FMA] tanh_fma [AVX, FMA] From 7c37086553ccaad3a9048f05096d2097869a53ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:04:02 +0000 Subject: [PATCH 6/7] ci: drop per-symbol CLDEMOTE allowlist entry superseded by the global hint-space ignore --- scripts/verify-baseline-static/allowlist-x64-windows.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index 10f59e08652..9078bd9e2aa 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -846,10 +846,7 @@ sinf [AVX, FMA] sinh_fma [AVX, FMA] sinhf_fma [AVX, FMA] strnlen [AVX, AVX2] -# CLDEMOTE: newer UCRT builds on the CI image surface cldemote cache hints in -# strpbrk's vectorized path. Hint-space encoding (NP 0f 1c /0) — architecturally -# a NOP on CPUs without CLDEMOTE (SDM vol. 2), so baseline-safe with no gate. -strpbrk [AVX, AVX2, CLDEMOTE] # UCRT vectorized str routine, runtime-gated on __isa_available +strpbrk [AVX, AVX2] # UCRT vectorized str routine, runtime-gated on __isa_available tan [AVX, FMA] tanf [AVX, FMA] tanh_fma [AVX, FMA] From b8ffbf32744112b64525a3f4b387b004ad13ac66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:27:12 +0000 Subject: [PATCH 7/7] test: end the raw socket after writing the empty compressed response --- 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 14476569f73..892394375e2 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -306,7 +306,9 @@ describe("empty compressed responses", () => { "content-length-0": `HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n`, })) { it(`empty gzip body via ${name} resolves as empty`, async () => { - const raw = createNetServer(socket => void socket.write(write)); + // 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())); const port = (raw.address() as { port: number }).port; try {