Skip to content

Commit 1fe387f

Browse files
authored
Merge pull request #886 from benoitc/fix/h2-goaway-pooled-reuse
Fix reused-connection hangs: HTTP/2 GOAWAY and HTTP/1.1 active-once stranding
2 parents a868eeb + fa6b95f commit 1fe387f

9 files changed

Lines changed: 1382 additions & 74 deletions

NEWS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# NEWS
22

3+
4.4.4 - 2026-06-17
4+
------------------
5+
6+
### Fixed
7+
8+
- HTTP/2: a connection is no longer reused after the peer sends `GOAWAY` while
9+
keeping the socket open (as AWS ALB does to recycle connections). The
10+
connection is retired so the pool dials a fresh one, instead of being handed
11+
out again with new streams the peer ignores until `recv_timeout`.
12+
- HTTP/2: when the per-stream `recv_timeout` watchdog fires, the stalled stream
13+
is cancelled (`RST_STREAM`) so the peer stops sending and the connection is not
14+
reused with an orphaned stream.
15+
- HTTP/1.1: bytes that issue #544's idle `{active, once}` delivers to the
16+
connection mailbox on a reused connection are now buffered and fed to the next
17+
request instead of dropping the connection (refines the 4.4.3 behavior below),
18+
so a reused request no longer blocks to `recv_timeout` while the response sits
19+
stranded as an unread message. The idle buffer is bounded, and a server close
20+
still refuses reuse (#544).
21+
322
4.4.3 - 2026-06-17
423
------------------
524

src/hackney_conn.erl

Lines changed: 127 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@
119119
%% late-arriving calls race the pool DOWN cleanup and still get a proper
120120
%% error reply instead of exit:{normal, _}. See issue #836.
121121
-define(CLOSED_GRACE_MS, 50).
122+
%% Cap on bytes buffered from an idle HTTP/1.1 connection via #544 {active,
123+
%% once}. A well-behaved peer sends nothing while idle; the next response's
124+
%% stranded prefix is small. Past this, treat the peer as misbehaving (flooding
125+
%% an idle connection) and drop it rather than buffer unboundedly.
126+
-define(MAX_IDLE_BUFFER, 65536).
122127

123128
%% State data record
124129
-record(conn_data, {
@@ -838,24 +843,26 @@ connected({call, From}, verify_socket, #conn_data{transport = Transport, socket
838843
connected({call, From}, is_ready, #conn_data{socket = undefined} = Data) ->
839844
%% Socket not connected
840845
{next_state, closed, Data, [{reply, From, {ok, closed}}]};
841-
connected({call, From}, is_ready, #conn_data{transport = Transport, socket = Socket} = Data) ->
846+
connected({call, From}, is_ready, #conn_data{transport = Transport, socket = Socket,
847+
buffer = Buffer} = Data) ->
842848
%% Stop active-mode delivery before reconciling the socket for checkout, so
843849
%% no further {tcp,_}/{ssl,_} messages can land after we inspect it.
844850
_ = Transport:setopts(Socket, [{active, false}]),
845-
%% A pooled connection is only reusable if nothing arrived while it was idle.
846-
%% #544: a server-initiated close (tcp_closed/ssl_closed) means drop it.
847-
%% Unsolicited data is just as disqualifying: hackney does not pipeline, so
848-
%% bytes that arrived while idle cannot belong to the next response. Reusing
849-
%% such a socket would strand them (passive recv blocks on an empty buffer)
850-
%% or corrupt the next read. Drop it and let the pool dial a fresh one.
851-
case has_pending_close(Socket) orelse has_pending_data(Transport, Socket) of
851+
case has_pending_close(Socket) of
852852
true ->
853+
%% #544: the server closed the idle connection - never reuse it.
853854
{next_state, closed, Data#conn_data{socket = undefined},
854855
[{reply, From, {ok, closed}}]};
855856
false ->
856857
case check_socket_health(Transport, Socket) of
857858
ok ->
858-
{keep_state_and_data, [{reply, From, {ok, connected}}]};
859+
%% Bytes delivered to the mailbox while idle in {active, once}
860+
%% are the start of the response; keep them in the read buffer
861+
%% so the next request consumes them instead of stranding them.
862+
Drained = drain_socket_data(Socket),
863+
{keep_state,
864+
Data#conn_data{buffer = <<Buffer/binary, Drained/binary>>},
865+
[{reply, From, {ok, connected}}]};
859866
{error, _} ->
860867
{keep_state_and_data, [{reply, From, {ok, closed}}]}
861868
end
@@ -965,7 +972,10 @@ connected({call, From}, {request, Method, Path, Headers, Body, ReqOpts}, Data) -
965972
status = undefined,
966973
reason = undefined,
967974
response_headers = undefined,
968-
buffer = <<>>,
975+
%% NOTE: buffer is intentionally preserved (not reset to <<>>). It is
976+
%% empty after any complete response, but may hold response bytes that
977+
%% #544 {active, once} stranded into the mailbox and connected(info,...)
978+
%% buffered; the next request must consume them.
969979
async = false,
970980
async_ref = undefined,
971981
stream_to = undefined,
@@ -1040,7 +1050,8 @@ connected({call, From}, {send_headers, Method, Path, Headers}, Data) ->
10401050
status = undefined,
10411051
reason = undefined,
10421052
response_headers = undefined,
1043-
buffer = <<>>,
1053+
%% buffer preserved (see the {request,...} handler): may hold stranded
1054+
%% response bytes buffered from #544 {active, once}.
10441055
async = false,
10451056
async_ref = undefined,
10461057
stream_to = undefined
@@ -1076,10 +1087,13 @@ connected(info, {ssl_error, Socket, _Reason}, #conn_data{socket = Socket} = Data
10761087

10771088
%% Unexpected data received while idle - HTTP/1.1 only (H/2 socket is owned
10781089
%% by h2_connection; H/3 uses QUIC messages).
1079-
connected(info, {tcp, Socket, _UnexpectedData}, #conn_data{socket = Socket} = Data) ->
1080-
{next_state, closed, Data#conn_data{socket = undefined}};
1081-
connected(info, {ssl, Socket, _UnexpectedData}, #conn_data{socket = Socket} = Data) ->
1082-
{next_state, closed, Data#conn_data{socket = undefined}};
1090+
%% Bytes delivered by #544 {active, once} while idle are the start of the next
1091+
%% response on a reused connection. Buffer them (do NOT treat as a broken
1092+
%% connection) and re-arm close detection, so the next request consumes them.
1093+
connected(info, {tcp, Socket, Data}, #conn_data{socket = Socket} = D) ->
1094+
buffer_idle_data(Data, D);
1095+
connected(info, {ssl, Socket, Data}, #conn_data{socket = Socket} = D) ->
1096+
buffer_idle_data(Data, D);
10831097

10841098
%% HTTP/3 message handling
10851099
connected(info, {h3, ConnRef, {stream_headers, StreamId, Headers, Fin}},
@@ -1131,15 +1145,20 @@ connected(EventType, Event, Data) ->
11311145
%% State: sending - Sending request data
11321146
%%====================================================================
11331147

1134-
sending(enter, connected, #conn_data{transport = Transport, socket = Socket}) ->
1135-
%% Set socket to passive mode for blocking send/recv operations
1136-
%% (socket was in active mode while idle in connected state)
1137-
%% Note: socket may be undefined for HTTP/3 (QUIC) connections
1138-
_ = case Socket of
1139-
undefined -> ok;
1140-
_ -> Transport:setopts(Socket, [{active, false}])
1141-
end,
1142-
keep_state_and_data;
1148+
sending(enter, connected, #conn_data{transport = Transport, socket = Socket,
1149+
buffer = Buffer} = Data) ->
1150+
%% Deterministically leave {active, once} before sending the request, and
1151+
%% drain any bytes already delivered to the mailbox into the read buffer so
1152+
%% the request/response cycle never runs with stranded data (the reuse hang).
1153+
%% Note: socket may be undefined for HTTP/3 (QUIC) connections.
1154+
case Socket of
1155+
undefined ->
1156+
keep_state_and_data;
1157+
_ ->
1158+
_ = Transport:setopts(Socket, [{active, false}]),
1159+
Drained = drain_socket_data(Socket),
1160+
{keep_state, Data#conn_data{buffer = <<Buffer/binary, Drained/binary>>}}
1161+
end;
11431162

11441163
sending(internal, {send_request, Method, Path, Headers, Body}, Data) ->
11451164
case do_send_request(Method, Path, Headers, Body, Data) of
@@ -1186,15 +1205,18 @@ streaming_body(enter, connected, #conn_data{protocol = http2}) ->
11861205
%% mode. hackney_conn must NOT flip it to passive or the h2 lib stops
11871206
%% receiving frames (the response would never arrive).
11881207
keep_state_and_data;
1189-
streaming_body(enter, connected, #conn_data{transport = Transport, socket = Socket}) ->
1190-
%% Set socket to passive mode for blocking send/recv operations
1191-
%% (socket was in active mode while idle in connected state)
1192-
%% Note: socket may be undefined for HTTP/3 (QUIC) connections
1193-
_ = case Socket of
1194-
undefined -> ok;
1195-
_ -> Transport:setopts(Socket, [{active, false}])
1196-
end,
1197-
keep_state_and_data;
1208+
streaming_body(enter, connected, #conn_data{transport = Transport, socket = Socket,
1209+
buffer = Buffer} = Data) ->
1210+
%% Same as sending(enter): go passive and un-strand any mailbox bytes before
1211+
%% the request/response cycle (socket may be undefined for HTTP/3 QUIC).
1212+
case Socket of
1213+
undefined ->
1214+
keep_state_and_data;
1215+
_ ->
1216+
_ = Transport:setopts(Socket, [{active, false}]),
1217+
Drained = drain_socket_data(Socket),
1218+
{keep_state, Data#conn_data{buffer = <<Buffer/binary, Drained/binary>>}}
1219+
end;
11981220

11991221
streaming_body(internal, {send_headers_only, Method, Path, Headers}, Data) ->
12001222
%% Send only headers, then return ok and wait for body chunks
@@ -2333,7 +2355,18 @@ stream_body_chunk_result({error, Reason}, _Data) ->
23332355

23342356
%% @private Receive data from socket
23352357
recv_data(#conn_data{transport = Transport, socket = Socket, recv_timeout = Timeout}) ->
2336-
Transport:recv(Socket, 0, Timeout).
2358+
%% Consume any bytes stranded in the mailbox by #544 {active, once} before
2359+
%% falling back to a passive socket read, so a reused connection never blocks
2360+
%% on an empty socket buffer while the response sits unread as a message.
2361+
case drain_socket_data(Socket) of
2362+
<<>> ->
2363+
case has_pending_close(Socket) of
2364+
true -> {error, closed};
2365+
false -> Transport:recv(Socket, 0, Timeout)
2366+
end;
2367+
Bytes ->
2368+
{ok, Bytes}
2369+
end.
23372370

23382371
%% @private Determine if we should enable active mode when entering connected state
23392372
%% We only want active mode for close detection when the connection is truly idle
@@ -2395,30 +2428,36 @@ has_pending_close(Socket) ->
23952428
false
23962429
end.
23972430

2398-
%% @private Detect unsolicited data that arrived while the socket was idle in
2399-
%% active mode. Returns true when any data is pending — such a connection is not
2400-
%% safe to reuse (hackney does not pipeline, so the bytes cannot belong to the
2401-
%% next response). Drains the mailbox so a dropped connection leaves nothing
2402-
%% behind, and peeks the socket buffer to close the active->passive race where a
2403-
%% {tcp,_}/{ssl,_} message has not yet landed. Must run after the socket is set
2404-
%% passive. Close messages are checked separately via has_pending_close/1.
2405-
has_pending_data(Transport, Socket) ->
2406-
HadMailbox = drain_socket_mailbox(Socket),
2407-
HadBuffer = case Transport:recv(Socket, 0, 0) of
2408-
{ok, _Bytes} -> true; %% bytes already buffered on the socket
2409-
{error, timeout} -> false; %% nothing pending - healthy idle socket
2410-
{error, _} -> true %% closed/other - not reusable
2411-
end,
2412-
HadMailbox orelse HadBuffer.
2431+
%% @private Drain and return any {tcp/ssl, Socket, Data} bytes queued in the
2432+
%% mailbox. #544 puts idle pooled sockets in {active, once}, which delivers the
2433+
%% next inbound bytes (the start of the response on a reused connection) as a
2434+
%% mailbox message and reverts the socket to passive. Those bytes are real
2435+
%% response data and must reach the parser, so we drain-and-return them rather
2436+
%% than discard them. Close/error messages are handled via has_pending_close/1.
2437+
drain_socket_data(Socket) ->
2438+
drain_socket_data(Socket, <<>>).
24132439

2414-
%% @private Remove any {tcp/ssl, Socket, Data} messages from the mailbox,
2415-
%% returning true if at least one was present.
2416-
drain_socket_mailbox(Socket) ->
2440+
drain_socket_data(Socket, Acc) ->
24172441
receive
2418-
{tcp, Socket, _Data} -> _ = drain_socket_mailbox(Socket), true;
2419-
{ssl, Socket, _Data} -> _ = drain_socket_mailbox(Socket), true
2442+
{tcp, Socket, Data} -> drain_socket_data(Socket, <<Acc/binary, Data/binary>>);
2443+
{ssl, Socket, Data} -> drain_socket_data(Socket, <<Acc/binary, Data/binary>>)
24202444
after 0 ->
2421-
false
2445+
Acc
2446+
end.
2447+
2448+
%% @private Buffer bytes that #544 {active, once} delivered on an idle HTTP/1.1
2449+
%% connection (the start of the next response on reuse) and re-arm close
2450+
%% detection, so the next request consumes them. Bounded by ?MAX_IDLE_BUFFER: a
2451+
%% peer flooding an idle connection is dropped rather than buffered unboundedly.
2452+
buffer_idle_data(Data, #conn_data{socket = Socket, transport = Transport,
2453+
buffer = Buffer} = D) ->
2454+
NewBuffer = <<Buffer/binary, Data/binary>>,
2455+
case byte_size(NewBuffer) > ?MAX_IDLE_BUFFER of
2456+
true ->
2457+
{next_state, closed, D#conn_data{socket = undefined}};
2458+
false ->
2459+
_ = Transport:setopts(Socket, [{active, once}]),
2460+
{keep_state, D#conn_data{buffer = NewBuffer}}
24222461
end.
24232462

24242463
%% @private Notify pool that connection is available for reuse (async)
@@ -2476,7 +2515,8 @@ do_request_async(From, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowR
24762515
status = undefined,
24772516
reason = undefined,
24782517
response_headers = undefined,
2479-
buffer = <<>>,
2518+
%% buffer preserved (see the {request,...} handler): may hold stranded
2519+
%% response bytes buffered from #544 {active, once}.
24802520
async = AsyncMode,
24812521
async_ref = Ref,
24822522
stream_to = StreamTo,
@@ -2738,12 +2778,18 @@ cancel_all_h2_timers(#conn_data{h2_timers = Timers} = Data) ->
27382778
%% stream and a sync reader is parked, fail that reader and drop the stream.
27392779
%% A stale timer (re-armed or already completed) is ignored.
27402780
handle_h2_recv_timeout(StreamId, TRef,
2741-
#conn_data{h2_streams = Streams, h2_timers = Timers} = Data) ->
2781+
#conn_data{h2_streams = Streams, h2_timers = Timers,
2782+
h2_conn = H2Conn} = Data) ->
27422783
case maps:get(StreamId, Timers, undefined) of
27432784
TRef ->
27442785
Timers2 = maps:remove(StreamId, Timers),
27452786
case maps:get(StreamId, Streams, undefined) of
27462787
{From, Inner} when is_tuple(Inner), element(1, Inner) =:= sync ->
2788+
%% RST_STREAM(CANCEL) the stalled stream so the peer stops
2789+
%% sending for it and the h2 layer drops it; otherwise the
2790+
%% pooled connection would be reused with an orphaned stream
2791+
%% still open (h2_conn_usable only checks the conn state).
2792+
_ = cancel_h2_stream(H2Conn, StreamId),
27472793
Streams2 = maps:remove(StreamId, Streams),
27482794
{keep_state,
27492795
Data#conn_data{h2_streams = Streams2, h2_timers = Timers2,
@@ -2756,6 +2802,11 @@ handle_h2_recv_timeout(StreamId, TRef,
27562802
{keep_state, Data}
27572803
end.
27582804

2805+
%% @private RST_STREAM(CANCEL) a stalled HTTP/2 stream, tolerating a dead conn.
2806+
cancel_h2_stream(undefined, _StreamId) -> ok;
2807+
cancel_h2_stream(H2Conn, StreamId) ->
2808+
try h2_connection:cancel_stream(H2Conn, StreamId) catch _:_ -> ok end.
2809+
27592810
%% @private Send an HTTP/2 request via the h2 library.
27602811
do_h2_request(From, Method, Path, Headers, Body, Data) ->
27612812
do_h2_send(From, Method, Path, Headers, Body,
@@ -3188,9 +3239,26 @@ h2_stream_parked_from({stream, headers, _, _, _, From}) -> From;
31883239
h2_stream_parked_from({stream, body_full, _, _, _, From}) -> From;
31893240
h2_stream_parked_from(_) -> undefined.
31903241

3191-
h2_on_goaway(ErrorCode, Data) ->
3242+
h2_on_goaway(ErrorCode, #conn_data{h2_conn = H2Conn, h2_mon = H2Mon} = Data) ->
3243+
%% A GOAWAY means the peer will not service new streams on this connection.
3244+
%% AWS ALBs recycle connections this way, sending GOAWAY but keeping the
3245+
%% socket open for a drain window. Leaving the conn `connected` and pooled
3246+
%% made checkout_h2/h2_conn_usable keep handing it out, so every reused
3247+
%% request opened a stream past last_stream_id that the peer ignored and hung
3248+
%% to recv_timeout. Tear the connection down and transition to `closed` (like
3249+
%% h2_on_closed/2): the pool then stops reusing it (h2_conn_usable requires
3250+
%% `connected`) and new requests dial a fresh connection. in-flight streams
3251+
%% are aborted with the goaway error as before.
31923252
{Replies, Data1} = collect_h2_aborts({goaway, ErrorCode}, Data),
3193-
{keep_state, cancel_all_h2_timers(Data1), Replies}.
3253+
Data2 = cancel_all_h2_timers(Data1),
3254+
_ = case H2Mon of
3255+
undefined -> ok;
3256+
_ -> erlang:demonitor(H2Mon, [flush])
3257+
end,
3258+
close_h2(H2Conn),
3259+
Stripped = Data2#conn_data{h2_conn = undefined, h2_mon = undefined,
3260+
socket = undefined, no_reuse = true},
3261+
{next_state, closed, Stripped, Replies}.
31943262

31953263
h2_on_closed(Reason, Data) ->
31963264
{Replies, Data1} = collect_h2_aborts({closed, Reason}, Data),

0 commit comments

Comments
 (0)