Keep an upgraded tunnel open when the request body ends after 101 - #947
Open
dorianverlaine wants to merge 1 commit into
Open
Keep an upgraded tunnel open when the request body ends after 101#947dorianverlaine wants to merge 1 commit into
dorianverlaine wants to merge 1 commit into
Conversation
An upgrade request carries no body, so the downstream half queues a single end-of-body task before the upstream has answered anything. If the 101 is read before that task is handled, the queued end sets request_done while was_upgraded() is already true, which also marks the response done and exits the duplex loop. The tunnel is torn down the instant it is established: the client receives the 101 and then EOF, and the client-to-upstream direction is never connected. Which of the two is handled first is decided by task scheduling, so this shows up as a fraction of upgrades failing under load and never on an idle host. Account for the end of the request body exactly once, in whichever branch observes it first, so that only a later end coming from the tunnel itself terminates the loop. A closed task pipe still terminates immediately, since that is the downstream half going away. Add a deterministic regression that delays the request body filter so the 101 is always read first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Fixes #946.
What happens
An HTTP/1 upgrade (WebSocket) is torn down the instant it is established: the client
receives the
101, then EOF. The client-to-upstream direction of the tunnel is neverconnected, so nothing the client sends after the handshake reaches the origin.
It does not happen every time. On an idle machine it essentially never happens, which is
why it reads as a flaky test rather than a bug.
Why
An upgrade request carries no body, so
proxy_handle_downstreamqueues a singleend-of-body task into the pipe before the upstream has answered anything. In
proxy_handle_upstreamthat end is then handled whilewas_upgraded()is already true:The response branch already handles the mirror-image ordering: when the 101 is read
after the end of the request body, it resets
request_doneso the loop keeps readingfrom downstream. What is missing is that the same end-of-body task can also be handled
after the 101, and then nothing distinguishes "the request finished" from "the tunnel
finished".
Which order occurs is decided by task scheduling, not by anything on the wire:
was_upgraded()is still false, soresponse_donestays false; the 101 then resetsrequest_done— tunnel works.was_upgraded()is already true when the queuedend arrives, so both flags end up true — tunnel is dropped.
Measured in a downstream project built on pingora-proxy, forwarding WebSocket upgrades to
a local origin:
docker run --cpus=2)yield)The last row is what identifies the cause: inserting a yield point before the response
reorders the two tasks, and the failures disappear entirely.
The fix
Exactly one end-of-body task belongs to the original request. Account for it once, in
whichever branch observes it first, so that only a later end — one that genuinely comes
from the tunnel — terminates the loop. A closed task pipe (
rx.recv()returningNone)still terminates immediately, because that is the downstream half going away rather than a
request body ending.
Test
The race cannot be pinned from the wire, but
request_body_filterruns before theend-of-body task is queued, so delaying it forces the failing order every time. The test
adds an
x-delay-request-bodyhook to the existing test proxy — the same shape as thex-hooks already there — and asserts that the tunnel still carries a payload afterwards.It is deterministic in both directions, verified by reverting only the
proxy_h1.rschange and keeping the test:
Full suites on
rust:1.97-bookwormwith the openresty mock origin:🤖 Generated with Claude Code