Update (root cause corrected). My first hypothesis below — that the leak is caused by ?IDLE_TIMEOUT defaulting to infinity — turned out to be incomplete. Giving ?IDLE_TIMEOUT a finite value does not fix the leak: the idle reaper does {next_state, closed, Data}, but closed(enter) for a non-pooled connection (pool_pid = undefined) simply {keep_state, ...} with no stop and no grace timer (only pooled connections arm closed_grace_expired -> {stop, normal}). So a finite timeout just moves the zombie from connected to closed — the socket closes, but the gen_statem process is never terminated and keeps accumulating.
The actual root cause is in the synchronous body-completion path. receiving({call, From}, body, ...) and receiving({call, From}, stream_body, ...) unconditionally return {next_state, connected, ...} after the body is read — they never consult no_reuse or should_close_connection/1. So every completed no_reuse (or Connection: close) connection parks back in connected forever, pinned alive by its owner link → one leaked hackney_conn process per request. The async streaming path already handles this correctly in finish_async_streaming/1 (it stops non-reusable / non-pooled connections); the sync path (what HTTPoison uses) does not. See the corrected "Root cause" and "Suggested fix" sections below.
Summary
Under sustained load against an HTTP/1.1 endpoint whose connections are flagged no_reuse (e.g. Connection: close), hackney_conn gen_statem processes accumulate without bound and are never terminated. Each completed connection parks in the connected state as a child of hackney_conn_sup and nothing ever reaps it. Process memory grows until the node OOMs.
Observed on 4.4.5; the relevant code paths are unchanged in 4.5.2, so this is not fixed by upgrading.
Live evidence (production node — plain-HTTP JSON-RPC client, a few req/s)
2539 hackney_conn processes and climbing ~1.5/s (was 1714 a few minutes earlier), while only 34 OS ports exist. These are orphaned FSMs, not live connections (so it is not an fd/socket leak).
- Every one of them:
:sys.get_state/2 → connected
hackney_conn:checkin_info/1 →
#{protocol => http1, upgraded_ssl => false, no_reuse => true,
ready => false, should_close => false, pool_ssl => false}
- Their only live link is
hackney_conn_sup. The calling owner is a long-lived process that never dies, so no owner-DOWN ever triggers cleanup.
Root cause (in src/hackney_conn.erl)
The synchronous request path never closes a non-reusable connection when the request completes. After the body is fully read:
receiving({call, From}, body, Data) ->
case read_full_body(Data, <<>>) of
...
{ok, Body, NewData} ->
%% Socket still valid - return to connected state
{next_state, connected, NewData, [{reply, From, {ok, Body}}]}; %% <-- unconditional
...
stream_body does the same on {done, NewData}. Neither consults no_reuse nor should_close_connection/1, so a no_reuse/Connection: close connection returns to connected and stays there. Because its owner is long-lived and its socket is idle (or already gone with no {tcp_closed, _} delivered), there is no path to termination.
Contrast the async path, which gets it right — finish_async_streaming/1:
case should_close_connection(Data) of
true -> ... {stop, normal, ...};
false when PoolPid =:= undefined -> ... {stop, normal, ...};
false -> {next_state, connected, reset_async(Data)}
end
Two secondary factors make it impossible for any fallback to save the parked process:
-define(IDLE_TIMEOUT, infinity). and idle_timeout is never populated from app-env or a request option, so the connected(state_timeout, idle_timeout) -> {next_state, closed} reaper is never armed.
- Even if it were,
closed(enter, ...) for a non-pooled connection (pool_pid = undefined) does {keep_state, ...} — it closes the socket but never stops the process. Only pooled connections arm closed_grace_expired -> {stop, normal}.
This looks adjacent to #886 (HTTP/1.1 active-once stranding) and #888 (keepalive pooling correctness), but those addressed reused/pooled connections; the completed non-reused sync case is still uncovered.
Reproduction
Issue hackney:request/5 (e.g. via httpoison) repeatedly against a plain-HTTP/1.1 server that responds with Connection: close, at a modest rate, with a long-lived caller. Then count connection processes:
length([P || P <- erlang:processes(),
{dictionary, D} <- [erlang:process_info(P, dictionary)],
match =:= (case lists:keyfind('$initial_call', 1, D) of
{_, {hackney_conn, _, _}} -> match; _ -> nomatch end)]).
It grows monotonically while open sockets (length(erlang:ports())) stay flat.
Suggested fix
Make the sync completion path mirror finish_async_streaming/1 — stop the process instead of parking it when the connection must not be reused:
finish_sync_request(From, Reply, #conn_data{transport = Transport, socket = Socket,
no_reuse = NoReuse} = Data) ->
case NoReuse orelse should_close_connection(Data) of
true ->
case Socket of
undefined -> ok;
_ -> Transport:close(Socket)
end,
{stop_and_reply, normal, [{reply, From, Reply}],
Data#conn_data{socket = undefined}};
false ->
{next_state, connected, Data, [{reply, From, Reply}]}
end.
…called from both receiving({call,From}, body, ...) {ok, Body, NewData} and receiving({call,From}, stream_body, ...) {done, NewData}. Reusable keep-alive connections still return to connected.
Optionally, as defense-in-depth, also make closed(enter, ...) for pool_pid = undefined arm a grace timer + {stop, normal} (mirroring the pooled path) so that connections reaching closed via server-close / tcp_error also terminate.
Happy to send a PR.
Summary
Under sustained load against an HTTP/1.1 endpoint whose connections are flagged
no_reuse(e.g.Connection: close),hackney_conngen_statem processes accumulate without bound and are never terminated. Each completed connection parks in theconnectedstate as a child ofhackney_conn_supand nothing ever reaps it. Process memory grows until the node OOMs.Observed on 4.4.5; the relevant code paths are unchanged in 4.5.2, so this is not fixed by upgrading.
Live evidence (production node — plain-HTTP JSON-RPC client, a few req/s)
2539hackney_connprocesses and climbing ~1.5/s (was1714a few minutes earlier), while only 34 OS ports exist. These are orphaned FSMs, not live connections (so it is not an fd/socket leak).:sys.get_state/2→connectedhackney_conn:checkin_info/1→#{protocol => http1, upgraded_ssl => false, no_reuse => true, ready => false, should_close => false, pool_ssl => false}hackney_conn_sup. The calling owner is a long-lived process that never dies, so no owner-DOWNever triggers cleanup.Root cause (in
src/hackney_conn.erl)The synchronous request path never closes a non-reusable connection when the request completes. After the body is fully read:
stream_bodydoes the same on{done, NewData}. Neither consultsno_reusenorshould_close_connection/1, so ano_reuse/Connection: closeconnection returns toconnectedand stays there. Because its owner is long-lived and its socket is idle (or already gone with no{tcp_closed, _}delivered), there is no path to termination.Contrast the async path, which gets it right —
finish_async_streaming/1:Two secondary factors make it impossible for any fallback to save the parked process:
-define(IDLE_TIMEOUT, infinity).andidle_timeoutis never populated from app-env or a request option, so theconnected(state_timeout, idle_timeout) -> {next_state, closed}reaper is never armed.closed(enter, ...)for a non-pooled connection (pool_pid = undefined) does{keep_state, ...}— it closes the socket but neverstops the process. Only pooled connections armclosed_grace_expired -> {stop, normal}.This looks adjacent to #886 (HTTP/1.1 active-once stranding) and #888 (keepalive pooling correctness), but those addressed reused/pooled connections; the completed non-reused sync case is still uncovered.
Reproduction
Issue
hackney:request/5(e.g. via httpoison) repeatedly against a plain-HTTP/1.1 server that responds withConnection: close, at a modest rate, with a long-lived caller. Then count connection processes:It grows monotonically while open sockets (
length(erlang:ports())) stay flat.Suggested fix
Make the sync completion path mirror
finish_async_streaming/1— stop the process instead of parking it when the connection must not be reused:…called from both
receiving({call,From}, body, ...){ok, Body, NewData}andreceiving({call,From}, stream_body, ...){done, NewData}. Reusable keep-alive connections still return toconnected.Optionally, as defense-in-depth, also make
closed(enter, ...)forpool_pid = undefinedarm a grace timer +{stop, normal}(mirroring the pooled path) so that connections reachingclosedvia server-close /tcp_erroralso terminate.Happy to send a PR.