Skip to content

Commit 2ba4856

Browse files
himprove port handling
This commit tries to improve two issues 1) A free port is obtained by setting the port value as zero and the OS will bind to a free port. We immediately close the port and then later create another socket on the same port. The issue with the approach is, OS could allocate the same port to another because we have closed the port. This leads to a situation where more than one bypass server could listen on the same port (this is possible because of SO_REUSEPORT flag). The issue is fixed by not closing the socket. 2) Bypass exposes a down api, which closes the socket. The issue here is the same as above, the OS is free to allocate the port to others. The current solution tries to fix the issue by keeping track of which test owns which ports and try not to reuse the same ports. This is still not foolproof, there is a small interval during which the socket is active, but better than the old logic.
1 parent 6436504 commit 2ba4856

File tree

4 files changed

+128
-54
lines changed

4 files changed

+128
-54
lines changed

lib/bypass/application.ex

+6-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ defmodule Bypass.Application do
44
use Application
55

66
def start(_type, _args) do
7-
opts = [strategy: :one_for_one, name: Bypass.Supervisor]
8-
DynamicSupervisor.start_link(opts)
7+
children = [
8+
Bypass.FreePort,
9+
{DynamicSupervisor, strategy: :one_for_one, name: Bypass.Supervisor}
10+
]
11+
12+
Supervisor.start_link(children, strategy: :one_for_one)
913
end
1014
end

lib/bypass/free_port.ex

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule Bypass.FreePort do
2+
alias Bypass.Utils
3+
use GenServer
4+
5+
defstruct [:ports, :owners]
6+
7+
def start_link([]) do
8+
GenServer.start_link(__MODULE__, [], name: __MODULE__)
9+
end
10+
11+
def reserve(owner) do
12+
GenServer.call(__MODULE__, {:reserve, owner})
13+
end
14+
15+
def init([]) do
16+
{:ok, %__MODULE__{ports: MapSet.new(), owners: %{}}}
17+
end
18+
19+
def handle_call({:reserve, owner}, _from, state) do
20+
ref = Process.monitor(owner)
21+
{state, reply} = find_free_port(state, owner, ref, 0)
22+
{:reply, reply, state}
23+
end
24+
25+
def handle_info({:DOWN, ref, _type, pid, _reason}, state) do
26+
{port, owners} = Map.pop(state.owners, {pid, ref})
27+
{:noreply, %{state | ports: MapSet.delete(state.ports, port), owners: owners}}
28+
end
29+
30+
def handle_info(_msg, state) do
31+
{:noreply, state}
32+
end
33+
34+
defp find_free_port(state, _owner, _ref, 10 = _attempt),
35+
do: {state, {:error, :too_many_attempts}}
36+
37+
defp find_free_port(state, owner, ref, attempt) do
38+
case :ranch_tcp.listen(Utils.so_reuseport() ++ [ip: Utils.listen_ip(), port: 0]) do
39+
{:ok, socket} ->
40+
{:ok, port} = :inet.port(socket)
41+
42+
if MapSet.member?(state.ports, port) do
43+
true = :erlang.port_close(socket)
44+
45+
find_free_port(state, owner, ref, attempt + 1)
46+
else
47+
state = %{
48+
state
49+
| ports: MapSet.put(state.ports, port),
50+
owners: Map.put_new(state.owners, {owner, ref}, port)
51+
}
52+
53+
{state, {:ok, socket}}
54+
end
55+
56+
{:error, reason} ->
57+
{state, {:error, reason}}
58+
end
59+
end
60+
end

lib/bypass/instance.ex

+20-52
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,19 @@ defmodule Bypass.Instance do
2424
# GenServer callbacks
2525

2626
def init([opts]) do
27-
# Get a free port from the OS
28-
case :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: Keyword.get(opts, :port, 0)]) do
29-
{:ok, socket} ->
30-
{:ok, port} = :inet.port(socket)
31-
:erlang.port_close(socket)
27+
result =
28+
case Keyword.get(opts, :port) do
29+
nil ->
30+
Bypass.FreePort.reserve(self())
31+
32+
port ->
33+
{:ok, port}
34+
end
3235

36+
case result do
37+
{:ok, port_or_socket} ->
3338
ref = make_ref()
34-
socket = do_up(port, ref)
39+
{:ok, port, socket} = do_up(port_or_socket, ref)
3540

3641
state = %{
3742
expectations: %{},
@@ -77,7 +82,7 @@ defmodule Bypass.Instance do
7782
end
7883

7984
defp do_handle_call(:up, _from, %{port: port, ref: ref, socket: nil} = state) do
80-
socket = do_up(port, ref)
85+
{:ok, _port, socket} = do_up(port, ref)
8186
{:reply, :ok, %{state | socket: socket}}
8287
end
8388

@@ -317,12 +322,17 @@ defmodule Bypass.Instance do
317322

318323
defp match_route(_, _), do: {false, nil}
319324

320-
defp do_up(port, ref) do
321-
plug_opts = [self()]
325+
defp do_up(port, ref) when is_integer(port) do
322326
{:ok, socket} = :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: port])
327+
do_up(socket, ref)
328+
end
329+
330+
defp do_up(socket, ref) do
331+
plug_opts = [self()]
332+
{:ok, port} = :inet.port(socket)
323333
cowboy_opts = cowboy_opts(port, ref, socket)
324334
{:ok, _pid} = Plug.Cowboy.http(Bypass.Plug, plug_opts, cowboy_opts)
325-
socket
335+
{:ok, port, socket}
326336
end
327337

328338
defp do_down(ref, socket) do
@@ -420,46 +430,4 @@ defmodule Bypass.Instance do
420430
defp cowboy_opts(port, ref, socket) do
421431
[ref: ref, port: port, transport_options: [num_acceptors: 5, socket: socket]]
422432
end
423-
424-
# Use raw socket options to set SO_REUSEPORT so we fix {:error, :eaddrinuse} - where the OS errors
425-
# when we attempt to listen on the same port as before, since it's still considered in use.
426-
#
427-
# See https://lwn.net/Articles/542629/ for details on SO_REUSEPORT.
428-
#
429-
# See https://github.com/aetrion/erl-dns/blob/0c8d768/src/erldns_server_sup.erl#L81 for an
430-
# Erlang library using this approach.
431-
#
432-
# We want to do this:
433-
#
434-
# int optval = 1;
435-
# setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
436-
#
437-
# Use the following C program to find the values on each OS:
438-
#
439-
# #include <stdio.h>
440-
# #include <sys/socket.h>
441-
#
442-
# int main() {
443-
# printf("SOL_SOCKET: %d\n", SOL_SOCKET);
444-
# printf("SO_REUSEPORT: %d\n", SO_REUSEPORT);
445-
# return 0;
446-
# }
447-
defp so_reuseport() do
448-
case :os.type() do
449-
{:unix, :linux} -> [{:raw, 1, 15, <<1::32-native>>}]
450-
{:unix, :darwin} -> [{:raw, 65_535, 512, <<1::32-native>>}]
451-
_ -> []
452-
end
453-
end
454-
455-
# This is used to override the default behaviour of ranch_tcp
456-
# and limit the range of interfaces it will listen on to just
457-
# the configured interface. Loopback is a default interface.
458-
defp listen_ip do
459-
Application.get_env(:bypass, :listen_ip, "127.0.0.1")
460-
|> String.split(".")
461-
|> Enum.map(&Integer.parse/1)
462-
|> Enum.map(&elem(&1, 0))
463-
|> List.to_tuple()
464-
end
465433
end

lib/bypass/utils.ex

+42
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,46 @@ defmodule Bypass.Utils do
1515
:ok
1616
end
1717
end
18+
19+
# This is used to override the default behaviour of ranch_tcp
20+
# and limit the range of interfaces it will listen on to just
21+
# the configured interface. Loopback is a default interface.
22+
def listen_ip do
23+
Application.get_env(:bypass, :listen_ip, "127.0.0.1")
24+
|> String.split(".")
25+
|> Enum.map(&Integer.parse/1)
26+
|> Enum.map(&elem(&1, 0))
27+
|> List.to_tuple()
28+
end
29+
30+
# Use raw socket options to set SO_REUSEPORT so we fix {:error, :eaddrinuse} - where the OS errors
31+
# when we attempt to listen on the same port as before, since it's still considered in use.
32+
#
33+
# See https://lwn.net/Articles/542629/ for details on SO_REUSEPORT.
34+
#
35+
# See https://github.com/aetrion/erl-dns/blob/0c8d768/src/erldns_server_sup.erl#L81 for an
36+
# Erlang library using this approach.
37+
#
38+
# We want to do this:
39+
#
40+
# int optval = 1;
41+
# setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
42+
#
43+
# Use the following C program to find the values on each OS:
44+
#
45+
# #include <stdio.h>
46+
# #include <sys/socket.h>
47+
#
48+
# int main() {
49+
# printf("SOL_SOCKET: %d\n", SOL_SOCKET);
50+
# printf("SO_REUSEPORT: %d\n", SO_REUSEPORT);
51+
# return 0;
52+
# }
53+
def so_reuseport() do
54+
case :os.type() do
55+
{:unix, :linux} -> [{:raw, 1, 15, <<1::32-native>>}]
56+
{:unix, :darwin} -> [{:raw, 65_535, 512, <<1::32-native>>}]
57+
_ -> []
58+
end
59+
end
1860
end

0 commit comments

Comments
 (0)