You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
hyperium/http-body#90 proposes the addition of a new method to the http_body::Body trait. because Body is a foundational interface used in HTTP clients and servers, altering this trait is a significant change that would benefit from some consideration.
#4131 introduced an RFC-like process to hyper, to help provide a mechanism for proposing non-trivial changes like this.
I don't have a production case for this. An automated security scan of a small hyper/axum project flagged its overload path: when the server is at capacity it answers with a 503 and a short reason string, and an HTTP/2 client with a zero window never lets that body finish, so each refused request keeps a stream open. Making the 503 header-only avoids it, but that only works for responses with nothing to say, so I followed it from there. As far as I can tell, poll_progress (http-body#90) fixes the zero-window case, but a client that opens a 1-byte window can still hold the stream open indefinitely.
PipeToSendStream waits until the peer's flow-control window allows at least 1 byte, then hands the whole chunk to send_data(chunk, is_eos). It has to, per #4212. h2 queues the bytes the window doesn't allow yet. If that chunk ends the body, the pipe returns and drops the body, and the queued data and the stream slot stay in h2 with nothing left to poll. Hiding the end of the body doesn't change this. When the body later returns None, hyper queues an empty END_STREAM frame without waiting for window and drops the body anyway.
I ported the HTTP/2 part of #3169 (cc @sfackler) onto master (cf563e8a) and wrapped a 17-byte body in a 500ms poll_progress timeout. The client never sends WINDOW_UPDATE, and the server has max_concurrent_streams(1):
client stream window
timeout fired
stream
next request
0
yes
reset
succeeds
1
no
stuck after 1 byte
blocked
65535
no
completes
succeeds
Without the port, the 1-byte row looks the same, and server keep-alive pings don't end it.
This can't be fixed outside hyper and h2. A body wrapper in the app or hyper-util only runs while hyper still holds the body, and hyper drops it once the last chunk is queued. A socket-level write timeout, the suggestion in #3852 and #3685, never fires, because the socket isn't blocked: h2 just isn't allowed to send. The client answers keep-alive pings. After that point, only h2's send queue still knows about the stream.
Two possible fixes:
hyper keeps the body, and keeps polling poll_progress, until h2 has flushed the stream. That needs a new h2 API (say SendStream::poll_flushed), because poll_capacity returns None once END_STREAM is queued.
poll_progress stays limited to bodies hyper is still reading, and h2 gets a send-stall timeout:
The builder sets it, and h2 times each stream separately. A stream is stalled while it has queued DATA and a send window of 0, and only a WINDOW_UPDATE for that stream restarts its timer. On expiry, h2 clears the queue and sends RST_STREAM(CANCEL). An implicit reset waits behind queued DATA and never goes out (hyperium/h2#880), so the reset has to clear the queue rather than join it. These resets should count toward max_local_error_reset_streams.
It should be off by default, because clients can legitimately stop reading for a while. It should also be overridable per stream, for example from a response extension: a streaming gRPC response and a small error page want very different limits.
Option 2 looks simpler, and the two don't conflict.
Prior art:
Envoy had this exact bug (CVE-2020-12604). Its stream_flush_timeout covers the case where "the entirety of stream data (local end stream is true) has been buffered pending available window".
Activity
cratelyn commentedon Oct 22, 2026
checking in to note that i'm still working on this.
cratelyn commentedon Nov 2, 2026
cross-referencing rust-lang/rfcs#4019 and rust-lang/rfcs#4020, which is proposing the introduction of
std::iter::AsyncIterator::poll_progress().joshka commentedon Nov 8, 2026
I don't have a production case for this. An automated security scan of a small hyper/axum project flagged its overload path: when the server is at capacity it answers with a 503 and a short reason string, and an HTTP/2 client with a zero window never lets that body finish, so each refused request keeps a stream open. Making the 503 header-only avoids it, but that only works for responses with nothing to say, so I followed it from there. As far as I can tell,
poll_progress(http-body#90) fixes the zero-window case, but a client that opens a 1-byte window can still hold the stream open indefinitely.PipeToSendStreamwaits until the peer's flow-control window allows at least 1 byte, then hands the whole chunk tosend_data(chunk, is_eos). It has to, per #4212. h2 queues the bytes the window doesn't allow yet. If that chunk ends the body, the pipe returns and drops the body, and the queued data and the stream slot stay in h2 with nothing left to poll. Hiding the end of the body doesn't change this. When the body later returnsNone, hyper queues an empty END_STREAM frame without waiting for window and drops the body anyway.I ported the HTTP/2 part of #3169 (cc @sfackler) onto master (
cf563e8a) and wrapped a 17-byte body in a 500mspoll_progresstimeout. The client never sends WINDOW_UPDATE, and the server hasmax_concurrent_streams(1):Without the port, the 1-byte row looks the same, and server keep-alive pings don't end it.
This can't be fixed outside hyper and h2. A body wrapper in the app or hyper-util only runs while hyper still holds the body, and hyper drops it once the last chunk is queued. A socket-level write timeout, the suggestion in #3852 and #3685, never fires, because the socket isn't blocked: h2 just isn't allowed to send. The client answers keep-alive pings. After that point, only h2's send queue still knows about the stream.
Two possible fixes:
hyper keeps the body, and keeps polling
poll_progress, until h2 has flushed the stream. That needs a new h2 API (saySendStream::poll_flushed), becausepoll_capacityreturnsNoneonce END_STREAM is queued.poll_progressstays limited to bodies hyper is still reading, and h2 gets a send-stall timeout:The builder sets it, and h2 times each stream separately. A stream is stalled while it has queued DATA and a send window of 0, and only a WINDOW_UPDATE for that stream restarts its timer. On expiry, h2 clears the queue and sends
RST_STREAM(CANCEL). An implicit reset waits behind queued DATA and never goes out (hyperium/h2#880), so the reset has to clear the queue rather than join it. These resets should count towardmax_local_error_reset_streams.It should be off by default, because clients can legitimately stop reading for a while. It should also be overridable per stream, for example from a response extension: a streaming gRPC response and a small error page want very different limits.
Option 2 looks simpler, and the two don't conflict.
Prior art:
stream_flush_timeoutcovers the case where "the entirety of stream data (local end stream is true) has been buffered pending available window".send_timeout) and Varnish (h2_window_timeout) have similar timeouts.cratelyn commentedon Nov 12, 2026
early draft is open at #4265.