Skip to content

Keep an upgraded tunnel open when the request body ends after 101 - #947

Open
dorianverlaine wants to merge 1 commit into
cloudflare:mainfrom
dorianverlaine:fix-upgrade-tunnel-request-body-end
Open

Keep an upgraded tunnel open when the request body ends after 101#947
dorianverlaine wants to merge 1 commit into
cloudflare:mainfrom
dorianverlaine:fix-upgrade-tunnel-request-body-end

Conversation

@dorianverlaine

Copy link
Copy Markdown

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 never
connected, 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_downstream queues a single
end-of-body task into the pipe before the upstream has answered anything. In
proxy_handle_upstream that end is then handled while was_upgraded() is already true:

body = rx.recv(), if !request_done => {
    match send_body_to1(client_session, body).await {
        Ok(send_done) => {
            request_done = send_done;                          // empty body ends -> true
            // An upgraded request is terminated when either side is done
            if request_done && client_session.was_upgraded() { // 101 already read -> true
                response_done = true;                          // both true -> loop exits
            }

The response branch already handles the mirror-image ordering: when the 101 is read
after the end of the request body, it resets request_done so the loop keeps reading
from 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:

  • end-of-body first (upstream slower): was_upgraded() is still false, so
    response_done stays false; the 101 then resets request_done — tunnel works.
  • 101 first (upstream fast enough): was_upgraded() is already true when the queued
    end 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:

environment upgrades that survived
idle 10-core macOS host 40/40
2-core Linux container (docker run --cpus=2) 34/40
same container, any delay before the origin's 101 (including a bare yield) 15/15

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() returning None)
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_filter runs before the
end-of-body task is queued, so delaying it forces the failing order every time. The test
adds an x-delay-request-body hook to the existing test proxy — the same shape as the
x- 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.rs
change and keeping the test:

without the fix:  panicked at 'tunnel closed instead of echoing the payload back'
with the fix:     ok

Full suites on rust:1.97-bookworm with the openresty mock origin:

test_upstream : 99 passed; 0 failed; 4 ignored
test_basic    : 15 passed; 0 failed
cargo fmt --all -- --check                  : clean
cargo clippy -p pingora-proxy --all-targets : no new warnings

🤖 Generated with Claude Code

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>
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.

HTTP/1 upgrade torn down when the upstream's 101 is read before the request's empty body

1 participant