Skip to content

Commit ef22ec4

Browse files
authored
Feature: Add :auto_port and :ignore_startup_errors configs (#950)
* Add :auto_port and :ignore_startup_errors configs * Refactor * CR suggestions
1 parent c3a233f commit ef22ec4

7 files changed

Lines changed: 215 additions & 15 deletions

File tree

docs/config.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@ config :live_debugger,
8181
external_url: "http://your_external_url"
8282
```
8383

84+
## Port Conflict Handling
85+
86+
LiveDebugger provides two options for dealing with port conflicts (e.g. when running multiple application instances).
87+
88+
### Auto-select next available port
89+
90+
Set `auto_port: true` to make LiveDebugger automatically find the next free port if the configured port is already in use:
91+
92+
```elixir
93+
# config/dev.exs
94+
95+
config :live_debugger,
96+
port: 4007,
97+
auto_port: true
98+
```
99+
100+
LiveDebugger will try up to 3 consecutive ports starting from the configured one, logging a warning for each port that is skipped.
101+
102+
Note: `auto_port` is ignored when using a Unix socket (`ip: {:local, path}`).
103+
104+
### Ignore startup errors
105+
106+
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:
107+
108+
```elixir
109+
# config/dev.exs
110+
111+
config :live_debugger, :ignore_startup_errors, true
112+
```
113+
114+
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.
115+
84116
## Other Settings
85117

86118
```elixir

lib/live_debugger.ex

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ defmodule LiveDebugger do
2727
end
2828

2929
def update_live_debugger_tags() do
30-
@app_name
31-
|> Application.get_all_env()
32-
|> put_live_debugger_tags()
30+
config = Application.get_all_env(@app_name)
31+
endpoint_config = Keyword.get(config, LiveDebugger.App.Web.Endpoint, [])
32+
33+
resolved_port =
34+
get_in(endpoint_config, [:http, :port]) || Keyword.get(config, :port, @default_port)
35+
36+
put_live_debugger_tags(config, resolved_port)
3337
end
3438

3539
defp get_children() do
@@ -41,8 +45,9 @@ defmodule LiveDebugger do
4145
LiveDebugger.API.StatesStorage.init()
4246

4347
config = Application.get_all_env(@app_name)
44-
put_endpoint_config(config)
45-
put_live_debugger_tags(config)
48+
resolved_port = resolve_port(config)
49+
put_endpoint_config(config, resolved_port)
50+
put_live_debugger_tags(config, resolved_port)
4651

4752
[]
4853
|> LiveDebugger.App.append_app_children()
@@ -58,12 +63,22 @@ defmodule LiveDebugger do
5863
end
5964
end
6065

61-
defp put_endpoint_config(config) do
66+
defp resolve_port(config) do
67+
ip = Keyword.get(config, :ip, @default_ip)
68+
port = Keyword.get(config, :port, @default_port)
69+
auto_port? = Keyword.get(config, :auto_port, false)
70+
71+
LiveDebugger.PortResolver.resolve(ip, port, auto_port?)
72+
end
73+
74+
defp put_endpoint_config(config, resolved_port) do
75+
ip = Keyword.get(config, :ip, @default_ip)
76+
6277
endpoint_config =
6378
[
6479
http: [
65-
ip: Keyword.get(config, :ip, @default_ip),
66-
port: Keyword.get(config, :port, @default_port)
80+
ip: ip,
81+
port: resolved_port
6782
],
6883
secret_key_base: Keyword.get(config, :secret_key_base, @default_secret_key_base),
6984
live_view: [signing_salt: Keyword.get(config, :signing_salt, @default_signing_salt)],
@@ -84,13 +99,11 @@ defmodule LiveDebugger do
8499
Application.put_env(@app_name, LiveDebugger.App.Web.Endpoint, endpoint_config)
85100
end
86101

87-
defp put_live_debugger_tags(config) do
88-
port = Keyword.get(config, :port, @default_port)
89-
102+
defp put_live_debugger_tags(config, resolved_port) do
90103
default_url =
91104
case Keyword.get(config, :ip, @default_ip) do
92105
{:local, _path} -> nil
93-
ip_tuple -> "http://#{ip_tuple |> :inet.ntoa() |> List.to_string()}:#{port}"
106+
ip_tuple -> "http://#{ip_tuple |> :inet.ntoa() |> List.to_string()}:#{resolved_port}"
94107
end
95108

96109
live_debugger_url = Keyword.get(config, :external_url, default_url)

lib/live_debugger/app.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule LiveDebugger.App do
1313
children ++
1414
[
1515
pubsub,
16-
{LiveDebugger.App.Web.Endpoint,
16+
{LiveDebugger.App.Web.EndpointStarter,
1717
[
1818
check_origin: false,
1919
pubsub_server: @pubsub_name
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule LiveDebugger.App.Web.EndpointStarter do
2+
@moduledoc """
3+
Wrapper around `LiveDebugger.App.Web.Endpoint` that handles startup failures gracefully.
4+
5+
When `config :live_debugger, ignore_startup_errors: true` is set, a port conflict or
6+
other startup error will log an error and allow the host application to continue running
7+
without LiveDebugger, instead of crashing the whole application.
8+
"""
9+
10+
require Logger
11+
12+
def child_spec(opts) do
13+
%{LiveDebugger.App.Web.Endpoint.child_spec(opts) | start: {__MODULE__, :start_link, [opts]}}
14+
end
15+
16+
def start_link(opts) do
17+
case LiveDebugger.App.Web.Endpoint.start_link(opts) do
18+
{:ok, pid} ->
19+
{:ok, pid}
20+
21+
{:error, reason} ->
22+
if Application.get_env(:live_debugger, :ignore_startup_errors, false) do
23+
Logger.error(
24+
"LiveDebugger failed to start: #{inspect(reason)}. " <>
25+
"LiveDebugger will be unavailable. " <>
26+
"To disable LiveDebugger entirely, set `config :live_debugger, disabled?: true`."
27+
)
28+
29+
Application.put_env(:live_debugger, :live_debugger_tags, [])
30+
31+
:ignore
32+
else
33+
{:error, reason}
34+
end
35+
end
36+
end
37+
end

lib/live_debugger/port_resolver.ex

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule LiveDebugger.PortResolver do
2+
@moduledoc """
3+
Resolves the port for the LiveDebugger endpoint.
4+
5+
When `auto_port: true` is configured, scans upward from the configured port
6+
to find an available one (up to `@max_attempts` tries). Skipped for non-TCP
7+
IP configurations (e.g. Unix sockets).
8+
"""
9+
10+
require Logger
11+
12+
@max_attempts 3
13+
14+
@spec resolve(ip :: term(), port :: term(), auto_port? :: boolean()) :: term()
15+
def resolve(ip, port, auto_port?) do
16+
if auto_port? and tcp_ip?(ip) and is_integer(port) and port > 0 do
17+
find_available_port(ip, port, @max_attempts)
18+
else
19+
port
20+
end
21+
end
22+
23+
defp tcp_ip?({_, _, _, _}), do: true
24+
defp tcp_ip?({_, _, _, _, _, _, _, _}), do: true
25+
defp tcp_ip?(_), do: false
26+
27+
defp find_available_port(_ip, port, 0) do
28+
Logger.warning(
29+
"LiveDebugger: could not find an available port after #{@max_attempts} attempts, " <>
30+
"using port #{port}"
31+
)
32+
33+
port
34+
end
35+
36+
defp find_available_port(ip, port, attempts_left) do
37+
inet_family = if tuple_size(ip) == 4, do: :inet, else: :inet6
38+
39+
case :gen_tcp.listen(port, [inet_family, {:ip, ip}]) do
40+
{:ok, socket} ->
41+
:gen_tcp.close(socket)
42+
port
43+
44+
{:error, :eaddrinuse} ->
45+
Logger.warning("LiveDebugger: port #{port} is already in use, trying #{port + 1}")
46+
find_available_port(ip, port + 1, attempts_left - 1)
47+
48+
{:error, _} ->
49+
port
50+
end
51+
end
52+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule LiveDebugger.PortResolverTest do
2+
use ExUnit.Case, async: false
3+
4+
alias LiveDebugger.PortResolver
5+
6+
@ip {127, 0, 0, 1}
7+
8+
describe "resolve/3" do
9+
test "returns configured port when auto_port is false" do
10+
assert PortResolver.resolve(@ip, 4007, false) == 4007
11+
end
12+
13+
test "returns same port when auto_port is true and port is free" do
14+
assert PortResolver.resolve(@ip, 39_871, true) == 39_871
15+
end
16+
17+
test "finds next available port when configured port is occupied" do
18+
{:ok, socket} = :gen_tcp.listen(39_872, [:inet, {:ip, @ip}, {:reuseaddr, true}])
19+
20+
resolved = PortResolver.resolve(@ip, 39_872, true)
21+
22+
:gen_tcp.close(socket)
23+
24+
assert resolved == 39_873
25+
end
26+
27+
test "stops after max attempts and returns the next port" do
28+
sockets =
29+
for port <- 39_874..39_876 do
30+
{:ok, socket} = :gen_tcp.listen(port, [:inet, {:ip, @ip}, {:reuseaddr, true}])
31+
socket
32+
end
33+
34+
resolved = PortResolver.resolve(@ip, 39_874, true)
35+
36+
Enum.each(sockets, &:gen_tcp.close/1)
37+
38+
# After 3 failed attempts (39874, 39875, 39876), returns 39877
39+
assert resolved == 39_877
40+
end
41+
42+
test "skips auto_port for Unix socket IP" do
43+
assert PortResolver.resolve({:local, "/tmp/test.sock"}, 4007, true) == 4007
44+
end
45+
46+
test "skips auto_port when port is not a positive integer" do
47+
assert PortResolver.resolve(@ip, 0, true) == 0
48+
end
49+
end
50+
end

test/live_debugger_test.exs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
defmodule LiveDebuggerTest do
2-
@moduledoc false
3-
42
use ExUnit.Case, async: false
53

64
import Mox
@@ -41,6 +39,24 @@ defmodule LiveDebuggerTest do
4139
assert tags == []
4240
end
4341

42+
test "uses resolved port from endpoint config" do
43+
Application.put_env(:live_debugger, :ip, {127, 0, 0, 1})
44+
Application.put_env(:live_debugger, :port, 4007)
45+
Application.put_env(:live_debugger, LiveDebugger.App.Web.Endpoint, http: [port: 4009])
46+
47+
LiveDebugger.MockAPISettingsStorage
48+
|> expect(:get, fn :debug_button -> true end)
49+
50+
LiveDebugger.update_live_debugger_tags()
51+
52+
tags = Application.get_env(:live_debugger, :live_debugger_tags)
53+
rendered = tags |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary()
54+
assert rendered =~ "http://127.0.0.1:4009"
55+
refute rendered =~ "http://127.0.0.1:4007"
56+
57+
Application.delete_env(:live_debugger, LiveDebugger.App.Web.Endpoint)
58+
end
59+
4460
test "generates tags when Unix socket IP is used with external_url" do
4561
Application.put_env(:live_debugger, :ip, {:local, "/tmp/live_debugger.sock"})
4662
Application.put_env(:live_debugger, :port, 0)

0 commit comments

Comments
 (0)