Skip to content

hackney_conn processes leak: completed no_reuse (Connection: close) HTTP/1.1 connections park in connected forever (sync body-completion path never closes non-reusable connections) #902

Description

@lucianoengel

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/2connected
    • 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:

  1. -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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions