Skip to content

fetch: resolve empty compressed responses as empty bodies#31736

Merged
Jarred-Sumner merged 8 commits into
mainfrom
ali/fetch-empty-gzip-body
Jun 3, 2026
Merged

fetch: resolve empty compressed responses as empty bodies#31736
Jarred-Sumner merged 8 commits into
mainfrom
ali/fetch-empty-gzip-body

Conversation

@alii

@alii alii commented Jun 2, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Fixes #23149: a response that declares a Content-Encoding but sends zero body bytes — e.g. 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.

// server: HTTP/1.1 200 OK
//         Content-Encoding: gzip
//         Transfer-Encoding: chunked
//
//         0\r\n\r\n
const res = await fetch(url);
await res.text(); // before: throws ZlibError — after: ""

The fix is an early-out in decompress_bytes: when the response delivered no body bytes at all (buffer empty and total_body_received == 0), there is nothing to decompress. An empty final flush after real data still flushes normally (total_body_received is non-zero on that path).

Tests

Two cases added to test/js/web/fetch/fetch-gzip.test.ts using a raw socket server: empty gzip body via chunked encoding (fails on released Bun with ZlibError, passes with this PR) and via Content-Length: 0 (already worked; pinned). fetch-gzip.test.ts + fetch.test.ts suites pass locally (the only failures are pre-existing external-network tests that fail identically on released Bun in this environment).

CI notes

  • The "multiple chunks, TCP server" test in the same file was pinned to 127.0.0.1: binding localhost can pick ::1 while fetch() resolves localhost to 127.0.0.1, which made it fail ConnectionRefused on some hosts.
  • windows-x64-baseline-verify-baseline failed with strpbrk [CLDEMOTE] — newer UCRT on the CI Windows agents emits cldemote (NP 0f 1c /0, hint-space, NOP where unsupported) in strpbrk. An interim per-symbol allowlist widening unblocked this branch; main then landed the proper global ignore (Hardening: input validation and compatibility fixes across 9 subsystems (round 9) #31606), so this branch merged main and dropped the interim entry — no verifier changes remain in the diff.

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
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 46 minutes and 57 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 827693ee-8e67-4191-826a-3b043c1907ba

📥 Commits

Reviewing files that changed from the base of the PR and between 3f153b9 and b8ffbf3.

📒 Files selected for processing (2)
  • src/http/InternalState.rs
  • test/js/web/fetch/fetch-gzip.test.ts

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator
Updated 11:05 PM PT - Jun 2nd, 2026

@robobun, your commit b8ffbf3 has 3 failures in Build #60080 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31736

That installs a local version of the PR into your bun-31736 executable, so you can run:

bun-31736 --bun

@alii alii enabled auto-merge (squash) June 2, 2026 22:18
@alii

alii commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Adopted, validated, all review feedback addressed — ready for maintainer sign-off.

CI (build 60080): 282 jobs passed; the 3 red tests are all timeout/measurement heuristics with no connection to this diff:

  • streams-leak chunk-coalescing (chunks.length > 20, got 10) on one aarch64 agent — identity-encoded response, never enters the decompression path; main build 60061 (same merged content) passes it.
  • terminal.test.ts PTY-spawn 90s timeout and node-http-backpressure-max (4 GB identity transfer) 60s timeout, both on one darwin 14 x64 agent — same lanes passed on this branch's earlier builds (60046/60055/60066).

All fetch/http-decompression lanes are green everywhere they ran. Needs a maintainer to merge or re-run the two red shards.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — tight, well-guarded early-out that matches Node's behavior; the inline note is just a test-style nit.

Extended reasoning...

Overview

This PR adds a 3-line guard at the top of InternalState::decompress_bytes in src/http/InternalState.rs: when the incoming buffer is empty and total_body_received == 0, skip decompression entirely and return Ok(()). This fixes #23149, where a response declaring Content-Encoding: gzip but delivering zero body bytes (e.g. an empty chunked body) would surface a ZlibError from the decompressor's truncated-stream check. Two regression tests are added in test/js/web/fetch/fetch-gzip.test.ts — one for the chunked case (the actual bug) and one pinning the already-working Content-Length: 0 case.

I traced the call sites: total_body_received is incremented before every path that reaches decompress_bytes (lib.rs:4082, :4147, :4234, :4319), so the guard correctly distinguishes "never received any body" from "empty final flush after real data" — the latter still runs the decompressor as the PR description notes. The guard also calls compressed_body.reset() to preserve the existing reset-on-every-exit invariant documented in the PORT NOTE just below it.

Security risks

None. This relaxes an error into an empty body only in the degenerate zero-byte case, matching Node/undici. It cannot cause partially-decompressed data to be silently accepted, since any non-zero total_body_received bypasses the guard.

Level of scrutiny

This is core fetch() decompression, so it's production-critical, but the change is minimal (one conservative conditional), well-commented, and covered by new tests that exercise the exact wire format from the issue. The blast radius is narrow: the new branch is unreachable unless the server sent literally zero body bytes.

Other factors

The only finding from the bug-hunting pass is a style nit — the new test uses await import("node:net") inside the test body rather than a module-scope aliased import, contrary to test/CLAUDE.md. This doesn't affect correctness and isn't worth blocking on. No CODEOWNERS cover these paths and there are no outstanding human review comments.

Comment thread test/js/web/fetch/fetch-gzip.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — tight early-out guarded by total_body_received == 0, nit addressed.

Extended reasoning...

Overview

Fixes #23149: a response declaring Content-Encoding but delivering zero body bytes (e.g. an empty chunked gzip response) threw ZlibError because the decompressor was finalized on an empty stream. The fix is a 5-line early-return at the top of InternalState::decompress_bytes in src/http/InternalState.rs, plus two regression tests in test/js/web/fetch/fetch-gzip.test.ts and an incidental flake fix (localhost127.0.0.1) in a pre-existing test in the same file.

Security risks

None. The early-return is gated on buffer.is_empty() && self.total_body_received == 0, so it only fires when no body bytes were ever delivered — it cannot cause compressed data to be skipped or partially decoded. It does not touch auth, TLS, header parsing, or memory ownership; the compressed_body.reset() mirrors the deferred reset that already runs on every other exit path of this function.

Level of scrutiny

Low-to-medium. The change sits in the HTTP client decompression path, but it is a narrow, additive guard whose precondition I verified against every total_body_received += ... site in src/http/lib.rs (4082, 4147, 4234, 4319) — all increment before decompression is invoked, so the guard's semantics ("never received any body bytes") hold. The PR description's note that an empty final flush after real data still goes through the decompressor (because total_body_received > 0 by then) checks out.

Other factors

  • robobun adopted and validated fail-before/pass-after under debug+ASAN and release, with all CI lanes green.
  • My only prior feedback (hoist the dynamic node:net import to module scope) was addressed in d0c383c and the thread is resolved.
  • No CODEOWNERS entries cover src/http/ or this test file.
  • The bug-hunting system found nothing this round.

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.
Comment thread scripts/verify-baseline-static/allowlist-x64-windows.txt Outdated
Comment thread test/js/web/fetch/fetch-gzip.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All earlier feedback is addressed and I didn't find anything further — deferring to a human for sign-off since this is a behavior change in the fetch decompression path.

Extended reasoning...

Overview

The PR adds a 4-line early-return guard in InternalState::decompress_bytes (src/http/InternalState.rs): when the incoming buffer is empty and total_body_received == 0, skip decompression and return Ok(()). This makes fetch() resolve a response that declares Content-Encoding but delivers zero body bytes (e.g. an empty chunked gzip response) as an empty body, matching Node, instead of surfacing a ZlibError from the decompressor's truncated-stream check. Two regression tests are added in test/js/web/fetch/fetch-gzip.test.ts (chunked and Content-Length: 0 variants), plus an unrelated hardening of an existing test in the same file (localhost127.0.0.1 to avoid IPv6/IPv4 bind/resolve mismatch).

I traced the callers in src/http/lib.rs: every path that reaches decompress_bytes updates total_body_received before the call, so the guard genuinely fires only when the entire response carried zero body bytes — an empty intermediate streaming flush with later real data won't be affected (later calls have total_body_received > 0). The compressed_body.reset() in the new branch mirrors what every other exit from this function already does.

Security risks

None identified. The change relaxes an error into an empty-body success for the zero-input case only; it doesn't expand what input reaches the decompressor or introduce any new parsing.

Level of scrutiny

Moderate. The diff is tiny and well-commented, with fail-before/pass-after test coverage verified by robobun. But it's a behavior change in the HTTP client's response-decompression path — one of Bun's most-exercised code paths — so a maintainer glance is appropriate. I also note the parallel src/http/InternalState.zig does not carry the equivalent guard; someone familiar with the Zig→Rust port status should confirm that's expected (the passing tests indicate the Rust path is the live one).

Other factors

All three of my earlier inline nits (dynamic import → module-scope, CLDEMOTE allowlist → superseded by main, socket.writesocket.end) were addressed and the corresponding threads are resolved; the interim CI-unblock rider was dropped after merging main, so the diff is now purely the fetch fix + tests. No CODEOWNERS entry covers these files. The bug-hunting pass found nothing.

@Jarred-Sumner Jarred-Sumner disabled auto-merge June 3, 2026 06:06
@Jarred-Sumner Jarred-Sumner merged commit 79b5471 into main Jun 3, 2026
74 of 78 checks passed
@Jarred-Sumner Jarred-Sumner deleted the ali/fetch-empty-gzip-body branch June 3, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decompression error: ZlibError - empty chunked gzip response breaks fetch()

3 participants