HTTP/1 client connection is never closed or pooled when a response completes while the request body is still unsent ((Reading::KeepAlive, Writing::Body) is a terminal state)
@BlackRabbitCoder is already working on this.
Since Sep 14, 2026.
- Dominant language
- Rust
- Stars
- 16.3k
- Forks
- 1.8k
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 14
Description
Version
hyper 1.11.1
Platform
Darwin 6c7e67e8e325 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:18:48 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6020 arm64
Summary
On an HTTP/1 client connection, if the server sends a complete response while the client has not finished writing the request body, the connection lands in (Reading::KeepAlive, Writing::Body) and gets permanently stuck there:
- the response is delivered to the caller successfully (the request "succeeds"),
- but the connection is never closed (no FIN, no RST — it stays
ESTABLISHED), - and it is never returned to the pool (with
hyper_util'slegacy::Client,SendRequest::poll_readynever resolves again).
The connection leaks silently until the peer eventually closes it or the process exits. With a pooled client this manifests as a slow socket/fd leak: each affected request permanently consumes a connection while the caller believes it completed normally.
This is reachable in practice whenever a server responds before draining the request body (e.g. a fast 4xx/413) and the request body is large enough to fill the socket buffers, so the client's write stalls in Writing::Body.
Root cause
For a client (!T::should_read_first()), Dispatcher::is_done() (src/proto/h1/dispatch.rs) can only return true when read_done is set:
let read_done = self.conn.is_read_closed();
if !T::should_read_first() && read_done { true }
else { let write_done = ...; read_done && write_done }
But is_read_closed() is matches!(self.state.reading, Reading::Closed) (src/proto/h1/conn.rs), and a fully-read response leaves reading in Reading::KeepAlive, not Reading::Closed. So in (Reading::KeepAlive, Writing::Body):
is_done()isfalse→poll_innerreturnsPoll::Pending→conn.poll_shutdown()is never reached → no FIN/RST.State::try_keep_alive()has arms only for(KeepAlive, KeepAlive),(Closed, KeepAlive),(KeepAlive, Closed)— nothing for(KeepAlive, Body), so it can't idle-and-pool either.- The dispatch/cancel check (
poll_read_head→Dispatch::poll_ready→poll_canceled) is gated byConn::can_read_head(), which requiresReading::Init; inKeepAliveit's unreachable. And the response callback was already consumed when the response was delivered, so nothing observes the caller dropping the future either.
The read side falls through to poll_read_keep_alive → mid_message_detect_eof and parks. Nothing remaining can drive the connection to shutdown or to idle. It's a terminal park state.
Note this is distinct from #4085 (100% CPU busy-loop on peer FIN with no response) — here the peer sends a full response, there is no CPU spin, and the connection is silently stuck rather than busy-looping.
Steps to reproduce
Cargo.toml:
[dependencies]
hyper = { version = "=1.11.1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
http-body-util = "0.1"
bytes = "1"
tokio = { version = "1", features = ["full"] }
src/main.rs: see the code below...
Running it prints:
sender.ready() within 5s: false
connection task finished within 5s: false
BUG REPRODUCED: connection stuck in (Reading::KeepAlive, Writing::Body)
Code Sample
use std::convert::Infallible;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::BodyExt;
use hyper::body::{Body, Frame, SizeHint};
use hyper::client::conn::http1;
use hyper::Request;
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::timeout;
/// A request body that emits one chunk, then parks forever — modeling a request whose
/// write has not completed (in the real world this happens via TCP backpressure when the
/// peer responds without draining a large body; here it's made deterministic).
struct StallBody {
sent: bool,
}
impl Body for StallBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
mut self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
if !self.sent {
self.sent = true;
Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(b"x")))))
} else {
Poll::Pending
}
}
fn is_end_stream(&self) -> bool {
false
}
fn size_hint(&self) -> SizeHint {
SizeHint::default()
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Server: read the request head, send a full response, then stop reading and hold open.
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
loop {
let n = s.read(&mut buf).await.unwrap();
if n == 0 || buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nhi")
.await
.unwrap();
s.flush().await.unwrap();
tokio::time::sleep(Duration::from_secs(30)).await; // hold; never drain the request body
});
let tcp = TcpStream::connect(addr).await.unwrap();
let (mut sender, conn) = http1::handshake::<_, StallBody>(TokioIo::new(tcp)).await.unwrap();
let conn_done = tokio::spawn(conn); // background connection task
let req = Request::builder()
.method("POST")
.uri("/")
.header("host", "repro")
.body(StallBody { sent: false })
.unwrap();
// Response head + body arrive fine, even though the request body write is stalled.
let resp = sender.send_request(req).await.expect("send_request");
let _ = resp.into_body().collect().await.expect("collect body"); // full response consumed
// The request "succeeded". Now: is the connection reusable, or closed?
let ready = timeout(Duration::from_secs(5), sender.ready()).await;
let done = timeout(Duration::from_secs(5), conn_done).await;
println!("sender.ready() within 5s: {:?}", ready.is_ok()); // false -> never reusable
println!("connection task finished within 5s: {}", done.is_ok()); // false -> never closed
assert!(
ready.is_err() && done.is_err(),
"BUG: response delivered, but the connection is neither reusable nor closed (leaked)"
);
println!("BUG REPRODUCED: connection stuck in (Reading::KeepAlive, Writing::Body)");
}
Expected Behavior
After the response is fully received, the client connection should reach a terminal outcome: either shut down (send FIN/RST) if the request body can't be completed, or become reusable. It should not remain ESTABLISHED and non-reusable indefinitely.
Actual Behavior
In actuality:
sender.ready()never resolves,- the
Connectiontask never completes (no shutdown, socket staysESTABLISHED), - the request appears to have succeeded to the caller.
The same leak occurs through hyper_util::client::legacy::Client: the on_idle task (poll_fn(|cx| pooled.poll_ready(cx))) never resolves, so the connection is never inserted into the idle pool — a subsequent request dials a new socket and the wedged one leaks.
Additional Context
Proposed fix
Once a full response has been received, an HTTP/1 connection whose request body was not fully sent is not keep-alive-eligible (HTTP/1 requires the request to be fully sent before the connection can carry another request, and the peer has already responded and stopped reading). Per RFC 7230 §6.5–§6.6 a client in this situation may close the connection. So the correct action in (Reading::KeepAlive, Writing::Body) is to stop preserving the connection and shut it down rather than park.
Concretely, add an arm to State::try_keep_alive() in src/proto/h1/conn.rs:
// response fully read, but the request body never finished sending:
// the connection cannot be reused, and continuing to write is pointless
// (the peer already responded). Close instead of parking forever.
(&Reading::KeepAlive, &Writing::Body(_)) => {
self.close();
}
self.close() sets both sides to Closed → is_done() becomes true → poll_shutdown() runs (FIN, or RST if there is undeliverable data). Via the pooled legacy::Client, SendRequest::poll_ready then resolves (error), so the connection is dropped rather than leaked. A regression test belongs next to client_flushing_is_not_ready_for_next_request in src/proto/h1/dispatch.rs: drive a client to (KeepAlive, Body) and assert the dispatcher reaches shutdown instead of returning Pending.
Caveat / open question for maintainers
This is an abort-vs-drain decision. The arm above aborts the in-flight request upload. That is almost always the right choice (the request already received its response), but alternatives exist:
- attempt a cheap drain of the remaining request body first (mirroring the existing read-side
poll_drain_or_close_readpattern) and only close if it can't complete, or - only close when the caller has dropped the response body / future, keeping the current behavior otherwise.
Happy to adjust the patch to whichever behavior you prefer before opening a PR.
Real-world trigger (for context)
No misbehaving body is needed. With a normal buffered request body, the same (KeepAlive, Body) state is reached when the peer sends a complete response and stops reading, and the request body is larger than the socket send buffer + peer's receive window — hyper's write stalls in Writing::Body. (A body that fits in the buffers flushes fully, reaches Writing::KeepAlive, and the connection idles normally, so it's specifically the un-flushable case.)
The attached repro uses a request body that stops yielding, which reaches the identical (KeepAlive, Body) state deterministically without depending on OS socket-buffer sizing.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.