Describe the bug
Pingora's HTTP/2 client response-header path can lose the only h2 ResponseFuture for an in-flight stream when Http2Session::read_response_header() is cancelled or times out before the upstream response header is ready.
The vulnerable state transition is:
write_request_header() stores h2::client::ResponseFuture in self.resp_fut
-> read_response_header() removes it with self.resp_fut.take()
-> the method awaits the taken future, optionally under read_timeout
-> timeout/select/task cancellation drops read_response_header() while the ResponseFuture is still pending
-> the taken ResponseFuture is dropped
-> Http2Session survives with resp_fut = None and response_header = None
-> retrying read_response_header() panics instead of resuming or returning a structured stream error
This is a cancellation state-machine issue in the lower-level H2 client session API. The response header has not been installed, but the only continuation capable of reading it has been moved out of the surviving session object and can be destroyed by ordinary Rust async cancellation.
The bug is not that every read timeout must keep using the stream. A product may choose to abort an H2 stream after a response-header timeout. The issue is that the current API can return a timeout while leaving a live Http2Session in an internally unrecoverable state: the request was sent, no response header exists, no response body reader exists, and retrying the same header read hits a panic path.
Root cause
The root cause is visible in pingora-core/src/protocols/http/v2/client.rs. Source links below are pinned to commit e6e677fe9b58555140ab7bd14feff035392b3530.
- Sending an H2 request creates the response future and stores it as the only resumable header-read continuation:
pingora-core/src/protocols/http/v2/client.rs#L121-L129
let (resp_fut, send_body) = self
.send_req
.send_request(request, end)
.or_err(H2Error, "while sending request")
.map_err(|e| self.handle_err(e))?;
self.req_sent = Some(req);
self.send_body = Some(send_body);
self.resp_fut = Some(resp_fut);
self.ended = self.ended || end;
read_response_header() then destructively removes that future from the session before it is known to be ready:
pingora-core/src/protocols/http/v2/client.rs#L174-L184
pub async fn read_response_header(&mut self) -> Result<()> {
// TODO: how to read 1xx headers?
// https://github.com/hyperium/h2/issues/167
if self.response_header.is_some() {
panic!("H2 response header is already read")
}
let Some(resp_fut) = self.resp_fut.take() else {
panic!("Try to take response header, but it is already taken")
};
- The cancellation point is the subsequent await. With
read_timeout set, Pingora wraps the taken future in a timeout; when that timeout elapses, the inner resp_fut is dropped:
pingora-core/src/protocols/http/v2/client.rs#L186-L192
let res = match self.read_timeout {
Some(t) => timeout(t, resp_fut)
.await
.map_err(|_| Error::explain(ReadTimedout, "while reading h2 response header"))
.map_err(|e| self.handle_err(e))?,
None => resp_fut.await,
};
External cancellation has the same effect. For example, if a caller uses tokio::select!, an external timeout, task abort, parent future drop, or runtime shutdown while this async method is suspended, the generated read_response_header() future is dropped together with the taken ResponseFuture.
- The response state is installed only after the awaited future completes successfully:
pingora-core/src/protocols/http/v2/client.rs#L193-L195
let (resp, body_reader) = res.map_err(handle_read_header_error)?.into_parts();
self.response_header = Some(resp.into());
self.response_body_reader = Some(body_reader);
So cancellation can leave exactly this state:
req_sent = Some(...)
send_body = Some(...)
resp_fut = None
response_header = None
response_body_reader = None
That state violates the expected session invariant: if the response header has not been read yet, the session must still retain the future needed to read it, or it must be explicitly marked failed/aborted.
- The same file contains a safer local contrast.
poll_read_response_header() also takes resp_fut, but if the future is not ready it restores it into the session before returning Poll::Pending:
pingora-core/src/protocols/http/v2/client.rs#L201-L227
pub fn poll_read_response_header(
&mut self,
cx: &mut Context<'_>,
) -> Poll<Result<(), h2::Error>> {
if self.response_header.is_some() {
panic!("H2 response header is already read")
}
let Some(mut resp_fut) = self.resp_fut.take() else {
panic!("Try to take response header, but it is already taken")
};
let res = match resp_fut.poll_unpin(cx) {
Poll::Ready(Ok(res)) => res,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Pending => {
self.resp_fut = Some(resp_fut);
return Poll::Pending;
}
};
let (resp, body_reader) = res.into_parts();
self.response_header = Some(resp.into());
self.response_body_reader = Some(body_reader);
Poll::Ready(Ok(()))
}
This contrast is the key state-machine evidence: H2 response-header progress can be kept resumable by restoring the response future on pending. The async wrapper currently stores that resumable progress only inside a cancellable method future.
- The generic client API exposes this path through
HttpSession::H2:
pingora-core/src/protocols/http/client.rs#L103-L135
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) {
match self {
HttpSession::H1(h1) => h1.read_timeout = timeout,
HttpSession::H2(h2) => h2.read_timeout = timeout,
HttpSession::Custom(c) => c.set_read_timeout(timeout),
}
}
pub async fn read_response_header(&mut self) -> Result<()> {
match self {
HttpSession::H1(h1) => {
h1.read_response().await?;
Ok(())
}
HttpSession::H2(h2) => h2.read_response_header().await,
HttpSession::Custom(c) => c.read_response_header().await,
}
}
One in-tree user, HTTP health checks, sets a read timeout and then awaits the generic response-header API:
pingora-load-balancing/src/health_check.rs#L301-L303
session.set_read_timeout(peer.options.read_timeout);
session.read_response_header().await?;
The H2 proxy path also sets the H2 client read timeout from peer options:
pingora-proxy/src/proxy_h2.rs#L172
client_session.read_timeout = peer.options.read_timeout;
There is a proxy-specific mitigation for one application-level upstream ReadTimedout path: Pingora sends RST_STREAM CANCEL on the request body stream and marks the H2 connection for shutdown.
pingora-proxy/src/proxy_h2.rs#L220-L229
if e.esource == ErrorSource::Upstream && matches!(e.etype, ReadTimedout) {
client_body.send_reset(h2::Reason::CANCEL);
// Mark the underlying H2 connection for shutdown so it's not used
// for new streams in case it is hung.
client_session.conn.mark_shutdown();
}
That lowers the risk for this specific proxy timeout path, but it does not repair Http2Session::read_response_header() itself. Direct H2 users, generic HttpSession users, caller-side select! or timeout cancellation, task abort, and shutdown cancellation can still leave the lower-level session in the panic-on-retry state.
Pingora info
Please include the following information about your environment:
Pingora version: audited and reproduced against commit e6e677fe9b58555140ab7bd14feff035392b3530; the targeted unit-test log reports pingora-core v0.8.0
Rust version: not captured in the shared reproduction log
Operating system version: reproduced as a Rust unit test on Linux x86 (server36)
Steps to reproduce
The following is a focused whitebox reproduction. It intentionally asserts the current bad behavior, so the test passing means the cancellation window is reachable in the current implementation. After a fix, the assertion should be inverted into a regression property such as: a timed-out or cancelled response-header read must not leave resp_fut = None while response_header = None; retrying should not panic.
Steps:
- Apply the test-only code below inside the existing
#[cfg(test)] mod tests_h2 in pingora-core/src/protocols/http/v2/client.rs.
- Run the targeted test command shown below.
- Observe that the first header read returns
ReadTimedout, and the retry panics because the only ResponseFuture was lost.
Full whitebox test code for pingora-core/src/protocols/http/v2/client.rs
#[cfg(test)]
mod tests_h2 {
use super::*;
use bytes::Bytes;
use http::{Response, StatusCode};
use std::panic::AssertUnwindSafe;
use tokio::io::duplex;
use tokio::sync::oneshot;
#[tokio::test(start_paused = true)]
async fn cancelled_response_header_read_loses_response_future() {
let (client_io, server_io) = duplex(65536);
let (request_accepted_tx, request_accepted_rx) = oneshot::channel();
let (release_response_tx, release_response_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
let mut conn = h2::server::handshake(server_io).await.unwrap();
if let Some(result) = conn.accept().await {
let (req, mut send_resp) = result.unwrap();
assert_eq!(req.method(), http::Method::GET);
let _ = request_accepted_tx.send(());
let _ = release_response_rx.await;
let resp = Response::builder().status(StatusCode::OK).body(()).unwrap();
let _ = send_resp.send_response(resp, true);
conn.graceful_shutdown();
}
});
let (send_req, connection) = h2::client::handshake(client_io).await.unwrap();
let (closed_tx, closed_rx) = tokio::sync::watch::channel(false);
let ping_timeout = Arc::new(AtomicBool::new(false));
let connection_task = tokio::spawn(async move {
let _ = connection.await;
let _ = closed_tx.send(true);
});
let digest = Digest::default();
let conn_ref = crate::connectors::http::v2::ConnectionRef::new(
send_req.clone(),
closed_rx,
ping_timeout,
0,
1,
digest,
);
let mut h2s = Http2Session::new(send_req, conn_ref);
let mut req = RequestHeader::build("GET", b"/", None).unwrap();
req.insert_header(http::header::HOST, "example.com")
.unwrap();
h2s.write_request_header(Box::new(req), true).unwrap();
request_accepted_rx
.await
.expect("server should accept the request before the read timeout");
h2s.read_timeout = Some(Duration::from_millis(1));
let err = h2s
.read_response_header()
.await
.expect_err("delayed response header should hit the read timeout");
assert!(
matches!(err.etype, ReadTimedout),
"unexpected first read error: {err:?}"
);
assert!(h2s.response_header().is_none());
let retry = AssertUnwindSafe(h2s.read_response_header())
.catch_unwind()
.await;
let panic_payload =
retry.expect_err("retry should panic because the response future was lost");
let panic_message = if let Some(message) = panic_payload.downcast_ref::<&str>() {
*message
} else if let Some(message) = panic_payload.downcast_ref::<String>() {
message.as_str()
} else {
""
};
assert!(
panic_message.contains("Try to take response header, but it is already taken"),
"unexpected panic message: {panic_message:?}"
);
let _ = release_response_tx.send(());
drop(h2s);
server_task.abort();
connection_task.abort();
}
}
If the existing tests_h2 module is already present, only the additional imports and the cancelled_response_header_read_loses_response_future test function are needed. The Bytes, Response, StatusCode, duplex, and oneshot imports are already used by the neighboring H2 tests in the current file; the reproduction additionally needs std::panic::AssertUnwindSafe for the caught retry panic.
Targeted command:
cargo test -p pingora-core --lib cancelled_response_header_read_loses_response_future -- --nocapture
Observed result:
Compiling pingora-core v0.8.0 (/home/jcj/whitebox/pingora/pingora-core)
Finished `test` profile [unoptimized + debuginfo] target(s) in 6.40s
Running unittests src/lib.rs (target/debug/deps/pingora_core-4ef134c88541a33d)
running 1 test
thread 'protocols::http::v2::client::tests_h2::cancelled_response_header_read_loses_response_future' (2954060) panicked at pingora-core/src/protocols/http/v2/client.rs:183:13:
Try to take response header, but it is already taken
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test protocols::http::v2::client::tests_h2::cancelled_response_header_read_loses_response_future ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 485 filtered out; finished in 0.01s
Why this reproduces the issue:
- The test creates a real in-memory h2 client/server pair over
tokio::io::duplex.
- The server accepts the request but waits on a gate before sending the response header, ensuring the client's
ResponseFuture remains pending.
- The client builds a real
Http2Session, sends a GET request, and sets h2s.read_timeout = Some(Duration::from_millis(1)).
- The first
read_response_header().await reaches the timeout path and returns a structured ReadTimedout error.
- At that timeout boundary, the taken
ResponseFuture has been dropped by the timeout wrapper, while h2s.response_header() is still None.
- The test then retries
h2s.read_response_header() on the same live session and catches the resulting panic.
- The panic message is the
self.resp_fut.take() None branch: Try to take response header, but it is already taken.
This is a bug-existence validation test. It passes on the current implementation because it reaches and catches the bad retry panic. A post-fix regression test should instead assert that retrying does not panic and that the session either resumes the header read, returns a structured timeout/error, or has been explicitly transitioned to an aborted/failed state before the first timeout returns.
Expected results
A cancelled or timed-out H2 response-header read should not destroy the only continuation needed to read the response header while leaving the Http2Session object live and apparently usable.
Acceptable outcomes include:
- preserve the same
ResponseFuture in self.resp_fut whenever the header-read future yields or is cancelled before completion;
- return
ReadTimedout while keeping the session in a resumable ResponsePending state;
- or, if timeout is intended to consume/abort the stream, explicitly transition the stream/session to a failed or aborted state and make later calls return a structured error rather than panicking.
The expected invariant is:
if response_header == None:
either resp_fut == Some(ResponseFuture)
or the stream/session is explicitly Failed/Aborted/Closed
Retrying after a timeout should never observe:
resp_fut == None
response_header == None
retry behavior == panic
Observed results
The current implementation can produce this state after read_response_header() times out or is cancelled while the upstream H2 response header is still pending:
request sent: yes
response future retained: no
response header installed: no
response body reader installed: no
stream/session explicitly failed: no
retry behavior: panic at "Try to take response header, but it is already taken"
The immediate observable failure is a panic on retry. The broader state-machine problem is that the response-header continuation has been dropped without either completing the response state or recording an explicit terminal stream state.
This can affect direct users of Http2Session and generic HttpSession::H2 users that treat a timeout or cancellation as a recoverable event, or that rely on caller-side cancellation such as select!, external timeout, task abort, parent future drop, or runtime shutdown. The proxy H2 path has a partial application-level mitigation for one upstream ReadTimedout branch, but that caller-specific cleanup does not make the lower-level H2 response-header API cancellation-safe.
Additional context
The cancellation-safety invariant I expected is:
An H2 client session must not remove its only pending response-header future
from durable session state across a cancellable await unless it also commits
the response state or records an explicit unrecoverable stream state.
A robust fix would likely mirror the existing poll_read_response_header() restore-on-pending pattern in the async API:
- take
resp_fut only for the duration of one poll;
- if polling returns
Poll::Pending, restore self.resp_fut = Some(resp_fut) before yielding;
- if polling returns
Poll::Ready(Ok(_)), install response_header and response_body_reader;
- if polling returns
Poll::Ready(Err(_)), return the structured error or transition the session to an explicit failed state;
- for timeout handling, avoid wrapping an owned
ResponseFuture that has already been removed from the session, or explicitly abort/mark the stream before returning ReadTimedout.
One possible state-machine shape is:
ResponsePending(resp_fut)
-> poll resp_fut
-> Pending: store ResponsePending(resp_fut), yield Pending
-> Ready(Ok(resp)): store ResponseReady(header, body_reader)
-> timeout/cancel before ready: keep ResponsePending(resp_fut) or store Failed/Aborted
The important repair property is that the response future and the response state should have a single durable owner at every suspension boundary. A cancellable method future should not be the only owner of protocol progress after the public session object has been left alive.
Describe the bug
Pingora's HTTP/2 client response-header path can lose the only h2
ResponseFuturefor an in-flight stream whenHttp2Session::read_response_header()is cancelled or times out before the upstream response header is ready.The vulnerable state transition is:
This is a cancellation state-machine issue in the lower-level H2 client session API. The response header has not been installed, but the only continuation capable of reading it has been moved out of the surviving session object and can be destroyed by ordinary Rust async cancellation.
The bug is not that every read timeout must keep using the stream. A product may choose to abort an H2 stream after a response-header timeout. The issue is that the current API can return a timeout while leaving a live
Http2Sessionin an internally unrecoverable state: the request was sent, no response header exists, no response body reader exists, and retrying the same header read hits a panic path.Root cause
The root cause is visible in
pingora-core/src/protocols/http/v2/client.rs. Source links below are pinned to commite6e677fe9b58555140ab7bd14feff035392b3530.pingora-core/src/protocols/http/v2/client.rs#L121-L129read_response_header()then destructively removes that future from the session before it is known to be ready:pingora-core/src/protocols/http/v2/client.rs#L174-L184read_timeoutset, Pingora wraps the taken future in a timeout; when that timeout elapses, the innerresp_futis dropped:pingora-core/src/protocols/http/v2/client.rs#L186-L192External cancellation has the same effect. For example, if a caller uses
tokio::select!, an external timeout, task abort, parent future drop, or runtime shutdown while this async method is suspended, the generatedread_response_header()future is dropped together with the takenResponseFuture.pingora-core/src/protocols/http/v2/client.rs#L193-L195So cancellation can leave exactly this state:
That state violates the expected session invariant: if the response header has not been read yet, the session must still retain the future needed to read it, or it must be explicitly marked failed/aborted.
poll_read_response_header()also takesresp_fut, but if the future is not ready it restores it into the session before returningPoll::Pending:pingora-core/src/protocols/http/v2/client.rs#L201-L227This contrast is the key state-machine evidence: H2 response-header progress can be kept resumable by restoring the response future on pending. The async wrapper currently stores that resumable progress only inside a cancellable method future.
HttpSession::H2:pingora-core/src/protocols/http/client.rs#L103-L135One in-tree user, HTTP health checks, sets a read timeout and then awaits the generic response-header API:
pingora-load-balancing/src/health_check.rs#L301-L303The H2 proxy path also sets the H2 client read timeout from peer options:
pingora-proxy/src/proxy_h2.rs#L172There is a proxy-specific mitigation for one application-level upstream
ReadTimedoutpath: Pingora sendsRST_STREAM CANCELon the request body stream and marks the H2 connection for shutdown.pingora-proxy/src/proxy_h2.rs#L220-L229That lowers the risk for this specific proxy timeout path, but it does not repair
Http2Session::read_response_header()itself. Direct H2 users, genericHttpSessionusers, caller-sideselect!or timeout cancellation, task abort, and shutdown cancellation can still leave the lower-level session in the panic-on-retry state.Pingora info
Please include the following information about your environment:
Pingora version: audited and reproduced against commit
e6e677fe9b58555140ab7bd14feff035392b3530; the targeted unit-test log reportspingora-core v0.8.0Rust version: not captured in the shared reproduction log
Operating system version: reproduced as a Rust unit test on Linux x86 (
server36)Steps to reproduce
The following is a focused whitebox reproduction. It intentionally asserts the current bad behavior, so the test passing means the cancellation window is reachable in the current implementation. After a fix, the assertion should be inverted into a regression property such as: a timed-out or cancelled response-header read must not leave
resp_fut = Nonewhileresponse_header = None; retrying should not panic.Steps:
#[cfg(test)] mod tests_h2inpingora-core/src/protocols/http/v2/client.rs.ReadTimedout, and the retry panics because the onlyResponseFuturewas lost.Full whitebox test code for
pingora-core/src/protocols/http/v2/client.rsIf the existing
tests_h2module is already present, only the additional imports and thecancelled_response_header_read_loses_response_futuretest function are needed. TheBytes,Response,StatusCode,duplex, andoneshotimports are already used by the neighboring H2 tests in the current file; the reproduction additionally needsstd::panic::AssertUnwindSafefor the caught retry panic.Targeted command:
cargo test -p pingora-core --lib cancelled_response_header_read_loses_response_future -- --nocaptureObserved result:
Why this reproduces the issue:
tokio::io::duplex.ResponseFutureremains pending.Http2Session, sends a GET request, and setsh2s.read_timeout = Some(Duration::from_millis(1)).read_response_header().awaitreaches the timeout path and returns a structuredReadTimedouterror.ResponseFuturehas been dropped by the timeout wrapper, whileh2s.response_header()is stillNone.h2s.read_response_header()on the same live session and catches the resulting panic.self.resp_fut.take()Nonebranch:Try to take response header, but it is already taken.This is a bug-existence validation test. It passes on the current implementation because it reaches and catches the bad retry panic. A post-fix regression test should instead assert that retrying does not panic and that the session either resumes the header read, returns a structured timeout/error, or has been explicitly transitioned to an aborted/failed state before the first timeout returns.
Expected results
A cancelled or timed-out H2 response-header read should not destroy the only continuation needed to read the response header while leaving the
Http2Sessionobject live and apparently usable.Acceptable outcomes include:
ResponseFutureinself.resp_futwhenever the header-read future yields or is cancelled before completion;ReadTimedoutwhile keeping the session in a resumableResponsePendingstate;The expected invariant is:
Retrying after a timeout should never observe:
Observed results
The current implementation can produce this state after
read_response_header()times out or is cancelled while the upstream H2 response header is still pending:The immediate observable failure is a panic on retry. The broader state-machine problem is that the response-header continuation has been dropped without either completing the response state or recording an explicit terminal stream state.
This can affect direct users of
Http2Sessionand genericHttpSession::H2users that treat a timeout or cancellation as a recoverable event, or that rely on caller-side cancellation such asselect!, external timeout, task abort, parent future drop, or runtime shutdown. The proxy H2 path has a partial application-level mitigation for one upstreamReadTimedoutbranch, but that caller-specific cleanup does not make the lower-level H2 response-header API cancellation-safe.Additional context
The cancellation-safety invariant I expected is:
A robust fix would likely mirror the existing
poll_read_response_header()restore-on-pending pattern in the async API:resp_futonly for the duration of one poll;Poll::Pending, restoreself.resp_fut = Some(resp_fut)before yielding;Poll::Ready(Ok(_)), installresponse_headerandresponse_body_reader;Poll::Ready(Err(_)), return the structured error or transition the session to an explicit failed state;ResponseFuturethat has already been removed from the session, or explicitly abort/mark the stream before returningReadTimedout.One possible state-machine shape is:
The important repair property is that the response future and the response state should have a single durable owner at every suspension boundary. A cancellable method future should not be the only owner of protocol progress after the public session object has been left alive.