HTTP/1 client: `SendRequest::is_ready()` can stay true while a request is in flight
Los mantenedores suelen responder en 2 días
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 35/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Estancado
- Stack tecnológico
- rust
- Área
- networking
Línea de trabajo
Start with tests/h1_ready_in_flight.rs and run its cargo test command to reproduce the stale readiness signal. Trace Receiver::poll_recv and the want state, then compare the behavior with hyper-util's client/legacy/client.rs pool check; done means the test passes and an in-flight HTTP/1 request reports not ready. Draft fix work is referenced in #4208 and want#6.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Version
hyper 1.10.1, 1.11.1 and master (e60932d); want 0.3.1; hyper-util 0.1.20 and 0.1.21
Platform
Linux 6.16 x86_64 (not platform-specific)
Summary
On an HTTP/1 client connection, SendRequest::is_ready() can keep returning true after the connection has taken a request, until that request's response is complete. hyper-util's legacy pool trusts is_ready() when a response head arrives, so it pools a connection that is still streaming a response body. The next request to check out that connection is written only after the whole previous response has been read.
This isn't HTTP/1.1 pipelining. It happens with default settings, and no option turns it off.
How readiness works
SendRequest and the connection task share a want flag. The connection task signals want when its request queue is empty, send_request clears it (Giver::give) when it queues a request, and is_ready() reports it.
sequenceDiagram
participant S as SendRequest
participant F as want flag
participant T as connection task
T->>T: poll_recv: queue empty, Pending
T->>F: want(): Idle → Want
S->>F: is_ready()? true
S->>F: send_request: give(), Want → Idle
S->>T: request queued
T->>T: poll_recv: Ready(request)
Note over T: writes the request and reads the response.<br/>It won't poll the queue again until the connection is idle.
S->>F: is_ready()? false
How the flag goes stale
The want can be set again after give() cleared it, with the request already queued. There are two ways.
- A race. The connection task finds the queue empty, but before it calls
taker.want(),send_requeston another thread runsgive()and queues a request. The task'swant()lands after thegive().
sequenceDiagram
participant S as SendRequest (thread A)
participant F as want flag
participant T as connection task (thread B)
Note over F: Want (connection idle)
T->>T: poll_recv: queue empty
rect rgb(255, 228, 228)
S->>F: give(): Want → Idle
S->>T: request queued
T->>F: want(): Idle → Want (stale)
end
T->>T: poll_recv: Ready(request), flag stays Want
- tokio's coop budget. Once the task's budget is used up,
mpsc::UnboundedReceiver::poll_recvreturnsPendingeven though a request is queued, so the task signals want anyway.
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->>T: poll_recv: budget used up, Pending
T->>F: want(): Idle → Want (stale)
end
T->>T: next poll: Ready(request), flag stays Want
Either way the dispatcher takes the request with want still set. While the request is in flight the dispatcher doesn't poll the queue, so nothing clears the flag, and is_ready() stays true until the response completes.
What that does to a pool
hyper-util's legacy client returns a connection to the pool when the response head arrives, if is_ready() is true (client/legacy/client.rs: if pooled.is_http2() || !pooled.is_pool_enabled() || pooled.is_ready()).
sequenceDiagram
participant A as request A (long stream)
participant P as hyper-util pool
participant C as connection
participant B as request B (short)
A->>C: sent, response head arrives
P->>C: is_ready()? true (stale)
P->>P: pools C while A's body is still streaming
B->>P: checkout
P->>B: C
B->>C: queued behind A
Note over B,C: B is written only after A's whole body has been read
We hit this in a reverse proxy that sends short GET probes and long streaming POSTs through one reqwest client. Probes regularly waited behind streams for hundreds of milliseconds to seconds. With the fix below, requests that waited behind a stream went from hundreds per 30-second run to zero in a standalone repro, and the proxy's tail latency dropped in production.
Code Sample
This test drives path 2 deterministically, using only the public API. On master it fails at the final assert!(!sender.is_ready()). Run it with cargo test --features full --test h1_ready_in_flight.
tests/h1_ready_in_flight.rs
// Test: an HTTP/1 client connection must not report ready while a request is
// in flight on it.
//
// The dispatch `Receiver` signals want when its queue reports `Pending`, and
// `SendRequest::is_ready` reports that want. The signal can go stale: the
// connection task can find its queue empty and a request land before it
// signals, or tokio's coop budget can make the queue report `Pending` with a
// request already in it. The request is then taken with want still set, so
// `is_ready` stays true until the response is complete. Pools that return a
// connection on `is_ready`, such as hyper-util's legacy client, then give that
// connection to the next request, which waits behind the whole response.
//
// The coop path is deterministic, so the test drives that one.
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures_util::future::poll_fn;
use http_body_util::Empty;
use hyper::client::conn::http1::{self, Connection};
use hyper::rt::{Read, ReadBufCursor, Write};
use hyper::Request;
/// Accepts every write and never answers, so a sent request stays in flight.
struct SilentIo;
impl Read for SilentIo {
fn poll_read(
self: Pin<&mut Self>,
_: &mut Context<'_>,
_: ReadBufCursor<'_>,
) -> Poll<io::Result<()>> {
Poll::Pending
}
}
impl Write for SilentIo {
fn poll_write(
self: Pin<&mut Self>,
_: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
/// Runs the connection task once; it never finishes, as the peer never answers.
fn poll_conn(conn: &mut Connection<SilentIo, Empty<Bytes>>, cx: &mut Context<'_>) -> Poll<()> {
assert!(Pin::new(conn).poll(cx).is_pending());
Poll::Ready(())
}
#[tokio::test]
async fn h1_connection_is_not_ready_while_a_request_is_in_flight() {
let (mut sender, mut conn) = http1::handshake(SilentIo).await.unwrap();
// Idle, the connection finds its queue empty and signals it is ready.
poll_fn(|cx| poll_conn(&mut conn, cx)).await;
assert!(sender.is_ready());
// Kept alive: dropping the response future cancels the request.
let _response = sender.send_request(Request::new(Empty::new()));
assert!(!sender.is_ready());
// Out of coop budget, the queue reports Pending with the request in it, so
// the connection signals ready again. A connection task preempted between
// finding its queue empty and signaling leaves the same stale signal.
poll_fn(|cx| {
while let Poll::Ready(restore) = tokio::task::coop::poll_proceed(cx) {
restore.made_progress();
}
poll_conn(&mut conn, cx)
})
.await;
tokio::task::yield_now().await;
// With a fresh budget it takes and writes the request, which then waits
// for a response that never comes.
poll_fn(|cx| poll_conn(&mut conn, cx)).await;
assert!(
!sender.is_ready(),
"connection reports ready with a request in flight"
);
}
Expected Behavior
Once the connection takes a request, is_ready() returns false until the connection is idle and can take another request.
Actual Behavior
is_ready() returns true while the request is in flight. hyper-util's pool then hands the busy connection to the next request, which waits for the whole previous response.
Additional Context
Proposed fix
When Receiver::poll_recv takes a request, withdraw any outstanding want with a new want::Taker::unwant(): a compare-exchange from Want to Idle, the taker-side counterpart of Giver::give. The connection task signals want again only when it is idle.
flowchart LR
R["Receiver::poll_recv"] -->|Pending| W["taker.want()<br/>signal ready"]
R -->|"Ready(request)"| U["taker.unwant() (new)<br/>withdraw a stale want"]
U --> D["dispatcher sends the request"]
pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll<Option<(T, Callback<T, U>)>> {
match self.inner.poll_recv(cx) {
Poll::Ready(item) => {
+ // A want signaled after finding the queue empty can land after
+ // the Sender's `give()` for the message taken here, as can one
+ // signaled on a coop-budget Pending with a message queued.
+ // Withdraw it, or the Sender reports this connection as ready
+ // while it is still serving this message.
+ self.taker.unwant();
Poll::Ready(item.map(|mut env| env.0.take().expect("envelope not dropped")))
}
- want: seanmonstar/want#6 adds
Taker::unwant. - hyper: #4208 calls it in
Receiver::poll_recvand adds the test above. It's a draft, building against the want PR branch, until want has a release.
With both changes the test passes, and cargo test --features full passes on master. HTTP/2 is unaffected: its is_ready() only checks whether the connection is closed.
A sender-side reorder (queue the request first, then give()) isn't enough. It leaves both paths open, and in our repro a few requests per run still waited behind a stream.
- Lenguaje dominante
- Rust
- Estrellas
- 16.3k
- Forks
- 1.8k
- Merge medio
- 4 d 7 h
- PR fusionados (30 d)
- 10
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de hyperium/hyper
-
Publicly reexport the http crateAbiertoC-feature
Dificultad 1/5 Menos de una hora Aptitud para principiantes 65/100
hyperium/hyper#2652 · 4 reacciones ·
Los mantenedores suelen responder en 2 días
-
Dificultad 3/5 1-2 días Aptitud para principiantes 74/100
Los mantenedores suelen responder en 2 días
-
Upgraded HTTP/2 CONNECT streams cannot be reset, so a failed tunnel looks like a clean closeAbierto
Dificultad 4/5 3-5 días Aptitud para principiantes 55/100
Los mantenedores suelen responder en 2 días
-
Dificultad 4/5 3-5 días Aptitud para principiantes 62/100
Los mantenedores suelen responder en 2 días
-
C-feature
Dificultad 5/5 Más de una semana Aptitud para principiantes 35/100
hyperium/hyper#4186 · 1 comentario ·
Los mantenedores suelen responder en 2 días
Todos los issues de hyperium/hyper
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
trezor/trezor-firmware#7997 ·
Los mantenedores suelen responder en 2 días
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 88/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
oxidecomputer/management-gateway-service#506 · 1 comentario ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
scylladb/nodejs-rs-driver#566 ·
Los mantenedores suelen responder en 1 día
-
A-ABI needs-triage relnotes relnotes-needs-review relnotes-tracking-issue T-lang T-libs T-opsem
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día