fix(http1): avoid hanging a pipelined connection after a backpressured response - #2
Open
fotinakis wants to merge 1 commit into
Open
fix(http1): avoid hanging a pipelined connection after a backpressured response#2fotinakis wants to merge 1 commit into
fotinakis wants to merge 1 commit into
Conversation
fotinakis
force-pushed
the
fix/http1-pipelined-response-wakeup
branch
from
July 13, 2026 02:23
93a2647 to
71208a5
Compare
…esponse An HTTP/1 server connection can wedge permanently when a pipelined request is parsed while the previous response is still flushing under write backpressure. Sequence: - The client pipelines request B behind request A. - Request A's response is large and the socket is slow, so it flushes across many polls under backpressure. - While A's response drains, request B is parsed and dispatched, so a response is already waiting once A finishes. - When A's response finally flushes, the connection idles the write side back to `Init`, but `poll_loop` yields without writing B's response and parks with no pending IO. Nothing ever wakes it, so B's response is never sent and the connection hangs until the peer times out. Two gaps combine to cause it, both in the dispatcher's write path: 1. `can_write_again` only reported "more to write" for an in-progress response body (`body_rx`). It didn't account for a freshly dispatched response whose head hasn't been written yet, so `poll_loop` yielded instead of writing it. 2. Even once `can_write_again` reports the pending response, the loop's write re-check writes and buffers it but `poll_write` returns `Pending` without flushing (the write buffer isn't full yet). The re-check then returned without flushing, leaving the committed response buffered with no IO pending to wake the task. Fix both: `can_write_again` also returns true when a response has been dispatched and the connection can write its head; and the write re-check flushes after a pending write so the buffered response actually goes out and, under backpressure, registers a wake-up. Found via randomized, deterministic simulation testing of the HTTP/1 server under write backpressure; the fix clears the reproducing case and a sweep of thousands of seeds shows no remaining hangs.
fotinakis
force-pushed
the
fix/http1-pipelined-response-wakeup
branch
from
July 14, 2026 00:00
71208a5 to
b2005cb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An HTTP/1 server connection can hang permanently when a pipelined request is parsed while the previous response is still flushing under write backpressure. The pipelined request's response gets buffered but never written, and the task parks with no pending IO to wake it, so the connection sits open and idle until the peer times out.
Sequence
Writing::Init— butpoll_loopyields without writing B's response, and parks with nothing pending on the IO. Nothing ever wakes the task, so B's response is never sent.Final observed state is
State { reading: KeepAlive, writing: Init, keep_alive: Disabled }with a dispatched-but-unwritten response in hand.Root cause
Two gaps in the dispatcher's write path combine:
1.
can_write_againdoesn't count a freshly dispatched response. It only reported "more to write" for an in-progress body (body_rx):When A's flush completes and idles the write side,
body_rxisNone(A is done) even though B has been dispatched and its head hasn't been written. Sowants_write_againis false andpoll_loopheads for its exit.2. The write re-check buffers B's response but doesn't flush it. Once
can_write_againreports B as writable, the loop's write re-check runspoll_write, which encodes and buffers B's response — but returnsPendingwithout flushing, because the write buffer isn't full yet. The re-check then returned on thatPending:So B's response ends up committed in
write_buf, the task yields, and nothing registered a wake-up — a lost wakeup. B is never flushed to the socket.Fix
Both changes are in
dispatch.rs:can_write_againalso returns true when a response has been dispatched (should_poll()) and the connection can write its head (can_write_head()), so the loop writes it instead of yielding.Pendingwrite in the loop's re-check, if there's no in-progress body, flush before yielding, so a just-buffered dispatched response actually goes out and — under backpressure — registers a wake-up. Gating on the no-body case leaves the streaming flush cadence untouched (covered bytests/ready_on_poll_stream.rs).Testing
Found and reproduced with a deterministic simulation harness around the HTTP/1 server: a single-threaded, seeded scheduler driving the real
serve_connectionfuture over an in-memory transport with bounded send buffers (sopoll_writereturnsPendinglike a full socket), randomized request pipelining, and a liveness check that the connection must terminate.masterand completes cleanly with this fix.cargo test --features fullsuite passes (288 tests).Notes
I didn't add an in-tree regression test here: reliably reproducing the parse-B-while-A-flushes-under-backpressure interleaving needs a controllable-backpressure IO and step-level scheduling, which is what the external simulation harness provides. Happy to port a minimal deterministic reproducer into
tests/if you'd like one. The maintainer note on hyperium#4018 thatpoll_loopis fragile and would benefit from an explicitFlushingstate is consistent with what this bug looks like from the inside — this change is a targeted fix rather than that larger refactor.