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
11 changes: 10 additions & 1 deletion lib/minch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Minch do
@type state :: term()
@type response :: %{status: Mint.Types.status(), headers: Mint.Types.headers()}
@type frame :: Mint.WebSocket.frame() | Mint.WebSocket.shorthand_frame()
@type option :: {:close_timeout, non_neg_integer()} | GenServer.option()

@type callback_result ::
{:ok, state()}
Expand Down Expand Up @@ -44,6 +45,9 @@ defmodule Minch do
@doc """
Invoked to handle a disconnect from the server or a failed connection attempt.

The reason is the `{:close, code, reason}` frame whenever a close handshake was started by
either side, and a `t:Mint.WebSocket.error/0` otherwise.

Returning `{:reconnect, backoff, state}` will schedule a reconnect after `backoff` milliseconds.
"""
@callback handle_disconnect(reason :: term(), attempt :: pos_integer(), state()) ::
Expand All @@ -68,8 +72,13 @@ defmodule Minch do

@doc """
Starts a `Minch` client process linked to the current process.

Accepts `GenServer` options and:

* `:close_timeout` - how long to wait, in milliseconds, for the server to close the
connection after a close handshake has started. Defaults to `5000`.
"""
@spec start_link(module(), term(), GenServer.options()) :: GenServer.on_start()
@spec start_link(module(), term(), [option()]) :: GenServer.on_start()
def start_link(module, init_arg, opts \\ []) do
Minch.Conn.start_link(module, init_arg, opts)
end
Expand Down
42 changes: 30 additions & 12 deletions lib/minch/conn.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,24 @@ defmodule Minch.Conn do
:callback,
:callback_state,
:reconnect_timer,
:close_timer
:close_timer,
:close_frame,
:close_timeout
]

@options [:close_timeout]
@internal :"$minch"

@spec start_link(module(), term(), GenServer.options()) :: GenServer.on_start()
@spec start_link(module(), term(), [Minch.option()]) :: GenServer.on_start()
def start_link(module, init_arg, opts \\ []) do
GenServer.start_link(__MODULE__, {module, init_arg}, opts)
{opts, gen_opts} = Keyword.split(opts, @options)
GenServer.start_link(__MODULE__, {module, init_arg, opts}, gen_opts)
end

@spec start(module(), term(), GenServer.options()) :: GenServer.on_start()
@spec start(module(), term(), [Minch.option()]) :: GenServer.on_start()
def start(module, init_arg, opts \\ []) do
GenServer.start(__MODULE__, {module, init_arg}, opts)
{opts, gen_opts} = Keyword.split(opts, @options)
GenServer.start(__MODULE__, {module, init_arg, opts}, gen_opts)
end

@spec stop(GenServer.server()) :: :ok
Expand All @@ -34,10 +39,16 @@ defmodule Minch.Conn do
end

@impl true
def init({callback, init_arg}) do
def init({callback, init_arg, opts}) do
case callback.init(init_arg) do
{:ok, callback_state} ->
state = %State{callback: callback, callback_state: callback_state, conn_attempt: 0}
state = %State{
callback: callback,
callback_state: callback_state,
close_timeout: Keyword.get(opts, :close_timeout, 5000),
conn_attempt: 0
}

Process.flag(:trap_exit, true)
{:ok, state, {:continue, :connect}}

Expand Down Expand Up @@ -97,8 +108,12 @@ defmodule Minch.Conn do
{:noreply, %{state | reconnect_timer: nil}, {:continue, :connect}}
end

def handle_info({@internal, {:close_timeout, reason}}, state) do
handle_disconnect(reason, state)
def handle_info({@internal, :close_timeout}, %State{close_timer: nil} = state) do
{:noreply, state}
end

def handle_info({@internal, :close_timeout}, state) do
handle_disconnect(state.close_frame, state)
end

def handle_info(message, %State{conn: nil} = state) do
Expand Down Expand Up @@ -179,9 +194,10 @@ defmodule Minch.Conn do
end

defp handle_disconnect(error, %State{} = state) do
reason = state.close_frame || error
state = close(%{state | conn_attempt: state.conn_attempt + 1})

case state.callback.handle_disconnect(error, state.conn_attempt, state.callback_state) do
case state.callback.handle_disconnect(reason, state.conn_attempt, state.callback_state) do
{:reconnect, backoff, callback_state} ->
cancel_timer(state.reconnect_timer)
reconnect_timer = internal_event(:reconnect, backoff)
Expand Down Expand Up @@ -273,13 +289,15 @@ defmodule Minch.Conn do

defp send_close(%State{} = state, frame) do
send_frame(state, frame)
%{state | websocket: nil, close_timer: internal_event({:close_timeout, frame}, 5000)}
cancel_timer(state.close_timer)
close_timer = internal_event(:close_timeout, state.close_timeout)
%{state | websocket: nil, close_timer: close_timer, close_frame: frame}
end

defp close(%State{conn: conn} = state) do
if conn, do: Mint.HTTP.close(conn)
cancel_timer(state.close_timer)
%{state | conn: nil, websocket: nil, request_ref: nil, close_timer: nil}
%{state | conn: nil, websocket: nil, request_ref: nil, close_timer: nil, close_frame: nil}
end

defp cancel_timer(nil), do: :ok
Expand Down
18 changes: 14 additions & 4 deletions test/minch/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Minch.ClientTest do
use Minch, restart: :transient

def start_link(state) do
Minch.start_link(__MODULE__, state)
Minch.start_link(__MODULE__, state, state[:opts] || [])
end

def connect(state) do
Expand Down Expand Up @@ -147,15 +147,25 @@ defmodule Minch.ClientTest do
@tag client_state: %{reconnect: 10}
test "handle_disconnect/2 is called when received a :close frame from server", ctx do
assert_receive {:client, :handle_connect, _}
Server.send_frame(ctx.server, :close)
assert_receive {:client, :handle_disconnect, [_reason, 1, _state]}
Server.send_frame(ctx.server, {:close, 4001, "policy"})
assert_receive {:client, :handle_disconnect, [{:close, 4001, "policy"}, 1, _state]}

assert_receive {:server, :init, server}
assert_receive {:client, :handle_connect, _}
Server.send_frame(server, :close)
assert_receive {:client, :handle_disconnect, [_reason, 1, _state]}
end

@tag client_state: %{opts: [close_timeout: 50]}
test "handle_disconnect/2 is called when the close handshake is left unanswered", ctx do
assert_receive {:client, :handle_connect, _}
# suspended so the server never reads our close frame, and never closes the socket
:sys.suspend(ctx.server)
send(ctx.client, {:close, 1000, "bye"})
assert_receive {:client, :handle_disconnect, [{:close, 1000, "bye"}, 1, _state]}
:sys.resume(ctx.server)
end

test "connection is properly closed and terminate/2 is called", ctx do
assert_receive {:client, :handle_connect, _}
Minch.close(ctx.client)
Expand All @@ -172,7 +182,7 @@ defmodule Minch.ClientTest do
assert_receive {:client, :handle_connect, _}
send(ctx.client, {:close, 1000, "bye"})
assert_receive {:server, :terminate, {:remote, 1000, "bye"}}
assert_receive {:client, :handle_disconnect, [%Mint.TransportError{reason: :closed}, 1, _]}
assert_receive {:client, :handle_disconnect, [{:close, 1000, "bye"}, 1, _]}
end

test "stops the client process by returning a :stop tuple from a callback", ctx do
Expand Down
Loading