-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
Version
hyper 1.8.1, h2 0.4.1
Platform
macOS 15 (Darwin 24.6.0), ARM64
Summary
When hyper's HTTP/2 client calls h2::send_request, it receives a ResponseFuture and a SendStream. It then spawns two independent tasks:
send_task(SendWhen) — holds theResponseFutureand the client callback.pipe_task(PipeMap) — holds theSendStreamand pumps the request body into it.
When the client drops the response future (e.g. due to a timeout), send_task detects the cancellation via poll_canceled and completes. This drops the ResponseFuture, but it does not signal pipe_task in any way. Since pipe_task still holds the SendStream, the h2 crate considers the stream alive and does not send RST_STREAM.
Code Sample
// A minimal test using a raw h2 server with `initial_window_size = 0` (so no body data can flow) and a hyper client that times out:
// 1. Server sets `initial_window_size = 0` — no body DATA can flow.
// 2. Client POSTs a 50‑byte body. `pipe_task` blocks on `poll_capacity`.
// 3. Client times out at 200 ms and drops the response future.
// 4. `send_task` detects cancellation and drops `ResponseFuture`.
// 5. `pipe_task` is **not** cancelled — `SendStream` stays alive.
// 6. Server waits 2 s for RST_STREAM — **nothing arrives**.
// Inside tests/client.rs, mod conn { ... }
#[tokio::test]
async fn h2_pipe_task_not_cancelled_on_response_future_drop() {
let (client_io, server_io, _) = setup_duplex_test_server();
let (rst_tx, rst_rx) = oneshot::channel::();
// --- server (raw h2) with zero stream window ---
tokio::spawn(async move {
let mut builder = h2::server::Builder::new();
builder.initial_window_size(0); // no body DATA can flow
let mut h2 = builder
.handshake::(server_io)
.await
.unwrap();
let (req, _respond) = h2.accept().await.unwrap().unwrap();
tokio::spawn(async move {
let _ = poll_fn(|cx| h2.poll_closed(cx)).await;
});
let mut body = req.into_body();
// If pipe_task were cancelled, RST_STREAM would arrive here.
// With the bug, nothing arrives and we hit the timeout.
let got_rst = tokio::time::timeout(Duration::from_secs(2), body.data())
.await
.map_or(false, |frame| matches!(frame, Some(Err(_)) | None));
let _ = rst_tx.send(got_rst);
});
// --- client (hyper) ---
let io = TokioIo::new(client_io);
let (mut client, conn) = conn::http2::Builder::new(TokioExecutor)
.handshake(io)
.await
.unwrap();
tokio::spawn(async move { let _ = conn.await; });
let req = Request::post("http://localhost/")
.body(Full::new(Bytes::from(vec![b'x'; 50])))
.unwrap();
// Client times out → drops the response future
let res =
tokio::time::timeout(Duration::from_millis(200), client.send_request(req)).await;
assert!(res.is_err(), "should timeout");
// Give time for RST_STREAM to propagate (if it were sent)
tokio::time::sleep(Duration::from_secs(1)).await;
let got_rst = rst_rx.await.unwrap();
// BUG: server never receives RST_STREAM
assert!(!got_rst, "pipe_task was not cancelled");
}Expected Behavior
Dropping the response future should cancel the request entirely: pipe_task should call send_stream.send_reset(Reason::CANCEL), causing h2 to drain queued data, release flow‑control capacity, and send RST_STREAM to the server.
Actual Behavior
pipe_task continues to run independently. No RST_STREAM is sent. The SendStream stays open and continues to hold (or wait for) flow‑control window capacity.
Additional Context
Impact:
On a multiplexed HTTP/2 connection, a large request whose body is stuck waiting for connection‑level flow‑control window will cause the client to time out. But because the pipe_task is never cancelled:
The timed‑out stream's SendStream keeps requesting/holding window capacity.
This adds back‑pressure to other streams sharing the same connection.
The server never learns the request was cancelled.