fix: don't emit the chunked terminator for zero-length body writes - #936
fix: don't emit the chunked terminator for zero-length body writes#936songhieu wants to merge 1 commit into
Conversation
|
Downstream consumer verification from Fresh public state: protected The restacked exact head is now also hosted GREEN: build run For CWL acceptance we will exercise the actual H2-downstream → H1-upstream wire path, including an empty DATA+END_STREAM request-body tail, verify exactly one chunk terminator, then reuse the same H1 keep-alive connection for an independent follow-up request. We will not work around the RED by switching upstream ALPN to H2 or pinning this mutable contributor head. If the intended fix remains |
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>
9b5bba9 to
e40ed4c
Compare
|
Rebased onto current I also confirmed the bug is still present on current @seonghobae thanks for the note; the branch is now directly consumable on top of @drcaramelsyrup this is the PR for #935, ready for review whenever you have a moment. |
Fixes #935.
What
In
ChunkedEncodingmode,BodyWriterencoded a zero-length application write as0\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): returnOk(Some(0))without touching the stream.poll_write_chunked_body_task(cancel-safe task path): complete the task asDone(0)without writing.finish()remains the only place that emits the terminator.Why it matters in practice
proxy_h1.rsforwards 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 doescurl -T -— therefore producesBody(Some(empty), end=true), and the H1 upstream receives: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 onfinish().write_body_task_chunked_ignores_empty_chunk— task path: same assertions throughsend_body_task/write_current_body_task.cargo test -p pingora-core --lib protocols::http::v1::bodypasses.