From cef2b3041cd70eb6ac89eb64f07609a5cb0bbb3d Mon Sep 17 00:00:00 2001 From: Hubert Kasprzycki Date: Wed, 11 Mar 2026 14:59:03 +0100 Subject: [PATCH 1/4] Add :auto_port and :ignore_startup_errors configs --- docs/config.md | 32 ++++++++++++ lib/live_debugger.ex | 52 +++++++++++++++---- lib/live_debugger/app.ex | 9 +++- lib/live_debugger/app/web/endpoint_starter.ex | 31 +++++++++++ 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 lib/live_debugger/app/web/endpoint_starter.ex diff --git a/docs/config.md b/docs/config.md index ea1a6f1b2..7ec921f98 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 scan upward from the configured port until it finds a free 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..6002afbbc 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 = Application.get_env(@app_name, 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,8 @@ 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 = put_endpoint_config(config) + put_live_debugger_tags(config, resolved_port) [] |> LiveDebugger.App.append_app_children() @@ -59,11 +63,22 @@ defmodule LiveDebugger do end defp put_endpoint_config(config) do + ip = Keyword.get(config, :ip, @default_ip) + port = Keyword.get(config, :port, @default_port) + auto_port? = Keyword.get(config, :auto_port, false) + + resolved_port = + if auto_port? and is_integer(port) and port > 0 do + find_available_port(ip, port) + else + port + end + 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)], @@ -82,15 +97,32 @@ defmodule LiveDebugger do end Application.put_env(@app_name, LiveDebugger.App.Web.Endpoint, endpoint_config) + + resolved_port end - defp put_live_debugger_tags(config) do - port = Keyword.get(config, :port, @default_port) + defp find_available_port(_ip, port) when port > 65535, do: port + + defp find_available_port(ip, port) do + case :gen_tcp.listen(port, [:inet, {: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) + + {:error, _} -> + port + end + end + 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..72b808f6b 100644 --- a/lib/live_debugger/app.ex +++ b/lib/live_debugger/app.ex @@ -7,13 +7,20 @@ defmodule LiveDebugger.App do @spec append_app_children(children :: list()) :: list() def append_app_children(children) do + ignore_startup_errors? = Application.get_env(:live_debugger, :ignore_startup_errors, false) + + endpoint_module = + if ignore_startup_errors?, + do: LiveDebugger.App.Web.EndpointStarter, + else: LiveDebugger.App.Web.Endpoint + pubsub = Supervisor.child_spec({Phoenix.PubSub, name: @pubsub_name}, id: @pubsub_name) children ++ [ pubsub, - {LiveDebugger.App.Web.Endpoint, + {endpoint_module, [ 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..9205d4355 --- /dev/null +++ b/lib/live_debugger/app/web/endpoint_starter.ex @@ -0,0 +1,31 @@ +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} -> + Logger.error( + "LiveDebugger failed to start: #{inspect(reason)}. " <> + "LiveDebugger will be unavailable. " <> + "To disable LiveDebugger entirely, set `config :live_debugger, disabled?: true`." + ) + + :ignore + end + end +end From 0ba09ef43f33704073a1afe76362346d1a7b0d46 Mon Sep 17 00:00:00 2001 From: Hubert Kasprzycki Date: Wed, 11 Mar 2026 15:10:08 +0100 Subject: [PATCH 2/4] Refactor --- lib/live_debugger.ex | 4 ++-- lib/live_debugger/app.ex | 9 +-------- lib/live_debugger/app/web/endpoint_starter.ex | 16 ++++++++++------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/live_debugger.ex b/lib/live_debugger.ex index 6002afbbc..89920f6ba 100644 --- a/lib/live_debugger.ex +++ b/lib/live_debugger.ex @@ -28,7 +28,7 @@ defmodule LiveDebugger do def update_live_debugger_tags() do config = Application.get_all_env(@app_name) - endpoint_config = Application.get_env(@app_name, LiveDebugger.App.Web.Endpoint, []) + endpoint_config = Keyword.get(config, LiveDebugger.App.Web.Endpoint, []) resolved_port = get_in(endpoint_config, [:http, :port]) || Keyword.get(config, :port, @default_port) @@ -101,7 +101,7 @@ defmodule LiveDebugger do resolved_port end - defp find_available_port(_ip, port) when port > 65535, do: port + defp find_available_port(_ip, port) when port > 65_535, do: port defp find_available_port(ip, port) do case :gen_tcp.listen(port, [:inet, {:ip, ip}]) do diff --git a/lib/live_debugger/app.ex b/lib/live_debugger/app.ex index 72b808f6b..910e9de81 100644 --- a/lib/live_debugger/app.ex +++ b/lib/live_debugger/app.ex @@ -7,20 +7,13 @@ defmodule LiveDebugger.App do @spec append_app_children(children :: list()) :: list() def append_app_children(children) do - ignore_startup_errors? = Application.get_env(:live_debugger, :ignore_startup_errors, false) - - endpoint_module = - if ignore_startup_errors?, - do: LiveDebugger.App.Web.EndpointStarter, - else: LiveDebugger.App.Web.Endpoint - pubsub = Supervisor.child_spec({Phoenix.PubSub, name: @pubsub_name}, id: @pubsub_name) children ++ [ pubsub, - {endpoint_module, + {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 index 9205d4355..800ddd7e9 100644 --- a/lib/live_debugger/app/web/endpoint_starter.ex +++ b/lib/live_debugger/app/web/endpoint_starter.ex @@ -19,13 +19,17 @@ defmodule LiveDebugger.App.Web.EndpointStarter do {:ok, pid} {:error, reason} -> - Logger.error( - "LiveDebugger failed to start: #{inspect(reason)}. " <> - "LiveDebugger will be unavailable. " <> - "To disable LiveDebugger entirely, set `config :live_debugger, disabled?: true`." - ) + 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`." + ) - :ignore + :ignore + else + {:error, reason} + end end end end From f340895b34ee9cd6112506b495592059d4522893 Mon Sep 17 00:00:00 2001 From: Hubert Kasprzycki Date: Thu, 12 Mar 2026 12:03:08 +0100 Subject: [PATCH 3/4] CR suggestions --- docs/config.md | 2 +- lib/live_debugger.ex | 47 +++++++++++++------ lib/live_debugger/app/web/endpoint_starter.ex | 2 + test/live_debugger_test.exs | 19 ++++++++ 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/docs/config.md b/docs/config.md index 7ec921f98..9994339f8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -97,7 +97,7 @@ config :live_debugger, auto_port: true ``` -LiveDebugger will scan upward from the configured port until it finds a free one, logging a warning for each port that is skipped. +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}`). diff --git a/lib/live_debugger.ex b/lib/live_debugger.ex index 89920f6ba..2fb011dbd 100644 --- a/lib/live_debugger.ex +++ b/lib/live_debugger.ex @@ -15,6 +15,7 @@ defmodule LiveDebugger do @default_secret_key_base "DEFAULT_SECRET_KEY_BASE_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" @default_signing_salt "live_debugger_signing_salt" @default_drainer [shutdown: 1000] + @max_port_attempts 3 @js_path "assets/live_debugger/client.js" @css_path "assets/live_debugger/client.css" @@ -31,7 +32,10 @@ defmodule LiveDebugger do endpoint_config = Keyword.get(config, LiveDebugger.App.Web.Endpoint, []) resolved_port = - get_in(endpoint_config, [:http, :port]) || Keyword.get(config, :port, @default_port) + case get_in(endpoint_config, [:http, :port]) do + nil -> Keyword.get(config, :port, @default_port) + port -> port + end put_live_debugger_tags(config, resolved_port) end @@ -45,7 +49,8 @@ defmodule LiveDebugger do LiveDebugger.API.StatesStorage.init() config = Application.get_all_env(@app_name) - resolved_port = put_endpoint_config(config) + resolved_port = resolve_port(config) + put_endpoint_config(config, resolved_port) put_live_debugger_tags(config, resolved_port) [] @@ -62,17 +67,20 @@ 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) - resolved_port = - if auto_port? and is_integer(port) and port > 0 do - find_available_port(ip, port) - else - port - end + if auto_port? and tcp_ip?(ip) and is_integer(port) and port > 0 do + find_available_port(ip, port, @max_port_attempts) + else + port + end + end + + defp put_endpoint_config(config, resolved_port) do + ip = Keyword.get(config, :ip, @default_ip) endpoint_config = [ @@ -97,21 +105,32 @@ defmodule LiveDebugger do end Application.put_env(@app_name, LiveDebugger.App.Web.Endpoint, endpoint_config) + 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_port_attempts} attempts, " <> + "using port #{port}" + ) - resolved_port + port end - defp find_available_port(_ip, port) when port > 65_535, do: port + defp find_available_port(ip, port, attempts_left) do + inet_family = if tuple_size(ip) == 4, do: :inet, else: :inet6 - defp find_available_port(ip, port) do - case :gen_tcp.listen(port, [:inet, {:ip, ip}]) do + 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) + find_available_port(ip, port + 1, attempts_left - 1) {:error, _} -> port diff --git a/lib/live_debugger/app/web/endpoint_starter.ex b/lib/live_debugger/app/web/endpoint_starter.ex index 800ddd7e9..2d88d08e3 100644 --- a/lib/live_debugger/app/web/endpoint_starter.ex +++ b/lib/live_debugger/app/web/endpoint_starter.ex @@ -26,6 +26,8 @@ defmodule LiveDebugger.App.Web.EndpointStarter do "To disable LiveDebugger entirely, set `config :live_debugger, disabled?: true`." ) + Application.put_env(:live_debugger, :live_debugger_tags, []) + :ignore else {:error, reason} diff --git a/test/live_debugger_test.exs b/test/live_debugger_test.exs index a9b9a5e86..ec5fd0861 100644 --- a/test/live_debugger_test.exs +++ b/test/live_debugger_test.exs @@ -11,6 +11,7 @@ defmodule LiveDebuggerTest do on_exit(fn -> Application.delete_env(:live_debugger, :ip) Application.delete_env(:live_debugger, :port) + Application.delete_env(:live_debugger, :auto_port) Application.delete_env(:live_debugger, :external_url) Application.delete_env(:live_debugger, :live_debugger_tags) end) @@ -41,6 +42,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) From 44e1264655311d1a75655b54e2b37e0d0f2fada9 Mon Sep 17 00:00:00 2001 From: Hubert Kasprzycki Date: Thu, 12 Mar 2026 16:09:31 +0100 Subject: [PATCH 4/4] CR suggestions --- lib/live_debugger.ex | 42 +----------------- lib/live_debugger/port_resolver.ex | 52 +++++++++++++++++++++++ test/live_debugger/port_resolver_test.exs | 50 ++++++++++++++++++++++ test/live_debugger_test.exs | 3 -- 4 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 lib/live_debugger/port_resolver.ex create mode 100644 test/live_debugger/port_resolver_test.exs diff --git a/lib/live_debugger.ex b/lib/live_debugger.ex index 2fb011dbd..781737a91 100644 --- a/lib/live_debugger.ex +++ b/lib/live_debugger.ex @@ -15,7 +15,6 @@ defmodule LiveDebugger do @default_secret_key_base "DEFAULT_SECRET_KEY_BASE_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" @default_signing_salt "live_debugger_signing_salt" @default_drainer [shutdown: 1000] - @max_port_attempts 3 @js_path "assets/live_debugger/client.js" @css_path "assets/live_debugger/client.css" @@ -32,10 +31,7 @@ defmodule LiveDebugger do endpoint_config = Keyword.get(config, LiveDebugger.App.Web.Endpoint, []) resolved_port = - case get_in(endpoint_config, [:http, :port]) do - nil -> Keyword.get(config, :port, @default_port) - port -> port - end + get_in(endpoint_config, [:http, :port]) || Keyword.get(config, :port, @default_port) put_live_debugger_tags(config, resolved_port) end @@ -72,11 +68,7 @@ defmodule LiveDebugger do port = Keyword.get(config, :port, @default_port) auto_port? = Keyword.get(config, :auto_port, false) - if auto_port? and tcp_ip?(ip) and is_integer(port) and port > 0 do - find_available_port(ip, port, @max_port_attempts) - else - port - end + LiveDebugger.PortResolver.resolve(ip, port, auto_port?) end defp put_endpoint_config(config, resolved_port) do @@ -107,36 +99,6 @@ defmodule LiveDebugger do Application.put_env(@app_name, LiveDebugger.App.Web.Endpoint, endpoint_config) 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_port_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 - defp put_live_debugger_tags(config, resolved_port) do default_url = case Keyword.get(config, :ip, @default_ip) do 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 ec5fd0861..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 @@ -11,7 +9,6 @@ defmodule LiveDebuggerTest do on_exit(fn -> Application.delete_env(:live_debugger, :ip) Application.delete_env(:live_debugger, :port) - Application.delete_env(:live_debugger, :auto_port) Application.delete_env(:live_debugger, :external_url) Application.delete_env(:live_debugger, :live_debugger_tags) end)