Skip to content

fix(http1): withdraw a stale want when the client dispatcher takes a request - #4208

Draft
shodoco wants to merge 2 commits into
hyperium:masterfrom
shodoco:h1-stale-want
Draft

shodoco wants to merge 2 commits into
hyperium:masterfrom
shodoco:h1-stale-want

Conversation

@shodoco

@shodoco shodoco commented Sep 28, 2026

Copy link
Copy Markdown

Closes #4207.

Draft: blocked on seanmonstar/want#6. The fix calls want::Taker::unwant(), which no want release has yet. The second commit patches want to that PR's branch so this builds and CI can run. Once want is released, I'll replace that commit with a want version bump and mark this ready for review.

The problem

An HTTP/1 SendRequest reports ready through a shared want flag. The connection task sets it when it finds its request queue empty, and send_request clears it with give() before queuing a request. The task's want() can land after that give(), so the request is taken with the flag still set. There are two ways, both described in #4207:

  • The task finds the queue empty, then send_request runs on another thread before the task calls want().
  • tokio's coop budget makes the queue return Pending with the request already in it.

While a request is in flight, the dispatcher doesn't poll the queue, so nothing clears the flag until the response is complete:

sequenceDiagram
    participant S as SendRequest
    participant F as want flag
    participant T as connection task
    Note over F: Want (connection idle)
    S->>F: give(): Want → Idle
    S->>T: request queued
    rect rgb(255, 228, 228)
    T->>F: want(): Idle → Want (stale)
    end
    T->>T: poll_recv: Ready(request)
    Note over T: writes the request and reads the response
    S->>F: is_ready()? true, with the request in flight
Loading

hyper-util's legacy pool checks is_ready() when a response head arrives. So it pools the busy connection, and the next request is written only after the whole previous response has been read.

The fix

When Receiver::poll_recv takes a request, it withdraws any outstanding want with taker.unwant(), a compare-exchange from Want to Idle. The task signals want again only when it is idle and polls an empty queue.

sequenceDiagram
    participant S as SendRequest
    participant F as want flag
    participant T as connection task
    Note over F: Want (connection idle)
    S->>F: give(): Want → Idle
    S->>T: request queued
    T->>F: want(): Idle → Want (stale)
    T->>T: poll_recv: Ready(request)
    rect rgb(222, 245, 222)
    T->>F: unwant(): Want → Idle (new)
    end
    Note over T: writes the request and reads the response
    S->>F: is_ready()? false until the connection is idle
Loading

When no stale want is set, unwant() finds the flag Idle and does nothing. HTTP/2 is unaffected: its is_ready() only checks whether the connection is closed.

A sender-side reorder (queue the request, then give()) isn't enough. The task's late want() can still land after the give(), and the coop path doesn't depend on the order at all.

Tests

tests/h1_ready_in_flight.rs drives the coop path deterministically, using only the public API. It drains the task's coop budget before polling the connection, so the queue returns Pending with a request in it. Then it checks that is_ready() is false once the request is taken.

  • cargo test --features full --test h1_ready_in_flight: passes. With the unwant() line removed, it fails at the final assert!(!sender.is_ready()), as it does on master.
  • cargo test --features full: 313 passed, 0 failed, 10 ignored.
  • rustfmt --check is clean on the changed files.

The test uses tokio::task::coop::poll_proceed, which is public since tokio 1.47. The tokio dev-dependency is "1", and CI resolves the latest version. I can raise it to "1.47" if you'd prefer.

…request

The Receiver signals want when its queue reports Pending, and
SendRequest::is_ready reports that want. A want signaled after finding the
queue empty can land after the Sender's give() for the request that follows,
and tokio's coop budget can make the queue report Pending with a request
already queued. Either way the request is taken with want set, and the
connection reports ready until the response completes. hyper-util's legacy
pool returns connections on is_ready, so it hands a busy connection to the
next request, which then waits behind the whole response.

Withdraw the want with want::Taker::unwant when a request is taken. Want is
signaled again once the dispatcher is idle.

Requires want with Taker::unwant (seanmonstar/want#6).

Closes hyperium#4207
…eased

The previous commit needs want::Taker::unwant, which no want release has
yet. Patch want to the PR branch so the change builds and CI can run.
Replace this with a want version bump once want is released.

This branch has not been deployed

No deployments
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 client: SendRequest::is_ready() can stay true while a request is in flight

1 participant