Repository navigation
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) #4176
Description
Activity
- addedC-bugCategory: bug. Something is wrong. This is bad!Category: bug. Something is wrong. This is bad!
on Aug 28, 2026 Yea interesting. So, I'm not sure this is a bug in hyper, but probably more 'working as intended'.
The request body has not finished flushing, so it will continue to do, waiting on the user-supplied body. Even though the server responded with a connection close, that does NOT indicate that the current request should abort. And I'm not so sure hyper should do that proactively. The server has a very simple option it could do: close the TCP connection itself. Since it hasn't, I think we should keep trying to write.
Is there some additional detail that I missed that makes this different?
Thanks for looking. Agreed it's not a protocol violation as the client is allowed to keep writing after an early response, and the server could close the connection itself.
I think the "waiting on the user-supplied body" read is what's throwing this off. In the minimal repro I used a body that stops yielding so it'd reproduce deterministically, but that's not the situation we actually hit in real life. In our real life case the whole request body is already buffered and handed to hyper; it just can't finish flushing because the peer sent its full response and stopped reading, so its receive window is zero and we're parked in poll_flush waiting on the peer, not on the application. And the repro's server sends a normal keep-alive response (Content-Length, Connection: keep-alive) and never closes, with no Connection: close anywhere.
What makes it feel like more than "just keep writing": by the time we're stuck, the response is fully delivered and the caller has moved on. The recv_msg already took the response callback, and the only cancel path goes through can_read_head(), which needs Reading::Init and is unreachable once we're in Reading::KeepAlive. So there's no future left to cancel and no timeout, which leaves nothing the application can do to get the connection back. A peer that answers and then just stops reading, never sending a FIN, holds our connection and fd open until it decides to close. We saw this in prod: both ends ESTABLISHED, no FIN, the server tore it down minutes later. "The server could close TCP" is true, but when it doesn't, the client has no way out today, even on the timeout() path, which is problematic for a high TPS service like ours where FDs stacking up can quickly become a problem.
I get the abort-vs-drain tension, the main thing is even if the server is misbehaving a connection idle timeout should reclaim the connection, and it doesn't. As far as the requester (client) is concerned, it completed successfully but the connection is wedged and doesn't go back in the pool, nor does it idle out from the client side. Question: since the caller has nothing left to cancel once the response is in hand, would you be open to either closing the connection when a full response has been read and the request body can't complete, or some opt-in bound (a write/overall deadline, or a handle to abort)? And if neither, what's the intended way for a client to put a ceiling on this?
OK, so the problem is that the server is, let's say 'misbehaving', because it's not reading, but it's also not closing. Right?
A write timeout makes sense. It could be something done in hyper. But it's also general enough that it likely could happen at the IO layer. Basically, in
poll_write(andpoll_write_vectored), if the underlying socket is returningPending, then it could start a timeout, and if it triggers before another write is able to make any progress, then you have a stalled connection, I suppose.- addedS-waiting-on-authorStatus: waiting on the author to provide more info, or make changes.Status: waiting on the author to provide more info, or make changes.
on Sep 14, 2026 That reframing helps.
I my mind, I still think it would be better for this to live in hyper, not in an IO wrapper. An external write timeout can force an error eventually, but it does not solve the h1 state-machine problem inside hyper:
Dispatcher::is_done()only flips once the read side is closed (dispatch.rs:is_done), a fully read response leaves the read side atReading::KeepAliverather thanReading::Closed(conn.rs:poll_read_body), andpoll_shutdown()never runs unlessis_done()does (dispatch.rs:poll_inner). So from(KeepAlive, Body)the connection neither goes idle nor shuts down; it just parks. That is the path I am trying to fix, not the eventual IO-error path. (issue #4176,dispatch.rs:is_done,dispatch.rs:poll_inner,conn.rs:poll_read_body,conn.rs:poll_read_keep_alive)The other reason I'm bearish an IO-layer timeout is that
poll_writereturningPendingdoes not tell you why it is pending. Ordinary backpressure and this wedge look the same at that layer. You can build a smarter wrapper by only arming a timer after a response has been observed, but once you do that you are reconstructing hyper’s read/write state outside hyper. It feels better to keep that logic where the state already exists. (issue #4176,dispatch.rs:poll_read,conn.rs:Reading,conn.rs:Writing)On the RFC point, I tend to read it a bit more narrowly. RFC 9112 §9.3 says that on a persistent connection the server has to either read the whole request body or close after responding, otherwise leftover bytes can be mistaken for the next request. RFC 9112 §9.5 also says that a client sending a body and seeing a response that indicates the server is closing should stop transmitting and close its side. So I think the crux here is that
(KeepAlive, Body)does not seem to be in a reusable state and should not be pooled.On abort vs drain: fair point. Seeing the response does not prove the caller wants to abandon the upload. They may still be driving the body, or they may care that
SendRequeststays usable for the next request. If you want to avoid a silent behavior change, a builder flag likeBuilder::abort_body_on_response_completeseems like a reasonable first step. If you would rather make it the default, I still think it should stay on the client-only path behind!T::should_read_first(). (dispatch.rs,issue #4176)For a regression test, my thought would be to put it next to
client_flushing_is_not_ready_for_next_requestindispatch.rs: drive a client into(Reading::KeepAlive, Writing::Body)and assert that the dispatcher reaches shutdown instead of parking forever. If this ships behind an option, the test can assert shutdown with the option enabled and the current parked behavior with it disabled. (dispatch.rs:client_flushing_is_not_ready_for_next_request,issue #4176)Happy to shape the PR either way. If you have a preference, we can put it behind the builder option first or wire it as the default on the client path.
- added a commit that references this issue
on Sep 27, 2026 - removedS-waiting-on-authorStatus: waiting on the author to provide more info, or make changes.Status: waiting on the author to provide more info, or make changes.
on Oct 5, 2026 On the RFC point, I tend to read it a bit more narrowly. RFC 9112 §9.3 says that on a persistent connection the server has to either read the whole request body or close after responding, otherwise leftover bytes can be mistaken for the next request. RFC 9112 §9.5 also says that a client sending a body and seeing a response that indicates the server is closing should stop transmitting and close its side.
In this case, I lean towards the example in our tenets, that we try to follow the spec, but sometimes we know better. In this case, I still think it's more appropriate to default to waiting on the server, since it is always allowed to close a connection whenever it wants.
I think I'm seeing now though that besides that, what you want to solve is to let your client give up if the server has stalled because it won't close the connection. But you may have given up the
Body. (While you could use a streaming channel and send an error through theimpl Body, it could still stall on one chunk.)And you want the added context of the HTTP state for a timeout. I think this is another case of eventually adding in the proposed Body::poll_progress().
If this waits for Body::poll_progress() what is the best means of us to remediate on our side until then? Right now we have a local patch to add the following, but would rather not be patching the library if there's a better hold-over till the correct remediation is available.
(&Reading::KeepAlive, &Writing::Body(_)) => { self.close(); }
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:ESTABLISHED),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 inWriting::Body.Root cause
For a client (
!T::should_read_first()),Dispatcher::is_done()(src/proto/h1/dispatch.rs) can only returntruewhenread_doneis set:But
is_read_closed()ismatches!(self.state.reading, Reading::Closed)(src/proto/h1/conn.rs), and a fully-read response leavesreadinginReading::KeepAlive, notReading::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.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_eofand 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:src/main.rs: see the code below...Running it prints:
Code Sample
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
ESTABLISHEDand non-reusable indefinitely.Actual Behavior
In actuality:
sender.ready()never resolves,Connectiontask never completes (no shutdown, socket staysESTABLISHED),The same leak occurs through
hyper_util::client::legacy::Client: theon_idletask (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()insrc/proto/h1/conn.rs:self.close()sets both sides toClosed→is_done()becomestrue→poll_shutdown()runs (FIN, or RST if there is undeliverable data). Via the pooledlegacy::Client,SendRequest::poll_readythen resolves (error), so the connection is dropped rather than leaked. A regression test belongs next toclient_flushing_is_not_ready_for_next_requestinsrc/proto/h1/dispatch.rs: drive a client to(KeepAlive, Body)and assert the dispatcher reaches shutdown instead of returningPending.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:
poll_drain_or_close_readpattern) and only close if it can't complete, orHappy 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 inWriting::Body. (A body that fits in the buffers flushes fully, reachesWriting::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.