Skip to content

Bug: Caddy.Admin.Request.do_recv/3 missing catch-all causes FunctionClauseError crash loop #8

@gsmlg

Description

@gsmlg

Summary

Two bugs in Caddy.Admin.Request and Caddy.Admin.Api can cause the Caddy.Server.External GenServer to crash in a tight loop, which exhausts the Caddy.Supervisor max restart intensity (3 restarts in 5 seconds) and terminates the entire :caddy OTP application. Once terminated, all Caddy.* calls from the dashboard throw :exit and show "Unavailable / Not Ready / Not Configured".


Bug 1: do_recv/3 has no catch-all clause

File: lib/caddy/admin/request.ex

The do_recv/3 recursive function handles HTTP response parsing but only matches three patterns:

defp do_recv(socket, {:ok, {:http_response, {1, 1}, code, _}}, resp) -> ...
defp do_recv(socket, {:ok, {:http_header, _, h, _, v}}, resp) -> ...
defp do_recv(socket, {:ok, :http_eoh}, resp) -> ...

Missing patterns that cause FunctionClauseError:

  • {:error, :timeout} — socket receive timeout (5 s limit is hit)
  • {:error, :closed} — server closes connection before response is complete
  • {:ok, {:http_response, {1, 0}, code, _}} — HTTP/1.0 response
  • {:ok, {:http_error, msg}} — malformed HTTP response
  • Any other unexpected value from :gen_tcp.recv/3

When any of these occur inside handle_info(:health_check, state) or handle_continue(:initial_setup, state), the exception propagates out of the GenServer callback and crashes Caddy.Server.External.

Fix:

defp do_recv(_socket, {:error, reason}, _resp) do
  {:error, reason}
end

defp do_recv(_socket, unexpected, _resp) do
  {:error, {:unexpected_response, unexpected}}
end

Bug 2: Api.load/1 crashes with FunctionClauseError when get_config() returns nil

File: lib/caddy/admin/api.ex

def load(conf) when is_map(conf) do
  get_config()       # Returns nil when Caddy Admin API is unreachable
  |> Map.merge(conf) # ** FunctionClauseError: no function clause matching Map.merge(nil, ...)
  |> Jason.encode!()
  |> load()
end

Api.get_config/0 returns nil on connection failure. Piping nil into Map.merge/2 throws FunctionClauseError, crashing the calling GenServer.

This is triggered from push_initial_config/0 in Caddy.Server.External — called during handle_continue(:initial_setup) and periodic handle_info(:health_check) — when Caddy briefly becomes reachable for the health check but is gone by the time the load request fires (race condition), or when the /adapt call succeeds but the subsequent get_config() inside load/1 fails.

Fix:

def load(conf) when is_map(conf) do
  case get_config() do
    nil ->
      conf |> Jason.encode!() |> load()
    existing when is_map(existing) ->
      existing |> Map.merge(conf) |> Jason.encode!() |> load()
  end
end

Crash cascade

Both bugs produce the same failure mode:

  1. Caddy.Server.External crashes (FunctionClauseError in handle_info or handle_continue)
  2. Caddy.Supervisor (:rest_for_one) restarts Caddy.Server.External
  3. It crashes again immediately (same code path)
  4. After 3 crashes in 5 seconds, Caddy.Supervisor terminates
  5. Caddy.Application terminates — all Caddy.* processes are gone
  6. Dashboard shows: Application State = Unavailable, Ready = Not Ready, Configuration = Not Configured, Sync Status = Unavailable

Environment

  • caddy hex package version: 2.3.1
  • Mode: :external, admin_url: "http://localhost:2019"
  • Elixir 1.18, OTP 28

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions