Files
bun.sh/test/regression/issue/18413.test.ts
robobun 72490281e5 fix: handle empty chunked gzip responses correctly (#22360)
## Summary
Fixes #18413 - Empty chunked gzip responses were causing `Decompression
error: ShortRead`

## The Issue
When a server sends an empty response with `Content-Encoding: gzip` and
`Transfer-Encoding: chunked`, Bun was throwing a `ShortRead` error. This
occurred because the code was checking if `avail_in == 0` (no input
data) and immediately returning an error, without attempting to
decompress what could be a valid empty gzip stream.

## The Fix
Instead of checking `avail_in == 0` before calling `inflate()`, we now:
1. Always call `inflate()` even when `avail_in == 0` 
2. Check the return code from `inflate()`
3. If it returns `BufError` with `avail_in == 0`, then we truly need
more data and return `ShortRead`
4. If it returns `StreamEnd`, it was a valid empty gzip stream and we
finish successfully

This approach correctly distinguishes between "no data yet" and "valid
empty gzip stream".

## Why This Works
- A valid empty gzip stream still has headers and trailers (~20 bytes)
- The zlib `inflate()` function can handle empty streams correctly  
- `BufError` with `avail_in == 0` specifically means "need more input
data"

## Test Plan
 Added regression test in `test/regression/issue/18413.test.ts`
covering:
- Empty chunked gzip response
- Empty non-chunked gzip response  
- Empty chunked response without gzip

 Verified all existing gzip-related tests still pass
 Tested with the original failing case from the issue

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-03 18:57:39 -07:00

98 lines
2.7 KiB
TypeScript

import { serve } from "bun";
import { expect, test } from "bun:test";
import { Readable } from "node:stream";
import { createGzip } from "node:zlib";
/**
* Regression test for issue #18413
* "Decompression error: ShortRead - empty chunked gzip response breaks fetch()"
*
* The issue was in Bun's zlib.zig implementation, which was incorrectly returning
* error.ShortRead when encountering empty gzip streams (when avail_in == 0).
*
* The fix is to call inflate() even when avail_in == 0, as this could be a valid
* empty gzip stream with proper headers/trailers. If inflate returns BufError
* with avail_in == 0, then we know we truly need more data and can return ShortRead.
*/
test("empty chunked gzip response should work", async () => {
using server = serve({
port: 0,
async fetch(req) {
// Create an empty gzip stream
const gzipStream = createGzip();
gzipStream.end(); // End immediately without writing data
// Convert to web stream
const webStream = Readable.toWeb(gzipStream);
return new Response(webStream, {
headers: {
"Content-Encoding": "gzip",
"Transfer-Encoding": "chunked",
"Content-Type": "text/plain",
},
});
},
});
const response = await fetch(`http://localhost:${server.port}`);
expect(response.status).toBe(200);
// This should not throw "Decompression error: ShortRead"
const text = await response.text();
expect(text).toBe(""); // Empty response
});
test("empty gzip response without chunked encoding", async () => {
using server = serve({
port: 0,
async fetch(req) {
// Create an empty gzip buffer
const emptyGzip = Bun.gzipSync(Buffer.alloc(0));
return new Response(emptyGzip, {
headers: {
"Content-Encoding": "gzip",
"Content-Type": "text/plain",
"Content-Length": emptyGzip.length.toString(),
},
});
},
});
const response = await fetch(`http://localhost:${server.port}`);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("");
});
test("empty chunked response without gzip", async () => {
using server = serve({
port: 0,
async fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
// Just close immediately
controller.close();
},
}),
{
headers: {
"Transfer-Encoding": "chunked",
"Content-Type": "text/plain",
},
},
);
},
});
const response = await fetch(`http://localhost:${server.port}`);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("");
});