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

1 participant