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 the ResponseFuture and the client callback.
pipe_task (PipeMap) — holds the SendStream and 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.
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 aResponseFutureand aSendStream. 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_taskdetects the cancellation viapoll_canceledand completes. This drops theResponseFuture, but it does not signalpipe_taskin any way. Sincepipe_taskstill holds theSendStream, the h2 crate considers the stream alive and does not sendRST_STREAM.Code Sample
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.