Skip to content

fix: don't emit the chunked terminator for zero-length body writes#936

Open
songhieu wants to merge 1 commit into
cloudflare:mainfrom
songhieu:fix/chunked-empty-write-terminator
Open

fix: don't emit the chunked terminator for zero-length body writes#936
songhieu wants to merge 1 commit into
cloudflare:mainfrom
songhieu:fix/chunked-empty-write-terminator

Conversation

@songhieu

Copy link
Copy Markdown

Fixes #935.

What

In ChunkedEncoding mode, BodyWriter encoded a zero-length application write as 0\r\n\r\n — which on the wire is the chunked terminator. finish() then wrote the terminator again, so the peer received the end-of-body sequence twice and parsed the second one as the start of a new message, desyncing the (keep-alive) connection.

This PR makes zero-length writes a no-op in chunked mode, in both write paths:

  • do_write_chunked_body (async path): return Ok(Some(0)) without touching the stream.
  • poll_write_chunked_body_task (cancel-safe task path): complete the task as Done(0) without writing.

finish() remains the only place that emits the terminator.

Why it matters in practice

proxy_h1.rs forwards the final downstream body chunk to the upstream even when it is empty (the mid-stream guard is deliberately !upstream_end_of_body && ...). An HTTP/2 downstream that ends the request body with an empty DATA frame carrying END_STREAM — Cloudflare's HTTP/2 to Origin does this for streamed POST bodies, as does curl -T - — therefore produces Body(Some(empty), end=true), and the H1 upstream receives:

Transfer-Encoding: chunked

11
{"hello":"world"}
0

0

Strict upstream parsers (e.g. uvicorn/h11) reject the duplicate terminator as a malformed pipelined request (400 Invalid HTTP request received.) and the poisoned pooled connection then serves that stale error to unrelated proxied requests. Full write-up with reproduction and captured wire bytes in #935.

Tests

  • write_body_chunked_ignores_empty_chunk — async path: data chunk, empty write (asserts no wire bytes via the mock's exact-write expectations), single terminator on finish().
  • write_body_task_chunked_ignores_empty_chunk — task path: same assertions through send_body_task / write_current_body_task.

cargo test -p pingora-core --lib protocols::http::v1::body passes.

In ChunkedEncoding mode a zero-length write was encoded as "0\r\n\r\n",
which on the wire is the chunked terminator; finish() then wrote the
terminator again. The peer parses the duplicate as the start of a new
message, desyncing keep-alive connections.

This is hit in practice by H2->H1 proxying: proxy_h1 forwards the final
downstream chunk even when empty, and H2 downstreams that end the
request body with an empty DATA frame + END_STREAM (Cloudflare
HTTP/2-to-Origin, curl -T) trigger the double terminator. Strict
upstream parsers (uvicorn/h11) reject it with 400 and the poisoned
pooled connection serves the stale error to unrelated requests.

Make zero-length writes a no-op in both chunked write paths (async
do_write_chunked_body and the cancel-safe poll task path); finish()
remains the only place that emits the terminator.

Fixes cloudflare#935

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant