Skip to content

Commit db9297c

Browse files
authored
Merge pull request #919 from lambadalambda/fix/truncated-body-unpooled-leak
Stop dead-end connections instead of parking them in `closed` (#918)
2 parents a442927 + 7546b07 commit db9297c

4 files changed

Lines changed: 166 additions & 12 deletions

File tree

NEWS.md

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

3+
unreleased
4+
----------
5+
6+
### Fixed
7+
8+
- A response body cut short by the peer closing mid-transfer no longer leaks
9+
the connection process. `read_full_body/2` hands back `socket = undefined`,
10+
so the connection went straight to `closed` and never reached the reuse
11+
check added for #902. An unpooled connection arms no grace timer there and,
12+
when started under `hackney_conn_sup`, has the supervisor as its `owner`, so
13+
the owner-DOWN clause never fired either: the process parked forever holding
14+
every refc binary it had read. Callers could not clean up, since a
15+
synchronous request returns the body directly and the truncated read still
16+
reports `{ok, Body}` (#918). The same applies to a failed body read and to
17+
bodyless (204/304) responses.
18+
- `hackney_conn:get_location/1` and `set_location/2` no longer exit with
19+
`noproc` when the connection has already stopped, which would otherwise
20+
propagate out of `hackney:request/5` on the redirect path.
21+
322
4.7.2 - 2026-07-17
423
------------------
524

src/hackney.erl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,12 +1489,13 @@ follow_redirect(ConnPid, Method, Body, WithBody, Options, CurrentURL, RespHeader
14891489
[{follow_redirect, true}, {max_redirect, MaxRedirect},
14901490
{redirect_count, RedirectCount + 1}, {with_body, WithBody} | Options2]) of
14911491
{ok, Status2, Headers2, Body2} ->
1492-
%% Store the final location in the connection
1493-
hackney_conn:set_location(ConnPid, FinalLocation),
1492+
%% Store the final location in the connection (the conn may already
1493+
%% be gone, so set_location can return {error, closed}).
1494+
_ = hackney_conn:set_location(ConnPid, FinalLocation),
14941495
{ok, Status2, Headers2, Body2};
14951496
{ok, Status2, Headers2} ->
14961497
%% Store the final location in the connection
1497-
hackney_conn:set_location(ConnPid, FinalLocation),
1498+
_ = hackney_conn:set_location(ConnPid, FinalLocation),
14981499
{ok, Status2, Headers2};
14991500
Error ->
15001501
Error

src/hackney_conn.erl

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -510,12 +510,15 @@ response_headers(Pid) ->
510510
%% @doc Get the stored location (final URL after redirects).
511511
-spec get_location(pid()) -> binary() | undefined.
512512
get_location(Pid) ->
513-
gen_statem:call(Pid, get_location).
513+
case safe_call(Pid, get_location) of
514+
{error, closed} -> undefined;
515+
Location -> Location
516+
end.
514517

515518
%% @doc Set the location (used after following redirects).
516-
-spec set_location(pid(), binary()) -> ok.
519+
-spec set_location(pid(), binary()) -> ok | {error, closed}.
517520
set_location(Pid, Location) ->
518-
gen_statem:call(Pid, {set_location, Location}).
521+
safe_call(Pid, {set_location, Location}).
519522

520523
%% @doc Send data through the connection process.
521524
%% This is a low-level function used by hackney_request.
@@ -1557,13 +1560,12 @@ receiving({call, From}, body, Data) ->
15571560
case read_full_body(Data, <<>>) of
15581561
{ok, Body, #conn_data{socket = undefined} = NewData} ->
15591562
%% Socket was closed during body read (e.g., no Content-Length)
1560-
%% Transition to closed state instead of connected
1561-
{next_state, closed, NewData, [{reply, From, {ok, Body}}]};
1563+
finish_dead_request(From, {ok, Body}, NewData);
15621564
{ok, Body, NewData} ->
15631565
%% Socket still valid - reuse it, or stop if not reusable.
15641566
finish_sync_request(From, {ok, Body}, NewData);
15651567
{error, Reason} ->
1566-
{next_state, closed, Data, [{reply, From, {error, Reason}}]}
1568+
finish_dead_request(From, {error, Reason}, Data)
15671569
end;
15681570

15691571
receiving({call, From}, stream_body, Data) ->
@@ -1572,12 +1574,12 @@ receiving({call, From}, stream_body, Data) ->
15721574
{ok, Chunk, NewData} ->
15731575
{keep_state, NewData, [{reply, From, {ok, Chunk}}]};
15741576
{done, #conn_data{socket = undefined} = NewData} ->
1575-
%% Socket was closed during body read - transition to closed state
1576-
{next_state, closed, NewData, [{reply, From, done}]};
1577+
%% Socket was closed during body read
1578+
finish_dead_request(From, done, NewData);
15771579
{done, NewData} ->
15781580
finish_sync_request(From, done, NewData);
15791581
{error, Reason} ->
1580-
{next_state, closed, Data, [{reply, From, {error, Reason}}]}
1582+
finish_dead_request(From, {error, Reason}, Data)
15811583
end;
15821584

15831585
receiving({call, From}, get_state, _Data) ->
@@ -2028,6 +2030,27 @@ finish_sync_request(From, Reply, #conn_data{transport = Transport, socket = Sock
20282030
Data#conn_data{socket = undefined}}
20292031
end.
20302032

2033+
%% @private The request is over and the connection cannot carry another one:
2034+
%% the body was cut short (the peer closed mid-transfer, so read_full_body/2
2035+
%% hands back `socket = undefined'), the response had no body to read, or the
2036+
%% read failed outright. None of these reach finish_sync_request/3 (#902).
2037+
%%
2038+
%% A pooled conn parks in `closed', keeping the grace window added for #836 so
2039+
%% late calls still get a proper reply before it stops. An unpooled conn has no
2040+
%% such timer, and when it was started under hackney_conn_sup its `owner' is the
2041+
%% supervisor, so the owner-DOWN clause never fires either: parking there leaks
2042+
%% the process along with every refc binary it read (#918). Stop instead.
2043+
finish_dead_request(From, Reply, #conn_data{transport = Transport, socket = Socket,
2044+
pool_pid = PoolPid} = Data) ->
2045+
case PoolPid of
2046+
undefined ->
2047+
ok = close_socket(Transport, Socket),
2048+
{stop_and_reply, normal, [{reply, From, Reply}],
2049+
Data#conn_data{socket = undefined}};
2050+
_ ->
2051+
{next_state, closed, Data, [{reply, From, Reply}]}
2052+
end.
2053+
20312054
%% @private Finish async streaming. Reuse only a reusable, pooled connection
20322055
%% (direct async conns are not re-driven, so they stop rather than park);
20332056
%% otherwise close and stop. Now also honours no_reuse via connection_reusable/1,

test/hackney_conn_tests.erl

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ hackney_conn_integration_test_() ->
6868
{"sync stream_body on a non-reusable conn stops it", {timeout, 30, fun test_sync_stream_body_no_reuse_stops/0}},
6969
{"async on a non-reusable pooled conn stops it", {timeout, 30, fun test_async_no_reuse_pooled_stops/0}},
7070
{"sync body on a reusable conn keeps it connected", {timeout, 30, fun test_sync_body_reusable_stays_connected/0}},
71+
%% Truncated body: bypasses finish_sync_request/3 and lands in `closed' (#918)
72+
{"truncated body on an unpooled conn stops it", {timeout, 30, fun test_truncated_body_unpooled_stops/0}},
73+
{"truncated stream_body on an unpooled conn stops it", {timeout, 30, fun test_truncated_stream_body_unpooled_stops/0}},
74+
{"truncated body on a pooled conn keeps the grace window", {timeout, 30, fun test_truncated_body_pooled_keeps_grace/0}},
75+
{"location accessors are noproc-safe", {timeout, 30, fun test_location_accessors_noproc_safe/0}},
7176
%% 1XX response handling
7277
{"skip 1XX informational responses", {timeout, 30, fun test_skip_1xx_responses/0}}
7378
]}.
@@ -955,3 +960,109 @@ stream_all(Pid, Acc) ->
955960
done ->
956961
Acc
957962
end.
963+
964+
%% A response whose body is cut short (peer closes mid-body) leaves
965+
%% read_full_body/2 with `socket = undefined', so `receiving' goes straight to
966+
%% `closed' and never reaches finish_sync_request/3 -- the #902 fix. For an
967+
%% unpooled connection `closed(enter)' arms no timer and the owner is
968+
%% hackney_conn_sup, so the process parked forever holding the partial body.
969+
test_truncated_body_unpooled_stops() ->
970+
{LSock, Port} = start_truncating_server(1024 * 64),
971+
try
972+
Pid = truncating_conn(Port),
973+
MRef = erlang:monitor(process, Pid),
974+
%% The short read still reports success, so the caller has no reason
975+
%% (and under hackney 4 no handle) to close anything.
976+
{ok, Body} = hackney_conn:body(Pid),
977+
?assertEqual(1024 * 64, byte_size(Body)),
978+
?assertEqual(normal, wait_down_reason(Pid, MRef))
979+
after
980+
catch gen_tcp:close(LSock)
981+
end.
982+
983+
%% Same, draining through stream_body/1 rather than body/1.
984+
test_truncated_stream_body_unpooled_stops() ->
985+
{LSock, Port} = start_truncating_server(1024 * 64),
986+
try
987+
Pid = truncating_conn(Port),
988+
MRef = erlang:monitor(process, Pid),
989+
_ = stream_all(Pid, <<>>),
990+
?assertEqual(normal, wait_down_reason(Pid, MRef))
991+
after
992+
catch gen_tcp:close(LSock)
993+
end.
994+
995+
%% A pooled connection must still park in `closed' so the #836 grace window
996+
%% answers late calls; the pool's own timer stops it shortly after.
997+
test_truncated_body_pooled_keeps_grace() ->
998+
{LSock, Port} = start_truncating_server(1024 * 64),
999+
Pool = spawn(fun() -> receive stop -> ok end end),
1000+
try
1001+
Pid = truncating_conn(Port, #{pool_pid => Pool}),
1002+
{ok, _Body} = hackney_conn:body(Pid),
1003+
?assertEqual({ok, closed}, hackney_conn:get_state(Pid)),
1004+
?assert(is_process_alive(Pid))
1005+
after
1006+
Pool ! stop,
1007+
catch gen_tcp:close(LSock)
1008+
end.
1009+
1010+
truncating_conn(Port) ->
1011+
truncating_conn(Port, #{}).
1012+
1013+
truncating_conn(Port, Extra) ->
1014+
Opts = maps:merge(#{
1015+
host => "127.0.0.1",
1016+
port => Port,
1017+
transport => hackney_tcp,
1018+
connect_timeout => 5000,
1019+
recv_timeout => 5000
1020+
}, Extra),
1021+
{ok, Pid} = hackney_conn:start_link(Opts),
1022+
ok = hackney_conn:connect(Pid),
1023+
{ok, _Status, _Headers} = hackney_conn:request(Pid, <<"GET">>, <<"/truncated">>, [], <<>>),
1024+
Pid.
1025+
1026+
%% Announces a Content-Length far larger than what it sends, then closes.
1027+
start_truncating_server(SendBytes) ->
1028+
{ok, LSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]),
1029+
{ok, Port} = inet:port(LSock),
1030+
spawn(fun() ->
1031+
case gen_tcp:accept(LSock, 5000) of
1032+
{ok, Sock} ->
1033+
_ = gen_tcp:recv(Sock, 0, 5000),
1034+
Headers = ["HTTP/1.1 200 OK\r\n",
1035+
"Content-Type: application/octet-stream\r\n",
1036+
"Content-Length: 100000000\r\n\r\n"],
1037+
_ = gen_tcp:send(Sock, Headers),
1038+
_ = gen_tcp:send(Sock, binary:copy(<<"x">>, SendBytes)),
1039+
gen_tcp:close(Sock);
1040+
_ ->
1041+
ok
1042+
end
1043+
end),
1044+
{LSock, Port}.
1045+
1046+
wait_down_reason(Pid, MRef) ->
1047+
receive
1048+
{'DOWN', MRef, process, Pid, Reason} -> Reason
1049+
after 5000 ->
1050+
timeout
1051+
end.
1052+
1053+
%% hackney:request/5 calls set_location/2 on the original connection after a
1054+
%% redirect chain returns. Now that a dead-end connection stops rather than
1055+
%% parks, that pid can already be gone, so the accessors must not exit with
1056+
%% noproc and propagate out of the caller.
1057+
test_location_accessors_noproc_safe() ->
1058+
Opts = #{
1059+
host => "127.0.0.1",
1060+
port => ?PORT,
1061+
transport => hackney_tcp,
1062+
connect_timeout => 5000
1063+
},
1064+
{ok, Pid} = hackney_conn:start_link(Opts),
1065+
ok = hackney_conn:stop(Pid),
1066+
?assertNot(is_process_alive(Pid)),
1067+
?assertEqual(undefined, hackney_conn:get_location(Pid)),
1068+
?assertEqual({error, closed}, hackney_conn:set_location(Pid, <<"http://127.0.0.1/final">>)).

0 commit comments

Comments
 (0)