Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 25 additions & 12 deletions lib/live_debugger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)],
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/live_debugger/app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions lib/live_debugger/app/web/endpoint_starter.ex
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions lib/live_debugger/port_resolver.ex
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions test/live_debugger/port_resolver_test.exs
Original file line number Diff line number Diff line change
@@ -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
20 changes: 18 additions & 2 deletions test/live_debugger_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
defmodule LiveDebuggerTest do
@moduledoc false

use ExUnit.Case, async: false

import Mox
Expand Down Expand Up @@ -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)
Expand Down
Loading