Skip to content

Write a HIP for Body::poll_progress() #4228

Open

Description

@cratelyn

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.

the benefits of a poll_progress() method have come up in various discussions since then, and a call for comments was posted a little over a year ago.

to help move this proposal forward, let's digest feedback we received, and restate the case for this in a HIP.

Activity

  1. self-assigned this
    on Oct 7, 2026
  2. added
    C-featureCategory: feature. This is adding a new feature.
    B-rfcBlocked: More comments would be useful in determine next steps.
    A-bodyArea: body streaming.
    on Oct 7, 2026
  3. cratelyn commented on Oct 22, 2026

    @cratelyn
    MemberAuthor

    checking in to note that i'm still working on this.

  4. cratelyn commented on Nov 2, 2026

    @cratelyn
    MemberAuthor

    cross-referencing rust-lang/rfcs#4019 and rust-lang/rfcs#4020, which is proposing the introduction of std::iter::AsyncIterator::poll_progress().

  5. joshka commented on Nov 8, 2026

    @joshka
    Contributor

    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:

    1. 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.

    2. poll_progress stays limited to bodies hyper is still reading, and h2 gets a send-stall timeout:

      pub fn send_stall_timeout(&mut self, dur: Duration) -> &mut Self;

      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:

  6. cratelyn commented on Nov 12, 2026

    @cratelyn
    MemberAuthor

    early draft is open at #4265.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

A-bodyArea: body streaming.B-rfcBlocked: More comments would be useful in determine next steps.C-featureCategory: feature. This is adding a new feature.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions