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
838843connected ({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
10851099connected (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
11441163sending (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
11991221streaming_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
23352357recv_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.
27402780handle_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.
27602811do_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;
31883239h2_stream_parked_from ({stream , body_full , _ , _ , _ , From }) -> From ;
31893240h2_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
31953263h2_on_closed (Reason , Data ) ->
31963264 {Replies , Data1 } = collect_h2_aborts ({closed , Reason }, Data ),
0 commit comments