Current section

Files

Jump to
slipstream lib slipstream connection pipeline.ex
Raw

lib/slipstream/connection/pipeline.ex

defmodule Slipstream.Connection.Pipeline do
@moduledoc false
@moduledoc since: "0.3.0"
import Slipstream.Signatures
import Slipstream.Connection.Impl, only: [route_event: 2]
alias Slipstream.Connection.{Impl, State, Telemetry}
alias Slipstream.{Commands, Events, Message}
require Logger
defstruct [
:state,
:raw_message,
:message,
:return,
events: [],
built_events: []
]
@type t :: %__MODULE__{}
@spec handle(raw_message :: term(), state :: State.t()) :: term()
def handle(raw_message, state) do
pipeline = %__MODULE__{
raw_message: raw_message,
state: state
}
Telemetry.span(
pipeline,
fn ->
pipeline
|> decode_message()
|> handle_message()
|> default_return()
|> build_events()
|> emit_events()
end
)
end
@spec decode_message(t()) :: t()
defp decode_message(%{raw_message: command(_) = cmd} = p) do
put_message(p, cmd)
end
defp decode_message(%{raw_message: :connect} = p) do
put_message(p, :connect)
end
defp decode_message(
%{
raw_message: {:DOWN, ref, :process, _pid, reason},
state: %{client_ref: ref}
} = p
) do
put_message(p, event(%Events.ParentProcessExited{reason: reason}))
end
defp decode_message(
%{
raw_message: [
{:status, ref, status},
{:headers, ref, headers} | _maybe_done
],
state: %{request_ref: ref} = state
} = p
) do
case Mint.WebSocket.new(state.conn, ref, status, headers) do
{:ok, conn, websocket} ->
p
|> put_message(
event(%Events.ChannelConnected{
pid: self(),
config: state.config,
response_headers: headers
})
)
|> put_state(%State{p.state | conn: conn, websocket: websocket})
{:error, _conn, reason} ->
failure_info = %{
status_code: status,
resp_headers: headers,
reason: reason
}
event = %Events.ChannelConnectFailed{
reason: {:upgrade_failure, failure_info}
}
put_message(p, event(event))
end
end
defp decode_message(
%{raw_message: messages, state: %{request_ref: ref} = state} = p
)
when is_list(messages) do
frames =
Enum.reduce_while(messages, {:ok, state, []}, fn
{:data, ^ref, data}, {:ok, state, acc} ->
case Mint.WebSocket.decode(state.websocket, data) do
{:ok, websocket, frames} ->
{:cont, {:ok, put_in(state.websocket, websocket), acc ++ frames}}
# coveralls-ignore-start
{:error, websocket, reason} ->
{:halt, {:error, put_in(state.websocket, websocket), reason}}
end
_message, {:ok, state, acc} ->
{:cont, {:ok, state, acc}}
# coveralls-ignore-stop
end)
case frames do
{:ok, state, frames} ->
events =
Enum.map(frames, fn frame ->
event(frame |> Impl.decode_message(state) |> Events.map(state))
end)
p
|> put_state(state)
|> put_message(events)
{:error, state, reason} ->
# coveralls-ignore-start
p
|> put_state(state)
|> put_message(event(%Events.ChannelClosed{reason: reason}))
# coveralls-ignore-stop
end
end
defp decode_message(p) do
case Mint.WebSocket.stream(p.state.conn, p.raw_message) do
{:ok, conn, messages} ->
put_in(p.raw_message, messages)
|> put_state(%State{p.state | conn: conn})
|> decode_message()
{:error, conn, %Mint.TransportError{reason: :closed}, _} ->
# coveralls-ignore-start
p
|> put_state(%{p.state | conn: conn})
|> put_message(event(%Events.ChannelClosed{reason: :closed}))
{:error, conn, %Mint.TransportError{reason: reason}, _} ->
Logger.error(
"Closing connection because of transport error: #{inspect(reason)}"
)
p
|> put_state(%{p.state | conn: conn})
|> put_message(event(%Events.ChannelClosed{reason: reason}))
:unknown ->
Logger.error("""
unknown message #{inspect(p.raw_message)} \
heard in #{inspect(__MODULE__)} \
please open an issue in NFIBrokerage/slipstream with this message and \
any available information. \
""")
put_message(p, event(%Events.NoOp{}))
# coveralls-ignore-stop
end
end
@spec handle_message(t()) :: t()
defp handle_message(
%{message: :connect, state: %{config: config} = state} = p
) do
with {:ok, conn} <- Impl.http_connect(config),
{:ok, conn, ref} <- Impl.websocket_upgrade(conn, config) do
put_state(p, %State{state | conn: conn, request_ref: ref})
else
# coveralls-ignore-start
{:error, conn, reason} ->
Mint.HTTP.close(conn)
p
|> put_event(:channel_connect_failed, reason: reason)
|> put_return({:stop, {:shutdown, :normal}, state})
# coveralls-ignore-stop
{:error, reason} ->
p
|> put_event(:channel_connect_failed, reason: reason)
|> put_return({:stop, {:shutdown, :normal}, state})
end
end
defp handle_message(%{message: messages} = p) when is_list(messages) do
Enum.reduce_while(messages, p, fn
# coveralls-ignore-start
_message, %{return: {:stop, _reason, _state}} = p ->
{:halt, p}
# coveralls-ignore-stop
message, p ->
{:cont, handle_message(%{p | message: message})}
end)
end
# handle commands
defp handle_message(%{message: command(%Commands.CollectGarbage{})} = p) do
:erlang.garbage_collect(self())
p
end
# coveralls-ignore-start
defp handle_message(
%{message: command(%Commands.SendHeartbeat{}), state: state} = p
) do
with nil <- state.heartbeat_ref,
{:ok, state} <-
state |> State.next_heartbeat_ref() |> Impl.push_heartbeat() do
put_state(p, state)
else
existing_heartbeat_ref when is_binary(existing_heartbeat_ref) ->
# the heartbeat_ref has not been cleared, meaning the server has not
# ack-ed our last heartbeat. This is heartbeat-timeout and it's time to
# disconnect and shut down
Mint.HTTP.close(state.conn)
p
|> put_event(:channel_closed, reason: :heartbeat_timeout)
|> put_return({:stop, {:shutdown, :disconnected}, state})
{:error, state, reason} ->
route_event state,
%Events.ChannelClosed{reason: {:send_failure, reason}}
put_return(p, {:stop, :normal, state})
end
end
# coveralls-ignore-stop
defp handle_message(
%{message: command(%Commands.PushMessage{} = cmd), state: state} = p
) do
{ref, state} = State.next_ref(state)
p =
p
|> put_state(state)
|> push_message(%Message{
topic: cmd.topic,
event: cmd.event,
payload: cmd.payload,
ref: ref
})
case p do
# coveralls-ignore-start
%{return: {:stop, _reason, _state}} = p -> p
# coveralls-ignore-stop
p -> put_return(p, {:reply, ref, state})
end
end
defp handle_message(
%{message: command(%Commands.JoinTopic{} = cmd), state: state} = p
) do
{ref, state} = State.next_ref(state)
state = %State{state | joins: Map.put(state.joins, cmd.topic, ref)}
p
|> put_state(state)
|> push_message(%Message{
topic: cmd.topic,
event: "phx_join",
payload: cmd.payload,
ref: state.current_ref_str,
join_ref: state.current_ref_str
})
end
defp handle_message(
%{message: command(%Commands.LeaveTopic{} = cmd), state: state} = p
) do
{ref, state} = State.next_ref(state)
state = %State{state | leaves: Map.put(state.leaves, cmd.topic, ref)}
p
|> put_state(state)
|> push_message(%Message{
topic: cmd.topic,
event: "phx_leave",
payload: %{},
ref: state.current_ref_str,
join_ref: state.joins[cmd.topic]
})
end
defp handle_message(
%{message: command(%Commands.CloseConnection{}), state: state} = p
) do
Mint.HTTP.close(state.conn)
p
|> put_event(:channel_closed, reason: :client_disconnect_requested)
|> put_return({:stop, {:shutdown, :disconnected}, state})
end
# handle events
# coveralls-ignore-start
defp handle_message(%{message: event(%Events.NoOp{})} = p), do: p
# coveralls-ignore-stop
defp handle_message(
%{
message: event(%Events.ParentProcessExited{reason: reason}),
state: state
} = p
) do
Mint.HTTP.close(state.conn)
put_return(p, {:stop, reason, state})
end
defp handle_message(
%{
message: event(%Events.ChannelConnectFailed{} = event),
state: state
} = p
) do
Mint.HTTP.close(state.conn)
route_event state, event
put_return(p, {:stop, :normal, state})
end
# coveralls-ignore-start
defp handle_message(%{message: event(%Events.PingReceived{data: data})} = p) do
push_message(p, {:pong, data})
end
defp handle_message(%{message: event(%Events.PongReceived{})} = p), do: p
# coveralls-ignore-stop
defp handle_message(%{message: event(%type{} = event), state: state} = p)
when type in [Events.TopicJoinFailed, Events.TopicJoinClosed] do
state = %State{state | joins: Map.delete(state.joins, event.topic)}
route_event state, event
put_state(p, state)
end
defp handle_message(
%{message: event(%Events.TopicLeaveAccepted{} = event), state: state} =
p
) do
state = %State{state | leaves: Map.delete(state.leaves, event.topic)}
put_state(p, state)
end
defp handle_message(
%{message: event(%Events.HeartbeatAcknowledged{}), state: state} = p
) do
# coveralls-ignore-start
put_state(p, State.reset_heartbeat(state))
# coveralls-ignore-stop
end
defp handle_message(
%{message: event(%Events.ChannelConnected{} = event), state: state} = p
) do
timer =
if state.config.heartbeat_interval_msec != 0 do
{:ok, tref} =
:timer.send_interval(
state.config.heartbeat_interval_msec,
command(%Commands.SendHeartbeat{})
)
tref
end
state =
%State{state | status: :connected, heartbeat_timer: timer}
|> State.reset_heartbeat()
route_event state, event
put_state(p, state)
end
# coveralls-ignore-start
defp handle_message(
%{message: event(%Events.ChannelClosed{} = event), state: state} = p
) do
Mint.HTTP.close(state.conn)
if match?({:interval, ref} when is_reference(ref), state.heartbeat_timer) do
:timer.cancel(state.heartbeat_timer)
end
state = %State{state | status: :terminating}
route_event state, event
p
|> put_state(state)
|> put_return({:stop, :normal, state})
end
# coveralls-ignore-stop
# the rest of the events are routed to the client process
defp handle_message(%{message: event(event), state: state} = p) do
route_event state, event
p
end
# coveralls-ignore-start
defp handle_message(%{message: message} = p) do
Logger.error("""
#{inspect(__MODULE__)} received a message it is not setup to handle: \
#{inspect(message)}. \
Please open an issue in NFIBrokerage/slipstream with any available details \
leading to this logger message. \
""")
p
end
# coveralls-ignore-stop
@spec default_return(t()) :: t()
defp default_return(%{state: state, return: nil} = p) do
put_return(p, {:noreply, state})
end
defp default_return(p), do: p
@spec build_events(t()) :: t()
defp build_events(%{events: []} = p), do: p
defp build_events(%{events: events} = p) do
built_events =
Enum.map(events, fn %{type: type, attrs: attrs} ->
build_event(type, attrs)
end)
%__MODULE__{p | built_events: built_events}
end
defp emit_events(%{built_events: []} = p), do: p
defp emit_events(%{built_events: events, state: state} = p) do
Enum.each(events, fn event ->
route_event state, event
end)
p
end
defp build_event(:channel_closed, attrs) do
%Events.ChannelClosed{reason: attrs.reason}
end
defp build_event(:channel_connect_failed, attrs) do
%Events.ChannelConnectFailed{reason: attrs.reason}
end
# --- token API
@spec put_state(t(), State.t()) :: t()
def put_state(p, state) do
%__MODULE__{p | state: state}
end
@spec put_message(t(), term()) :: t()
def put_message(p, message) do
%__MODULE__{p | message: message}
end
@doc """
Adds an event to be emitted
Note that this will not be the actual event to-be-sent, but an atom used to
build the event in the `build_events/1` phase of the pipeline
"""
@spec put_event(t(), atom(), Keyword.t() | map()) :: t()
def put_event(p, event, attrs \\ %{}) do
%__MODULE__{
p
| events: p.events ++ [%{type: event, attrs: Enum.into(attrs, %{})}]
}
end
@doc """
Declares the return value of the pipeline
This value will be given to the GenServer callback that invoked
"""
@spec put_return(t(), term()) :: t()
def put_return(p, return) do
%__MODULE__{p | return: return}
end
def push_message(p, message) do
case Impl.push_message(message, p.state) do
{:ok, state} ->
put_state(p, state)
{:error, state, reason} ->
# coveralls-ignore-start
route_event state,
%Events.ChannelClosed{reason: {:send_failure, reason}}
p
|> put_state(state)
|> put_return({:stop, :normal, state})
# coveralls-ignore-stop
end
end
end