Skip to content

Block HTTP/2 request-body sends on flow control#911

Merged
benoitc merged 5 commits into
masterfrom
fix/h2-blocking-send-flow-control
Jul 16, 2026
Merged

Block HTTP/2 request-body sends on flow control#911
benoitc merged 5 commits into
masterfrom
fix/h2-blocking-send-flow-control

Conversation

@benoitc

@benoitc benoitc commented Jul 16, 2026

Copy link
Copy Markdown
Owner

An HTTP/2 request body larger than the peer's flow control window failed with {error, send_buffer_full}: body sends used the non-blocking h2_connection:send_data/4, which buffers up to a 1 MB cap instead of waiting for WINDOW_UPDATE. Request-body sends now use send_data/5 with #{block => Timeout}, parking the caller until the window opens while the h2 connection keeps processing inbound frames.

The new send_timeout option bounds the wait: default 30000 ms, infinity allowed, nonblock restores the old fail-fast behavior. It works per-request and as a connection option, mirroring recv_timeout. Covers the whole-body send, streamed chunks, the end-stream frame and body producer funs. HTTP/1.1 and HTTP/3 paths are unchanged, and no h2 bump is needed (0.11.0 already ships the blocking form).

Tests extend the frame-level repro server with a window_update knob (delayed, split or withheld WINDOW_UPDATEs) and add a flow-control suite: a large body over a small window completes, a never-opening window yields {error, timeout} in bounded time instead of hanging, nonblock still fails fast, and a 1.5 MB body (over the old cap) is delivered byte-for-byte, verified by digest.

benoitc added 4 commits July 16, 2026 18:49
An HTTP/2 request body larger than the peer's flow control window failed
with {error, send_buffer_full}: body sends used the non-blocking
h2_connection:send_data/4, which buffers up to a cap instead of waiting
for WINDOW_UPDATE. Request-body sends now use send_data/5 with
#{block => Timeout}, parking the caller until the window opens. The new
send_timeout option bounds the wait (default 30000 ms, infinity allowed,
nonblock restores the old fail-fast behavior). Covers the whole-body
send, streamed chunks, the end-stream frame and body producer funs.
HTTP/1.1 and HTTP/3 paths are unchanged.

Tested against a frame-level h2 server with a small initial window and
delayed, split or withheld WINDOW_UPDATEs, plus a digest echo server for
a body over the old 1 MB cap.
Add flow-control tests for body-producer funs (arity 0 and {fun, State}),
a streamed chunk hitting send_timeout, and the async request path with
send_timeout set. Every changed line of the blocking-send fix is now
exercised.
A failed body send now RST_STREAM(CANCEL)s the abandoned stream from
h2_send_data, so its buffered body no longer lingers on a shared
connection. This also covers the nonblock send_buffer_full case.

send_timeout no longer leaks between pooled requests: the per-request
value is passed along instead of stored, and the connection default is
no longer seeded from the first checkout's options. Streamed requests
now forward send_timeout through send_request_headers/5 and keep it in
a per-stream field set at every stream start, so a reused pooled
connection applies the caller's deadline.

The new async coverage test exposed a pre-existing bug: the h2 stream
entry stored the gen_statem call reference where the async delivery
clauses expect the stream_to pid, so every async HTTP/2 response was
silently dropped. The entry now stores the stream owner.

Regression tests: RST_STREAM observed on the wire after a timed-out
send, a 25 ms send_timeout followed by a default request on the same
pooled conn, a streamed request with send_timeout on a reused conn, and
the direct hackney_conn async API forms.
HTTP/2 {async, once} now honors stream_next/1 with the same contract as
HTTP/1.1, backed by h2 manual flow control so an unpulled response keeps
the peer's send window closed. Direct connections honor {send_timeout, T}
from connect options again (pooled ones keep the constant default). Fix
the unmatched return that tripped dialyzer.
@benoitc
benoitc force-pushed the fix/h2-blocking-send-flow-control branch from 81506d1 to 4589443 Compare July 16, 2026 22:19
notify => self() leaked late {h2_raw_server, ...} messages into the
shared eunit process, where later suites' catch-all receives consumed
them as unexpected responses. Notifications now go to a per-test
collector process that dies with the test, so nothing reaches other
tests' mailboxes.
@benoitc
benoitc merged commit f62ce83 into master Jul 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant