mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## 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>
249 lines
7.0 KiB
TypeScript
249 lines
7.0 KiB
TypeScript
import { serve } from "bun";
|
|
import { expect, test } from "bun:test";
|
|
import { deflateRawSync, deflateSync } from "node:zlib";
|
|
|
|
/**
|
|
* Test deflate semantics - both zlib-wrapped and raw deflate
|
|
*
|
|
* HTTP Content-Encoding: deflate is ambiguous:
|
|
* - RFC 2616 (HTTP/1.1) says it should be zlib format (RFC 1950)
|
|
* - Many implementations incorrectly use raw deflate (RFC 1951)
|
|
*
|
|
* Bun should handle both gracefully, auto-detecting the format.
|
|
*/
|
|
|
|
// Test data
|
|
const testData = Buffer.from("Hello, World! This is a test of deflate encoding.");
|
|
|
|
// Test zlib-wrapped deflate (RFC 1950 - has 2-byte header and 4-byte Adler32 trailer)
|
|
test("deflate with zlib wrapper should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
// Create zlib-wrapped deflate (this is what the spec says deflate should be)
|
|
const compressed = deflateSync(testData);
|
|
|
|
// Verify it has a zlib header: CMF must be 0x78 and (CMF<<8 | FLG) % 31 == 0
|
|
expect(compressed[0]).toBe(0x78);
|
|
expect(((compressed[0] << 8) | compressed[1]) % 31).toBe(0);
|
|
return new Response(compressed, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe(testData.toString());
|
|
});
|
|
|
|
// Test raw deflate (RFC 1951 - no header/trailer, just compressed data)
|
|
test("raw deflate without zlib wrapper should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
// Create raw deflate (no zlib wrapper)
|
|
const compressed = deflateRawSync(testData);
|
|
|
|
// Verify it doesn't have zlib header (shouldn't start with 0x78)
|
|
expect(compressed[0]).not.toBe(0x78);
|
|
|
|
return new Response(compressed, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe(testData.toString());
|
|
});
|
|
|
|
// Test empty zlib-wrapped deflate
|
|
test("empty zlib-wrapped deflate should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const compressed = deflateSync(Buffer.alloc(0));
|
|
|
|
return new Response(compressed, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe("");
|
|
});
|
|
|
|
// Test empty raw deflate
|
|
test("empty raw deflate should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const compressed = deflateRawSync(Buffer.alloc(0));
|
|
|
|
return new Response(compressed, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe("");
|
|
});
|
|
|
|
// Test chunked zlib-wrapped deflate
|
|
test("chunked zlib-wrapped deflate should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const compressed = deflateSync(testData);
|
|
const mid = Math.floor(compressed.length / 2);
|
|
|
|
return new Response(
|
|
new ReadableStream({
|
|
async start(controller) {
|
|
controller.enqueue(compressed.slice(0, mid));
|
|
await Bun.sleep(50);
|
|
controller.enqueue(compressed.slice(mid));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Transfer-Encoding": "chunked",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe(testData.toString());
|
|
});
|
|
|
|
// Test chunked raw deflate
|
|
test("chunked raw deflate should work", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const compressed = deflateRawSync(testData);
|
|
const mid = Math.floor(compressed.length / 2);
|
|
|
|
return new Response(
|
|
new ReadableStream({
|
|
async start(controller) {
|
|
controller.enqueue(compressed.slice(0, mid));
|
|
await Bun.sleep(50);
|
|
controller.enqueue(compressed.slice(mid));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Transfer-Encoding": "chunked",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
const text = await response.text();
|
|
expect(text).toBe(testData.toString());
|
|
});
|
|
|
|
// Test truncated zlib-wrapped deflate (missing trailer)
|
|
test("truncated zlib-wrapped deflate should fail", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const compressed = deflateSync(testData);
|
|
// Remove the 4-byte Adler32 trailer
|
|
const truncated = compressed.slice(0, -4);
|
|
|
|
return new Response(truncated, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
await response.text();
|
|
expect.unreachable("Should have thrown decompression error");
|
|
} catch (err: any) {
|
|
expect(err.code).toMatch(/ZlibError|ShortRead/);
|
|
}
|
|
});
|
|
|
|
// Test invalid deflate data (not deflate at all)
|
|
test("invalid deflate data should fail", async () => {
|
|
using server = serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
// Random bytes that are neither zlib-wrapped nor raw deflate
|
|
const invalid = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc, 0xfb]);
|
|
|
|
return new Response(invalid, {
|
|
headers: {
|
|
"Content-Encoding": "deflate",
|
|
"Content-Type": "text/plain",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`http://localhost:${server.port}`);
|
|
await response.text();
|
|
expect.unreachable("Should have thrown decompression error");
|
|
} catch (err: any) {
|
|
expect(err.code).toMatch(/ZlibError/);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Documentation of deflate semantics in Bun:
|
|
*
|
|
* When Content-Encoding: deflate is received, Bun's HTTP client should:
|
|
* 1. Attempt to decompress as zlib format (RFC 1950) first
|
|
* 2. If that fails with a header error, retry as raw deflate (RFC 1951)
|
|
* 3. This handles both correct implementations and common misimplementations
|
|
*
|
|
* The zlib format has:
|
|
* - 2-byte header with compression method and flags
|
|
* - Compressed data using DEFLATE algorithm
|
|
* - 4-byte Adler-32 checksum trailer
|
|
*
|
|
* Raw deflate has:
|
|
* - Just the compressed data, no header or trailer
|
|
*
|
|
* Empty streams:
|
|
* - Empty zlib-wrapped: Has header and trailer, total ~8 bytes
|
|
* - Empty raw deflate: Minimal DEFLATE stream, ~2-3 bytes
|
|
*/
|