diff --git a/docs/config.md b/docs/config.md index ea1a6f1b2..9994339f8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -81,6 +81,38 @@ config :live_debugger, external_url: "http://your_external_url" ``` +## Port Conflict Handling + +LiveDebugger provides two options for dealing with port conflicts (e.g. when running multiple application instances). + +### Auto-select next available port + +Set `auto_port: true` to make LiveDebugger automatically find the next free port if the configured port is already in use: + +```elixir +# config/dev.exs + +config :live_debugger, + port: 4007, + auto_port: true +``` + +LiveDebugger will try up to 3 consecutive ports starting from the configured one, logging a warning for each port that is skipped. + +Note: `auto_port` is ignored when using a Unix socket (`ip: {:local, path}`). + +### Ignore startup errors + +Set `ignore_startup_errors: true` to allow the host application to continue running even if LiveDebugger fails to start (e.g. due to a port conflict). LiveDebugger will be unavailable, but your application will not crash: + +```elixir +# config/dev.exs + +config :live_debugger, :ignore_startup_errors, true +``` + +An error will be logged when startup fails. Both options can be combined: `auto_port` is attempted first, and if the endpoint still fails to start, `ignore_startup_errors` prevents the crash. + ## Other Settings ```elixir diff --git a/lib/live_debugger.ex b/lib/live_debugger.ex index 43f2e2000..781737a91 100644 --- a/lib/live_debugger.ex +++ b/lib/live_debugger.ex @@ -27,9 +27,13 @@ defmodule LiveDebugger do end def update_live_debugger_tags() do - @app_name - |> Application.get_all_env() - |> put_live_debugger_tags() + config = Application.get_all_env(@app_name) + endpoint_config = Keyword.get(config, LiveDebugger.App.Web.Endpoint, []) + + resolved_port = + get_in(endpoint_config, [:http, :port]) || Keyword.get(config, :port, @default_port) + + put_live_debugger_tags(config, resolved_port) end defp get_children() do @@ -41,8 +45,9 @@ defmodule LiveDebugger do LiveDebugger.API.StatesStorage.init() config = Application.get_all_env(@app_name) - put_endpoint_config(config) - put_live_debugger_tags(config) + resolved_port = resolve_port(config) + put_endpoint_config(config, resolved_port) + put_live_debugger_tags(config, resolved_port) [] |> LiveDebugger.App.append_app_children() @@ -58,12 +63,22 @@ defmodule LiveDebugger do end end - defp put_endpoint_config(config) do + defp resolve_port(config) do + ip = Keyword.get(config, :ip, @default_ip) + port = Keyword.get(config, :port, @default_port) + auto_port? = Keyword.get(config, :auto_port, false) + + LiveDebugger.PortResolver.resolve(ip, port, auto_port?) + end + + defp put_endpoint_config(config, resolved_port) do + ip = Keyword.get(config, :ip, @default_ip) + endpoint_config = [ http: [ - ip: Keyword.get(config, :ip, @default_ip), - port: Keyword.get(config, :port, @default_port) + ip: ip, + port: resolved_port ], secret_key_base: Keyword.get(config, :secret_key_base, @default_secret_key_base), live_view: [signing_salt: Keyword.get(config, :signing_salt, @default_signing_salt)], @@ -84,13 +99,11 @@ defmodule LiveDebugger do Application.put_env(@app_name, LiveDebugger.App.Web.Endpoint, endpoint_config) end - defp put_live_debugger_tags(config) do - port = Keyword.get(config, :port, @default_port) - + defp put_live_debugger_tags(config, resolved_port) do default_url = case Keyword.get(config, :ip, @default_ip) do {:local, _path} -> nil - ip_tuple -> "http://#{ip_tuple |> :inet.ntoa() |> List.to_string()}:#{port}" + ip_tuple -> "http://#{ip_tuple |> :inet.ntoa() |> List.to_string()}:#{resolved_port}" end live_debugger_url = Keyword.get(config, :external_url, default_url) diff --git a/lib/live_debugger/app.ex b/lib/live_debugger/app.ex index c95aafc23..910e9de81 100644 --- a/lib/live_debugger/app.ex +++ b/lib/live_debugger/app.ex @@ -13,7 +13,7 @@ defmodule LiveDebugger.App do children ++ [ pubsub, - {LiveDebugger.App.Web.Endpoint, + {LiveDebugger.App.Web.EndpointStarter, [ check_origin: false, pubsub_server: @pubsub_name diff --git a/lib/live_debugger/app/web/endpoint_starter.ex b/lib/live_debugger/app/web/endpoint_starter.ex new file mode 100644 index 000000000..2d88d08e3 --- /dev/null +++ b/lib/live_debugger/app/web/endpoint_starter.ex @@ -0,0 +1,37 @@ +defmodule LiveDebugger.App.Web.EndpointStarter do + @moduledoc """ + Wrapper around `LiveDebugger.App.Web.Endpoint` that handles startup failures gracefully. + + When `config :live_debugger, ignore_startup_errors: true` is set, a port conflict or + other startup error will log an error and allow the host application to continue running + without LiveDebugger, instead of crashing the whole application. + """ + + require Logger + + def child_spec(opts) do + %{LiveDebugger.App.Web.Endpoint.child_spec(opts) | start: {__MODULE__, :start_link, [opts]}} + end + + def start_link(opts) do + case LiveDebugger.App.Web.Endpoint.start_link(opts) do + {:ok, pid} -> + {:ok, pid} + + {:error, reason} -> + if Application.get_env(:live_debugger, :ignore_startup_errors, false) do + Logger.error( + "LiveDebugger failed to start: #{inspect(reason)}. " <> + "LiveDebugger will be unavailable. " <> + "To disable LiveDebugger entirely, set `config :live_debugger, disabled?: true`." + ) + + Application.put_env(:live_debugger, :live_debugger_tags, []) + + :ignore + else + {:error, reason} + end + end + end +end diff --git a/lib/live_debugger/port_resolver.ex b/lib/live_debugger/port_resolver.ex new file mode 100644 index 000000000..0e9b251f9 --- /dev/null +++ b/lib/live_debugger/port_resolver.ex @@ -0,0 +1,52 @@ +defmodule LiveDebugger.PortResolver do + @moduledoc """ + Resolves the port for the LiveDebugger endpoint. + + When `auto_port: true` is configured, scans upward from the configured port + to find an available one (up to `@max_attempts` tries). Skipped for non-TCP + IP configurations (e.g. Unix sockets). + """ + + require Logger + + @max_attempts 3 + + @spec resolve(ip :: term(), port :: term(), auto_port? :: boolean()) :: term() + def resolve(ip, port, auto_port?) do + if auto_port? and tcp_ip?(ip) and is_integer(port) and port > 0 do + find_available_port(ip, port, @max_attempts) + else + port + end + end + + defp tcp_ip?({_, _, _, _}), do: true + defp tcp_ip?({_, _, _, _, _, _, _, _}), do: true + defp tcp_ip?(_), do: false + + defp find_available_port(_ip, port, 0) do + Logger.warning( + "LiveDebugger: could not find an available port after #{@max_attempts} attempts, " <> + "using port #{port}" + ) + + port + end + + defp find_available_port(ip, port, attempts_left) do + inet_family = if tuple_size(ip) == 4, do: :inet, else: :inet6 + + case :gen_tcp.listen(port, [inet_family, {:ip, ip}]) do + {:ok, socket} -> + :gen_tcp.close(socket) + port + + {:error, :eaddrinuse} -> + Logger.warning("LiveDebugger: port #{port} is already in use, trying #{port + 1}") + find_available_port(ip, port + 1, attempts_left - 1) + + {:error, _} -> + port + end + end +end diff --git a/test/live_debugger/port_resolver_test.exs b/test/live_debugger/port_resolver_test.exs new file mode 100644 index 000000000..1036b9a95 --- /dev/null +++ b/test/live_debugger/port_resolver_test.exs @@ -0,0 +1,50 @@ +defmodule LiveDebugger.PortResolverTest do + use ExUnit.Case, async: false + + alias LiveDebugger.PortResolver + + @ip {127, 0, 0, 1} + + describe "resolve/3" do + test "returns configured port when auto_port is false" do + assert PortResolver.resolve(@ip, 4007, false) == 4007 + end + + test "returns same port when auto_port is true and port is free" do + assert PortResolver.resolve(@ip, 39_871, true) == 39_871 + end + + test "finds next available port when configured port is occupied" do + {:ok, socket} = :gen_tcp.listen(39_872, [:inet, {:ip, @ip}, {:reuseaddr, true}]) + + resolved = PortResolver.resolve(@ip, 39_872, true) + + :gen_tcp.close(socket) + + assert resolved == 39_873 + end + + test "stops after max attempts and returns the next port" do + sockets = + for port <- 39_874..39_876 do + {:ok, socket} = :gen_tcp.listen(port, [:inet, {:ip, @ip}, {:reuseaddr, true}]) + socket + end + + resolved = PortResolver.resolve(@ip, 39_874, true) + + Enum.each(sockets, &:gen_tcp.close/1) + + # After 3 failed attempts (39874, 39875, 39876), returns 39877 + assert resolved == 39_877 + end + + test "skips auto_port for Unix socket IP" do + assert PortResolver.resolve({:local, "/tmp/test.sock"}, 4007, true) == 4007 + end + + test "skips auto_port when port is not a positive integer" do + assert PortResolver.resolve(@ip, 0, true) == 0 + end + end +end diff --git a/test/live_debugger_test.exs b/test/live_debugger_test.exs index a9b9a5e86..3652a6761 100644 --- a/test/live_debugger_test.exs +++ b/test/live_debugger_test.exs @@ -1,6 +1,4 @@ defmodule LiveDebuggerTest do - @moduledoc false - use ExUnit.Case, async: false import Mox @@ -41,6 +39,24 @@ defmodule LiveDebuggerTest do assert tags == [] end + test "uses resolved port from endpoint config" do + Application.put_env(:live_debugger, :ip, {127, 0, 0, 1}) + Application.put_env(:live_debugger, :port, 4007) + Application.put_env(:live_debugger, LiveDebugger.App.Web.Endpoint, http: [port: 4009]) + + LiveDebugger.MockAPISettingsStorage + |> expect(:get, fn :debug_button -> true end) + + LiveDebugger.update_live_debugger_tags() + + tags = Application.get_env(:live_debugger, :live_debugger_tags) + rendered = tags |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() + assert rendered =~ "http://127.0.0.1:4009" + refute rendered =~ "http://127.0.0.1:4007" + + Application.delete_env(:live_debugger, LiveDebugger.App.Web.Endpoint) + end + test "generates tags when Unix socket IP is used with external_url" do Application.put_env(:live_debugger, :ip, {:local, "/tmp/live_debugger.sock"}) Application.put_env(:live_debugger, :port, 0)