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:
Caddy.Server.External crashes (FunctionClauseError in handle_info or handle_continue)
Caddy.Supervisor (:rest_for_one) restarts Caddy.Server.External
- It crashes again immediately (same code path)
- After 3 crashes in 5 seconds,
Caddy.Supervisor terminates
Caddy.Application terminates — all Caddy.* processes are gone
- 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
Summary
Two bugs in
Caddy.Admin.RequestandCaddy.Admin.Apican cause theCaddy.Server.ExternalGenServer to crash in a tight loop, which exhausts theCaddy.Supervisormax restart intensity (3 restarts in 5 seconds) and terminates the entire:caddyOTP application. Once terminated, allCaddy.*calls from the dashboard throw:exitand show "Unavailable / Not Ready / Not Configured".Bug 1:
do_recv/3has no catch-all clauseFile:
lib/caddy/admin/request.exThe
do_recv/3recursive function handles HTTP response parsing but only matches three patterns: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:gen_tcp.recv/3When any of these occur inside
handle_info(:health_check, state)orhandle_continue(:initial_setup, state), the exception propagates out of the GenServer callback and crashesCaddy.Server.External.Fix:
Bug 2:
Api.load/1crashes withFunctionClauseErrorwhenget_config()returnsnilFile:
lib/caddy/admin/api.exApi.get_config/0returnsnilon connection failure. PipingnilintoMap.merge/2throwsFunctionClauseError, crashing the calling GenServer.This is triggered from
push_initial_config/0inCaddy.Server.External— called duringhandle_continue(:initial_setup)and periodichandle_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/adaptcall succeeds but the subsequentget_config()insideload/1fails.Fix:
Crash cascade
Both bugs produce the same failure mode:
Caddy.Server.Externalcrashes (FunctionClauseError inhandle_infoorhandle_continue)Caddy.Supervisor(:rest_for_one) restartsCaddy.Server.ExternalCaddy.SupervisorterminatesCaddy.Applicationterminates — allCaddy.*processes are goneEnvironment
caddyhex package version:2.3.1:external,admin_url: "http://localhost:2019"