diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index c993b8325e77..090b2b0c08ef 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 abfe0a1f7eba..892394375e2e 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"; @@ -197,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) { @@ -293,3 +296,27 @@ 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 () => { + // 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 { + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(await res.text()).toBe(""); + } finally { + raw.close(); + } + }); + } +});