Skip to content
9 changes: 9 additions & 0 deletions src/http/InternalState.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 28 additions & 1 deletion test/js/web/fetch/fetch-gzip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 () => {
Comment thread
robobun marked this conversation as resolved.
// 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<void>(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();
}
});
}
});
Loading