Migrate WebSocket GenServer (Server) to Connection (#1585)

This commit is contained in:
Alexandre de Souza 2022-12-21 11:28:27 -03:00 committed by GitHub
parent 59cbba63b7
commit 3e11023925
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 558 additions and 232 deletions

View file

@ -2,22 +2,19 @@ defmodule Livebook.WebSocket do
@moduledoc false @moduledoc false
alias Livebook.WebSocket.Client alias Livebook.WebSocket.Client
alias LivebookProto.{Request, SessionRequest}
defmodule Connection do defmodule Connection do
defstruct [:conn, :websocket, :ref] defstruct [:conn, :websocket, :ref]
@type t :: %__MODULE__{ @type t :: %__MODULE__{
conn: Client.conn(), conn: Client.conn() | nil,
websocket: Client.websocket(), websocket: Client.websocket() | nil,
ref: Client.ref() ref: Client.ref() | nil
} }
end end
@type proto :: SessionRequest.t() @type proto :: LivebookProto.SessionRequest.t()
@typep headers :: Mint.Types.headers()
@typep header :: {String.t(), String.t()}
@typep headers :: list(header())
@doc """ @doc """
Connects with the WebSocket server for given URL and headers. Connects with the WebSocket server for given URL and headers.
@ -36,9 +33,15 @@ defmodule Livebook.WebSocket do
@doc """ @doc """
Disconnects the given WebSocket client. Disconnects the given WebSocket client.
""" """
@spec disconnect(Connection.t()) :: :ok @spec disconnect(Connection.t()) :: {:ok, Connection.t()} | {:error, Connection.t(), any()}
def disconnect(%Connection{} = connection) do def disconnect(%Connection{} = connection) do
Client.disconnect(connection.conn, connection.websocket, connection.ref) case Client.disconnect(connection.conn, connection.websocket, connection.ref) do
{:ok, conn, websocket} ->
{:ok, %{connection | conn: conn, websocket: websocket, ref: nil}}
{:error, conn, websocket, reason} ->
{:error, %{connection | conn: conn, websocket: websocket}, reason}
end
end end
@doc """ @doc """
@ -47,29 +50,24 @@ defmodule Livebook.WebSocket do
@spec send_request(Connection.t(), proto()) :: @spec send_request(Connection.t(), proto()) ::
{:ok, Connection.t()} {:ok, Connection.t()}
| {:error, Connection.t(), Client.ws_error() | Client.mint_error()} | {:error, Connection.t(), Client.ws_error() | Client.mint_error()}
def send_request(%Connection{} = connection, %struct{} = data) do def send_request(%Connection{} = connection, data) do
type = LivebookProto.request_type(struct) frame = LivebookProto.build_request_frame(data)
message = Request.new!(type: {type, data})
binary = {:binary, Request.encode(message)}
case Client.send(connection.conn, connection.websocket, connection.ref, binary) do case Client.send(connection.conn, connection.websocket, connection.ref, frame) do
{:ok, conn, websocket} -> {:ok, conn, websocket} ->
{:ok, %{connection | conn: conn, websocket: websocket}} {:ok, %{connection | conn: conn, websocket: websocket}}
{:error, %Mint.WebSocket{} = websocket, reason} -> {:error, conn, websocket, reason} ->
{:error, %{connection | websocket: websocket}, reason} {:error, %{connection | conn: conn, websocket: websocket}, reason}
{:error, conn, reason} ->
{:error, %{connection | conn: conn}, reason}
end end
end end
@dialyzer {:nowarn_function, receive_response: 1}
@doc """ @doc """
Receives a response from the given server. Receives a response from the given server.
""" """
@spec receive_response(Connection.t()) :: Client.receive_fun() @spec receive_response(Connection.t()) ::
{:ok, Connection.t(), Client.Response.t() | :connect}
| {:error, Connection.t(), Client.Response.t() | term()}
def receive_response(%Connection{conn: conn, websocket: websocket, ref: ref}) do def receive_response(%Connection{conn: conn, websocket: websocket, ref: ref}) do
conn conn
|> Client.receive(ref, websocket) |> Client.receive(ref, websocket)
@ -85,12 +83,51 @@ defmodule Livebook.WebSocket do
{:ok, %Connection{conn: conn, websocket: websocket, ref: ref}, result} {:ok, %Connection{conn: conn, websocket: websocket, ref: ref}, result}
end end
defp handle_receive({:error, conn, %Client.Response{body: nil, status: status}}, ref) do defp handle_receive({:error, conn, websocket, %Client.Response{body: nil, status: status}}, ref) do
{:error, %Connection{conn: conn, ref: ref}, Plug.Conn.Status.reason_phrase(status)} {:error, %Connection{conn: conn, websocket: websocket, ref: ref},
Plug.Conn.Status.reason_phrase(status)}
end end
defp handle_receive({:error, conn, %Client.Response{body: response}}, ref) do defp handle_receive({:error, conn, websocket, %Client.Response{body: response}}, ref) do
%{type: {:error, error}} = LivebookProto.Response.decode(response) %{type: {:error, error}} = LivebookProto.Response.decode(response)
{:error, %Connection{conn: conn, ref: ref}, error} {:error, %Connection{conn: conn, websocket: websocket, ref: ref}, error}
end
defp handle_receive({:error, reason}, ref) do
{:error, %Connection{ref: ref}, reason}
end
@doc """
Subscribe to WebSocket Server events.
## Messages
* `{:ok, pid, random_id, :connected}`
* `{:ok, pid, random_id, %Livebook.WebSocket.Response{}}`
* `{:error, pid, random_id, %Livebook.WebSocket.Response{}}`
* `{:error, pid, random_id, reason}`
"""
@spec subscribe() :: :ok | {:error, {:already_registered, pid()}}
def subscribe do
Phoenix.PubSub.subscribe(Livebook.PubSub, "websocket:clients")
end
@doc """
Unsubscribes from `subscribe/0`.
"""
@spec unsubscribe() :: :ok
def unsubscribe do
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "websocket:clients")
end
@doc """
Notifies interested processes about WebSocket Server messages.
Broadcasts the given message under the `"websocket:clients"` topic.
"""
@spec broadcast_message(any()) :: :ok
def broadcast_message(message) do
Phoenix.PubSub.broadcast(Livebook.PubSub, "websocket:clients", message)
end end
end end

View file

@ -7,7 +7,7 @@ defmodule Livebook.WebSocket.Client do
@type conn :: Mint.HTTP.t() @type conn :: Mint.HTTP.t()
@type websocket :: Mint.WebSocket.t() @type websocket :: Mint.WebSocket.t()
@type frame :: :close | {:binary, binary()} @type frame :: Mint.WebSocket.frame() | Mint.WebSocket.shorthand_frame()
@type ref :: Mint.Types.request_ref() @type ref :: Mint.Types.request_ref()
@type ws_error :: Mint.WebSocket.error() @type ws_error :: Mint.WebSocket.error()
@type mint_error :: Mint.Types.error() @type mint_error :: Mint.Types.error()
@ -16,9 +16,9 @@ defmodule Livebook.WebSocket.Client do
defstruct [:body, :status, :headers] defstruct [:body, :status, :headers]
@type t :: %__MODULE__{ @type t :: %__MODULE__{
body: Livebook.WebSocket.Response.t(), body: Livebook.WebSocket.Response.t() | nil,
status: Mint.Types.status(), status: Mint.Types.status() | nil,
headers: Mint.Types.headers() headers: Mint.Types.headers() | nil
} }
end end
@ -53,15 +53,19 @@ defmodule Livebook.WebSocket.Client do
If there's no WebSocket connection yet, it'll only close the HTTP connection. If there's no WebSocket connection yet, it'll only close the HTTP connection.
""" """
@spec disconnect(conn(), websocket(), ref()) :: :ok @spec disconnect(conn(), websocket() | nil, ref()) ::
{:ok, conn(), websocket() | nil}
| {:error, conn() | websocket(), term()}
def disconnect(conn, nil, _ref) do
{:ok, conn} = Mint.HTTP.close(conn)
{:ok, conn, nil}
end
def disconnect(conn, websocket, ref) do def disconnect(conn, websocket, ref) do
if websocket do with {:ok, conn, websocket} <- send(conn, websocket, ref, :close),
send(conn, websocket, ref, :close) {:ok, conn} <- Mint.HTTP.close(conn) do
{:ok, conn, websocket}
end end
Mint.HTTP.close(conn)
:ok
end end
@doc """ @doc """
@ -70,17 +74,24 @@ defmodule Livebook.WebSocket.Client do
If the WebSocket isn't connected yet, it will try to get the connection If the WebSocket isn't connected yet, it will try to get the connection
response to start a new WebSocket connection. response to start a new WebSocket connection.
""" """
@spec receive(conn(), ref(), term()) :: @spec receive(conn() | nil, ref(), websocket() | nil, term()) ::
{:ok, conn(), Response.t() | :connect} {:ok, conn(), websocket(), Response.t() | :connected}
| {:error, conn(), Response.t()} | {:error, conn(), websocket(), Response.t()}
| {:error, conn(), :unknown} | {:error, conn(), websocket(), ws_error() | mint_error()}
| {:error, :not_connected | :unknown}
def receive(conn, ref, websocket \\ nil, message \\ receive(do: (message -> message))) do def receive(conn, ref, websocket \\ nil, message \\ receive(do: (message -> message))) do
do_receive(conn, ref, websocket, message)
end
defp do_receive(nil, _ref, _websocket, _message), do: {:error, :not_connected}
defp do_receive(conn, ref, websocket, message) do
case Mint.WebSocket.stream(conn, message) do case Mint.WebSocket.stream(conn, message) do
{:ok, conn, responses} -> {:ok, conn, responses} ->
handle_responses(conn, ref, websocket, responses) handle_responses(conn, ref, websocket, responses)
{:error, conn, reason, []} -> {:error, conn, reason, []} ->
{:error, conn, reason} {:error, conn, websocket, reason}
{:error, conn, _reason, responses} -> {:error, conn, _reason, responses} ->
handle_responses(conn, ref, websocket, responses) handle_responses(conn, ref, websocket, responses)
@ -92,24 +103,6 @@ defmodule Livebook.WebSocket.Client do
@successful_status 100..299 @successful_status 100..299
defp handle_responses(conn, ref, nil, responses) do
result =
Enum.reduce(responses, %Response{}, fn
{:status, ^ref, status}, acc -> %{acc | status: status}
{:headers, ^ref, headers}, acc -> %{acc | headers: headers}
{:data, ^ref, body}, acc -> %{acc | body: body}
{:done, ^ref}, acc -> handle_done_response(conn, ref, acc)
end)
case result do
%Response{} = response when response.status not in @successful_status ->
{:error, conn, response}
result ->
result
end
end
defp handle_responses(conn, ref, websocket, [{:data, ref, data}]) do defp handle_responses(conn, ref, websocket, [{:data, ref, data}]) do
with {:ok, websocket, frames} <- Mint.WebSocket.decode(websocket, data) do with {:ok, websocket, frames} <- Mint.WebSocket.decode(websocket, data) do
case handle_frames(%Response{}, frames) do case handle_frames(%Response{}, frames) do
@ -120,8 +113,7 @@ defmodule Livebook.WebSocket.Client do
{:ok, conn, websocket, response} {:ok, conn, websocket, response}
{:close, result} -> {:close, result} ->
disconnect(conn, websocket, ref) handle_disconnect(conn, websocket, ref, result)
{:ok, conn, websocket, result}
{:error, response} -> {:error, response} ->
{:error, conn, websocket, response} {:error, conn, websocket, response}
@ -129,7 +121,25 @@ defmodule Livebook.WebSocket.Client do
end end
end end
defp handle_done_response(conn, ref, response) do defp handle_responses(conn, ref, websocket, [_ | _] = responses) do
result =
Enum.reduce(responses, %Response{}, fn
{:status, ^ref, status}, acc -> %{acc | status: status}
{:headers, ^ref, headers}, acc -> %{acc | headers: headers}
{:data, ^ref, body}, acc -> %{acc | body: body}
{:done, ^ref}, acc -> handle_done_response(conn, ref, websocket, acc)
end)
case result do
%Response{} = response when response.status not in @successful_status ->
{:error, conn, websocket, response}
result ->
result
end
end
defp handle_done_response(conn, ref, websocket, response) do
case Mint.WebSocket.new(conn, ref, response.status, response.headers) do case Mint.WebSocket.new(conn, ref, response.status, response.headers) do
{:ok, conn, websocket} -> {:ok, conn, websocket} ->
case decode_response(websocket, response) do case decode_response(websocket, response) do
@ -137,15 +147,20 @@ defmodule Livebook.WebSocket.Client do
{:ok, conn, websocket, result} {:ok, conn, websocket, result}
{websocket, {:close, result}} -> {websocket, {:close, result}} ->
disconnect(conn, websocket, ref) handle_disconnect(conn, websocket, ref, result)
{:ok, conn, websocket, result}
{websocket, {:error, reason}} -> {websocket, {:error, reason}} ->
{:error, conn, websocket, reason} {:error, conn, websocket, reason}
end end
{:error, conn, %UpgradeFailureError{status_code: status, headers: headers}} -> {:error, conn, %UpgradeFailureError{status_code: status, headers: headers}} ->
{:error, conn, %{response | status: status, headers: headers}} {:error, conn, websocket, %{response | status: status, headers: headers}}
end
end
defp handle_disconnect(conn, websocket, ref, result) do
with {:ok, conn, websocket} <- disconnect(conn, websocket, ref) do
{:ok, conn, websocket, result}
end end
end end
@ -173,18 +188,22 @@ defmodule Livebook.WebSocket.Client do
end) end)
end end
@dialyzer {:nowarn_function, send: 4}
@doc """ @doc """
Sends a message to the given HTTP Connection and WebSocket connection. Sends a message to the given HTTP Connection and WebSocket connection.
""" """
@spec send(conn(), websocket(), ref(), frame()) :: @spec send(conn(), websocket(), ref(), frame()) ::
{:ok, conn(), websocket()} {:ok, conn(), websocket()}
| {:error, conn() | websocket(), term()} | {:error, conn(), websocket(), term()}
def send(conn, websocket, ref, frame) when is_frame(frame) do def send(conn, websocket, ref, frame) when is_frame(frame) do
with {:ok, websocket, data} <- Mint.WebSocket.encode(websocket, frame), with {:ok, websocket, data} <- Mint.WebSocket.encode(websocket, frame),
{:ok, conn} <- Mint.WebSocket.stream_request_body(conn, ref, data) do {:ok, conn} <- Mint.WebSocket.stream_request_body(conn, ref, data) do
{:ok, conn, websocket} {:ok, conn, websocket}
else
{:error, %Mint.HTTP1{} = conn, reason} ->
{:error, conn, websocket, reason}
{:error, websocket, reason} ->
{:error, conn, websocket, reason}
end end
end end
end end

View file

@ -1,115 +1,196 @@
defmodule Livebook.WebSocket.Server do defmodule Livebook.WebSocket.Server do
@moduledoc false @moduledoc false
use GenServer use Connection
require Logger require Logger
import Livebook.WebSocket.Client, only: [is_frame: 1] alias Livebook.WebSocket
alias Livebook.WebSocket.Client alias Livebook.WebSocket.Client
defstruct [ @timeout 10_000
:conn, @backoff 1_490
:websocket,
:caller,
:status,
:resp_headers,
:resp_body,
:ref,
closing?: false
]
def start_link(opts \\ []) do defstruct [:url, :headers, :http_conn, :websocket, :ref, id: 0]
GenServer.start_link(__MODULE__, opts)
@doc """
Starts a new WebSocket Server connection with given URL and headers.
"""
@spec start_link(String.t(), Mint.Types.headers()) ::
{:ok, pid()} | {:error, {:already_started, pid()}}
def start_link(url, headers \\ []) do
Connection.start_link(__MODULE__, {url, headers})
end end
@doc """ @doc """
Connects the WebSocket client. Checks if the given WebSocket Server is connected.
""" """
def connect(pid, url, headers \\ []) do @spec connected?(pid()) :: boolean()
GenServer.call(pid, {:connect, url, headers}) def connected?(conn) do
Connection.call(conn, :connected?, @timeout)
end end
@doc """ @doc """
Disconnects the WebSocket client. Closes the given WebSocket Server connection.
""" """
def disconnect(pid) do @spec close(pid()) :: :ok
GenServer.cast(pid, :close) def close(conn) do
Connection.call(conn, :close, @timeout)
end end
@doc """ @doc """
Sends a message to the WebSocket server the message request. Sends a Request to given WebSocket Server.
""" """
def send_message(socket, frame) when is_frame(frame) do @spec send_request(pid(), WebSocket.proto()) :: :ok
GenServer.cast(socket, {:send_message, frame}) def send_request(conn, %_struct{} = data) do
Connection.call(conn, {:request, data}, @timeout)
end
## Connection callbacks
@impl true
def init({url, headers}) do
state = struct!(__MODULE__, url: url, headers: headers)
{:connect, :init, state}
end
@impl true
def connect(_, state) do
case Client.connect(state.url, state.headers) do
{:ok, conn, ref} ->
{:ok, %{state | http_conn: conn, ref: ref}}
{:error, exception} when is_exception(exception) ->
Logger.error("Received exception: #{Exception.message(exception)}")
{:backoff, @backoff, state}
{:error, conn, reason} ->
Logger.error("Received error: #{inspect(reason)}")
{:backoff, @backoff, %{state | http_conn: conn}}
end
end
@dialyzer {:nowarn_function, disconnect: 2}
@impl true
def disconnect({:close, caller}, state) do
case Client.disconnect(state.http_conn, state.websocket, state.ref) do
{:ok, conn, websocket} ->
Connection.reply(caller, :ok)
{:noconnect, %{state | http_conn: conn, websocket: websocket}}
{:error, conn, websocket, reason} ->
Connection.reply(caller, {:error, reason})
{:noconnect, %{state | http_conn: conn, websocket: websocket}}
end
end
def disconnect(info, state) do
case info do
{:error, :closed} -> Logger.error("Connection closed")
{:error, reason} -> Logger.error("Connection error: #{inspect(reason)}")
end
case Client.disconnect(state.http_conn, state.websocket, state.ref) do
{:ok, conn, websocket} ->
{:connect, :reconnect, %{state | http_conn: conn, websocket: websocket}}
{:error, conn, websocket, reason} ->
Logger.error("Received error: #{inspect(reason)}")
{:connect, :reconnect, %{state | http_conn: conn, websocket: websocket}}
end
end end
## GenServer callbacks ## GenServer callbacks
@impl true @impl true
def init(_) do def handle_call(:connected?, _from, state) do
{:ok, %__MODULE__{}} if conn = state.http_conn do
end {:reply, conn.state == :open, state}
else
@impl true {:reply, false, state}
def handle_call({:connect, url, headers}, from, state) do
case Client.connect(url, headers) do
{:ok, conn, ref} -> {:noreply, %{state | conn: conn, ref: ref, caller: from}}
{:error, _reason} = error -> {:reply, error, state}
{:error, conn, reason} -> {:reply, {:error, reason}, %{state | conn: conn}}
end end
end end
@impl true def handle_call(:close, caller, state) do
def handle_cast(:close, state) do {:disconnect, {:close, caller}, state}
Client.disconnect(state.conn, state.websocket, state.ref)
{:stop, :normal, state}
end end
def handle_cast({:send_message, frame}, state) do def handle_call({:request, data}, caller, state) do
case Client.send(state.conn, state.websocket, state.ref, frame) do id = state.id
frame = LivebookProto.build_request_frame(data, id)
Connection.reply(caller, :ok)
case Client.send(state.http_conn, state.websocket, state.ref, frame) do
{:ok, conn, websocket} -> {:ok, conn, websocket} ->
{:noreply, %{state | conn: conn, websocket: websocket}} {:noreply, %{state | http_conn: conn, websocket: websocket, id: id + 1}}
{:error, %Mint.WebSocket{} = websocket, _reason} -> {:error, conn, websocket, reason} ->
{:noreply, %{state | websocket: websocket}} WebSocket.broadcast_message({:error, self(), id, reason})
{:noreply, %{state | http_conn: conn, websocket: websocket}}
{:error, conn, _reason} ->
{:noreply, %{state | conn: conn}}
end end
end end
@impl true @impl true
def handle_info(message, state) do def handle_info(message, state) do
case Client.receive(state.conn, state.ref, state.websocket, message) do case Client.receive(state.http_conn, state.ref, state.websocket, message) do
{:ok, conn, websocket, response} -> {:ok, conn, websocket, :connected} ->
state = %{state | conn: conn, websocket: websocket} {:ok, :connected}
{:noreply, reply(state, {:ok, response})} |> build_response(state)
|> WebSocket.broadcast_message()
{:error, conn, websocket, response} -> {:noreply, %{state | http_conn: conn, websocket: websocket}}
state = %{state | conn: conn, websocket: websocket}
{:noreply, reply(state, {:error, response})}
{:error, conn, response} -> {:error, conn, websocket, %Mint.TransportError{} = reason} ->
state = %{state | conn: conn} {:error, reason}
{:noreply, reply(state, {:error, response})} |> build_response(state)
|> WebSocket.broadcast_message()
{:connect, :receive, %{state | http_conn: conn, websocket: websocket}}
{term, conn, websocket, data} ->
{term, data}
|> build_response(state)
|> WebSocket.broadcast_message()
{:noreply, %{state | http_conn: conn, websocket: websocket}}
{:error, _} = error ->
error
|> build_response(state)
|> WebSocket.broadcast_message()
{:error, _} ->
{:noreply, state} {:noreply, state}
end end
end end
# Private # Private
defp reply(%{caller: nil} = state, response) do defp build_response({:ok, :connected}, state) do
Logger.warning("The caller is nil, so we can't reply the message: #{inspect(response)}") {:ok, self(), state.id, :connected}
state
end end
defp reply(state, response) do defp build_response({:ok, %Client.Response{body: nil, status: nil, headers: nil}}, state) do
GenServer.reply(state.caller, response) {:ok, self(), state.id, :pong}
end
state defp build_response({:error, %Client.Response{body: nil, status: status} = response}, state) do
response = %{response | body: Plug.Conn.Status.reason_phrase(status)}
{:error, self(), state.id, response}
end
defp build_response({:ok, %Client.Response{body: body} = response}, _state) do
case LivebookProto.Response.decode(body) do
%{id: id, type: {:error, _} = error} -> {:error, self(), id, %{response | body: error}}
%{id: id, type: result} -> {:ok, self(), id, result}
end
end
defp build_response({:error, %Client.Response{body: body} = response}, _state) do
%{id: id, type: {:error, _} = error} = LivebookProto.Response.decode(body)
{:error, self(), id, %{response | body: error}}
end
defp build_response({:error, reason}, state) do
{:error, self(), state.id, %Client.Response{body: reason}}
end end
end end

View file

@ -106,7 +106,8 @@ defmodule Livebook.MixProject do
{:protobuf, "~> 0.8.0"}, {:protobuf, "~> 0.8.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test}, {:floki, ">= 0.27.0", only: :test},
{:bypass, "~> 2.1", only: :test} {:bypass, "~> 2.1", only: :test},
{:connection, "~> 1.1.0"}
] ]
end end

View file

@ -2,6 +2,7 @@
"aws_signature": {:hex, :aws_signature, "0.3.1", "67f369094cbd55ffa2bbd8cc713ede14b195fcfb45c86665cd7c5ad010276148", [:rebar3], [], "hexpm", "50fc4dc1d1f7c2d0a8c63f455b3c66ecd74c1cf4c915c768a636f9227704a674"}, "aws_signature": {:hex, :aws_signature, "0.3.1", "67f369094cbd55ffa2bbd8cc713ede14b195fcfb45c86665cd7c5ad010276148", [:rebar3], [], "hexpm", "50fc4dc1d1f7c2d0a8c63f455b3c66ecd74c1cf4c915c768a636f9227704a674"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},

View file

@ -1,10 +1,19 @@
defmodule LivebookProto do defmodule LivebookProto do
@moduledoc false @moduledoc false
@mapping (for {_id, field_prop} <- LivebookProto.Request.__message_props__().field_props, alias LivebookProto.Request
@mapping (for {_id, field_prop} <- Request.__message_props__().field_props,
into: %{} do into: %{} do
{field_prop.type, field_prop.name_atom} {field_prop.type, field_prop.name_atom}
end) end)
def request_type(module), do: Map.fetch!(@mapping, module) def build_request_frame(%struct{} = data, id \\ -1) do
type = request_type(struct)
message = Request.new!(id: id, type: {type, data})
{:binary, Request.encode(message)}
end
defp request_type(module), do: Map.fetch!(@mapping, module)
end end

View file

@ -4,5 +4,6 @@ defmodule LivebookProto.Request do
oneof :type, 0 oneof :type, 0
field :session, 1, type: LivebookProto.SessionRequest, oneof: 0 field :id, 1, type: :int32
field :session, 2, type: LivebookProto.SessionRequest, oneof: 0
end end

View file

@ -4,6 +4,7 @@ defmodule LivebookProto.Response do
oneof :type, 0 oneof :type, 0
field :error, 1, type: LivebookProto.Error, oneof: 0 field :id, 1, type: :int32
field :session, 2, type: LivebookProto.SessionResponse, oneof: 0 field :error, 2, type: LivebookProto.Error, oneof: 0
field :session, 3, type: LivebookProto.SessionResponse, oneof: 0
end end

View file

@ -19,15 +19,19 @@ message SessionResponse {
} }
message Request { message Request {
int32 id = 1;
oneof type { oneof type {
SessionRequest session = 1; SessionRequest session = 2;
} }
} }
message Response { message Response {
oneof type { int32 id = 1;
Error error = 1;
SessionResponse session = 2; oneof type {
Error error = 2;
SessionResponse session = 3;
} }
} }

View file

@ -1,7 +1,10 @@
defmodule Livebook.WebSocket.ClientTest do defmodule Livebook.WebSocket.ClientTest do
use Livebook.EnterpriseIntegrationCase, async: true use Livebook.EnterpriseIntegrationCase, async: true
@app_version Mix.Project.config()[:version]
alias Livebook.WebSocket.Client alias Livebook.WebSocket.Client
alias LivebookProto.Request
describe "connect/2" do describe "connect/2" do
test "successfully authenticates the websocket connection", %{url: url, token: token} do test "successfully authenticates the websocket connection", %{url: url, token: token} do
@ -9,7 +12,7 @@ defmodule Livebook.WebSocket.ClientTest do
assert {:ok, conn, ref} = Client.connect(url, headers) assert {:ok, conn, ref} = Client.connect(url, headers)
assert {:ok, conn, websocket, :connected} = Client.receive(conn, ref) assert {:ok, conn, websocket, :connected} = Client.receive(conn, ref)
assert Client.disconnect(conn, websocket, ref) == :ok assert {:ok, _conn, _websocket} = Client.disconnect(conn, websocket, ref)
end end
test "rejects the websocket with invalid address", %{token: token} do test "rejects the websocket with invalid address", %{token: token} do
@ -23,7 +26,7 @@ defmodule Livebook.WebSocket.ClientTest do
headers = [{"X-Auth-Token", "foo"}] headers = [{"X-Auth-Token", "foo"}]
assert {:ok, conn, ref} = Client.connect(url, headers) assert {:ok, conn, ref} = Client.connect(url, headers)
assert {:error, _conn, response} = Client.receive(conn, ref) assert {:error, _conn, nil, response} = Client.receive(conn, ref)
assert response.status == 403 assert response.status == 403
@ -31,7 +34,7 @@ defmodule Livebook.WebSocket.ClientTest do
assert error =~ "the given token is invalid" assert error =~ "the given token is invalid"
assert {:ok, conn, ref} = Client.connect(url) assert {:ok, conn, ref} = Client.connect(url)
assert {:error, _conn, response} = Client.receive(conn, ref) assert {:error, _conn, nil, response} = Client.receive(conn, ref)
assert response.status == 401 assert response.status == 401
@ -39,4 +42,36 @@ defmodule Livebook.WebSocket.ClientTest do
assert error =~ "could not get the token from the connection" assert error =~ "could not get the token from the connection"
end end
end end
describe "send/2" do
setup %{url: url, token: token} do
headers = [{"X-Auth-Token", token}]
{:ok, conn, ref} = Client.connect(url, headers)
{:ok, conn, websocket, :connected} = Client.receive(conn, ref)
on_exit(fn -> Client.disconnect(conn, websocket, ref) end)
{:ok, conn: conn, websocket: websocket, ref: ref}
end
test "successfully sends a session message", %{
conn: conn,
websocket: websocket,
ref: ref,
user: %{id: id, email: email}
} do
session_request = LivebookProto.SessionRequest.new!(app_version: @app_version)
request = Request.new!(type: {:session, session_request})
frame = {:binary, Request.encode(request)}
assert {:ok, conn, websocket} = Client.send(conn, websocket, ref, frame)
assert {:ok, ^conn, ^websocket, %Client.Response{body: body}} =
Client.receive(conn, ref, websocket)
assert %{type: result} = LivebookProto.Response.decode(body)
assert {:session, %{id: _, user: %{id: ^id, email: ^email}}} = result
end
end
end end

View file

@ -1,47 +1,132 @@
defmodule Livebook.WebSocket.ServerTest do defmodule Livebook.WebSocket.ServerTest do
use Livebook.EnterpriseIntegrationCase, async: true use Livebook.EnterpriseIntegrationCase, async: true
alias Livebook.WebSocket.Server @app_version Mix.Project.config()[:version]
@moduletag :capture_log
describe "connect/2" do alias Livebook.WebSocket.Server
alias Livebook.WebSocket.Client.Response
setup do
Livebook.WebSocket.subscribe()
:ok
end
describe "connect" do
test "successfully authenticates the websocket connection", %{url: url, token: token} do test "successfully authenticates the websocket connection", %{url: url, token: token} do
headers = [{"X-Auth-Token", token}] headers = [{"X-Auth-Token", token}]
assert {:ok, pid} = Server.start_link() assert {:ok, conn} = Server.start_link(url, headers)
assert {:ok, :connected} = Server.connect(pid, url, headers) assert_receive {:ok, ^conn, _id, :connected}
assert Server.disconnect(pid) == :ok assert Server.connected?(conn)
end end
test "rejects the websocket with invalid address", %{token: token} do test "rejects the websocket with invalid address", %{token: token} do
headers = [{"X-Auth-Token", token}] headers = [{"X-Auth-Token", token}]
assert {:ok, pid} = Server.start_link() assert {:ok, conn} = Server.start_link("http://localhost:9999", headers)
refute Server.connected?(conn)
assert {:error, %Mint.TransportError{reason: :econnrefused}} =
Server.connect(pid, "http://localhost:9999", headers)
assert Server.disconnect(pid) == :ok
end end
test "rejects the websocket connection with invalid credentials", %{url: url} do test "rejects the websocket connection with invalid credentials", %{url: url} do
headers = [{"X-Auth-Token", "foo"}] headers = [{"X-Auth-Token", "foo"}]
assert {:ok, pid} = Server.start_link() assert {:ok, conn} = Server.start_link(url, headers)
assert {:error, response} = Server.connect(pid, url, headers)
assert response.status == 403 assert_receive {:error, ^conn, -1, response}
assert %Response{body: {:error, %{details: error}}, status: 403} = response
assert %{type: {:error, %{details: error}}} = LivebookProto.Response.decode(response.body)
assert error =~ "the given token is invalid" assert error =~ "the given token is invalid"
assert Server.close(conn) == :ok
assert {:error, response} = Server.connect(pid, url) assert {:ok, conn} = Server.start_link(url)
assert response.status == 401 assert_receive {:error, ^conn, -1, response}
assert %Response{body: {:error, %{details: error}}, status: 401} = response
assert %{type: {:error, %{details: error}}} = LivebookProto.Response.decode(response.body)
assert error =~ "could not get the token from the connection" assert error =~ "could not get the token from the connection"
assert Server.close(conn) == :ok
end
end
assert Server.disconnect(pid) == :ok describe "send_request/2" do
setup %{url: url, token: token} do
headers = [{"X-Auth-Token", token}]
{:ok, conn} = Server.start_link(url, headers)
assert_receive {:ok, ^conn, _id, :connected}
{:ok, conn: conn}
end
test "successfully sends a session request", %{
conn: conn,
user: %{id: id, email: email}
} do
session_request = LivebookProto.SessionRequest.new!(app_version: @app_version)
assert Server.send_request(conn, session_request) == :ok
assert_receive {:ok, ^conn, 0, {:session, response}}
assert %{id: _, user: %{id: ^id, email: ^email}} = response
end
end
describe "reconnect event" do
setup %{test: name} do
suffix = Ecto.UUID.generate() |> :erlang.phash2() |> to_string()
app_port = Enum.random(1000..9000) |> to_string()
{:ok, _} =
EnterpriseServer.start(name,
env: %{"ENTERPRISE_DB_SUFFIX" => suffix},
app_port: app_port
)
url = EnterpriseServer.url(name)
token = EnterpriseServer.token(name)
headers = [{"X-Auth-Token", token}]
assert {:ok, conn} = Server.start_link(url, headers)
assert_receive {:ok, ^conn, _id, :connected}
assert Server.connected?(conn)
on_exit(fn ->
EnterpriseServer.disconnect(name)
EnterpriseServer.drop_database(name)
end)
{:ok, conn: conn}
end
test "receives the disconnect message from websocket server", %{conn: conn, test: name} do
EnterpriseServer.disconnect(name)
assert_receive {:error, ^conn, 0, %Response{body: reason}}
assert %Mint.TransportError{reason: :closed} = reason
assert Process.alive?(conn)
refute Server.connected?(conn)
end
test "reconnects after websocket server is up", %{conn: conn, test: name} do
EnterpriseServer.disconnect(name)
assert_receive {:error, ^conn, 0, %Response{body: reason}}
assert %Mint.TransportError{reason: :closed} = reason
Process.sleep(1000)
refute Server.connected?(conn)
# Wait until the server is up again
assert EnterpriseServer.reconnect(name) == :ok
assert_receive {:ok, ^conn, 0, :connected}, 5000
assert Server.connected?(conn)
assert Server.close(conn) == :ok
refute Server.connected?(conn)
end end
end end
end end

View file

@ -10,7 +10,7 @@ defmodule Livebook.WebSocketTest do
headers = [{"X-Auth-Token", token}] headers = [{"X-Auth-Token", token}]
assert {:ok, connection, :connected} = WebSocket.connect(url, headers) assert {:ok, connection, :connected} = WebSocket.connect(url, headers)
assert WebSocket.disconnect(connection) == :ok assert {:ok, _connection} = WebSocket.disconnect(connection)
end end
test "rejects the web socket connection with invalid credentials", %{url: url} do test "rejects the web socket connection with invalid credentials", %{url: url} do
@ -18,33 +18,36 @@ defmodule Livebook.WebSocketTest do
assert {:error, connection, %{details: error}} = WebSocket.connect(url, headers) assert {:error, connection, %{details: error}} = WebSocket.connect(url, headers)
assert error =~ "the given token is invalid" assert error =~ "the given token is invalid"
assert WebSocket.disconnect(connection) == :ok assert {:ok, _connection} = WebSocket.disconnect(connection)
assert {:error, connection, %{details: error}} = WebSocket.connect(url) assert {:error, connection, %{details: error}} = WebSocket.connect(url)
assert error =~ "could not get the token from the connection" assert error =~ "could not get the token from the connection"
assert WebSocket.disconnect(connection) == :ok assert {:ok, _connection} = WebSocket.disconnect(connection)
end end
end end
describe "send_request/2" do describe "send_request/2" do
test "receives the session response from server", %{url: url, token: token, user: user} do setup %{url: url, token: token} do
headers = [{"X-Auth-Token", token}] headers = [{"X-Auth-Token", token}]
assert {:ok, %WebSocket.Connection{} = connection, :connected} = {:ok, %WebSocket.Connection{} = connection, :connected} = WebSocket.connect(url, headers)
WebSocket.connect(url, headers)
on_exit(fn -> WebSocket.disconnect(connection) end)
{:ok, connection: connection}
end
test "successfully sends a session message", %{
connection: connection,
user: %{id: id, email: email}
} do
session_request = LivebookProto.SessionRequest.new!(app_version: @app_version) session_request = LivebookProto.SessionRequest.new!(app_version: @app_version)
assert {:ok, %WebSocket.Connection{} = connection} = assert {:ok, %WebSocket.Connection{} = connection} =
WebSocket.send_request(connection, session_request) WebSocket.send_request(connection, session_request)
assert {:ok, connection, {:session, session_response}} = assert {:ok, ^connection, response} = WebSocket.receive_response(connection)
WebSocket.receive_response(connection) assert {:session, %{id: _, user: %{id: ^id, email: ^email}}} = response
assert WebSocket.disconnect(connection) == :ok
assert session_response.user.id == user.id
assert session_response.user.email == user.email
end end
end end
end end

View file

@ -2,53 +2,100 @@ defmodule Livebook.EnterpriseServer do
@moduledoc false @moduledoc false
use GenServer use GenServer
defstruct [:token, :user, :node, :port] defstruct [:token, :user, :node, :port, :app_port, :url, :env]
@name __MODULE__ @name __MODULE__
@timeout 10_000
def start do def start(name \\ @name, opts \\ []) do
GenServer.start(__MODULE__, [], name: @name) GenServer.start(__MODULE__, opts, name: name)
end end
def url do def url(name \\ @name) do
"http://localhost:#{app_port()}" GenServer.call(name, :fetch_url, @timeout)
end end
def token do def token(name \\ @name) do
GenServer.call(@name, :fetch_token) GenServer.call(name, :fetch_token, @timeout)
end end
def user do def user(name \\ @name) do
GenServer.call(@name, :fetch_user) GenServer.call(name, :fetch_user, @timeout)
end
def drop_database(name \\ @name) do
GenServer.cast(name, :drop_database)
end
def reconnect(name \\ @name) do
GenServer.cast(name, :reconnect)
end
def disconnect(name \\ @name) do
GenServer.cast(name, :disconnect)
end end
# GenServer Callbacks # GenServer Callbacks
@impl true @impl true
def init(_opts) do def init(opts) do
state = %__MODULE__{node: enterprise_node()} state = struct(__MODULE__, opts)
{:ok, state, {:continue, :start_enterprise}}
{:ok, %{state | node: enterprise_node()}, {:continue, :start_enterprise}}
end end
@impl true @impl true
def handle_continue(:start_enterprise, state) do def handle_continue(:start_enterprise, state) do
ensure_app_dir!()
prepare_database(state)
{:noreply, %{state | port: start_enterprise(state)}} {:noreply, %{state | port: start_enterprise(state)}}
end end
@impl true @impl true
def handle_call(:fetch_token, _from, state) do def handle_call(:fetch_token, _from, state) do
state = if _ = state.token, do: state, else: create_enterprise_token(state) state = if state.token, do: state, else: create_enterprise_token(state)
{:reply, state.token, state} {:reply, state.token, state}
end end
@impl true @impl true
def handle_call(:fetch_user, _from, state) do def handle_call(:fetch_user, _from, state) do
state = if _ = state.user, do: state, else: create_enterprise_user(state) state = if state.user, do: state, else: create_enterprise_user(state)
{:reply, state.user, state} {:reply, state.user, state}
end end
@impl true
def handle_call(:fetch_url, _from, state) do
state = if state.app_port, do: state, else: %{state | app_port: app_port()}
url = state.url || fetch_url(state)
{:reply, url, %{state | url: url}}
end
@impl true
def handle_cast(:drop_database, state) do
:ok = mix(state, ["ecto.drop", "--quiet"])
{:noreply, state}
end
def handle_cast(:reconnect, state) do
if state.port do
{:noreply, state}
else
{:noreply, %{state | port: start_enterprise(state)}}
end
end
def handle_cast(:disconnect, state) do
if state.port do
Port.close(state.port)
end
{:noreply, %{state | port: nil}}
end
# Port Callbacks # Port Callbacks
@impl true @impl true
@ -85,14 +132,10 @@ defmodule Livebook.EnterpriseServer do
end end
defp start_enterprise(state) do defp start_enterprise(state) do
ensure_app_dir!() env =
prepare_database() for {key, value} <- env(state), into: [] do
{String.to_charlist(key), String.to_charlist(value)}
env = [ end
{~c"MIX_ENV", ~c"livebook"},
{~c"LIVEBOOK_ENTERPRISE_PORT", String.to_charlist(app_port())},
{~c"LIVEBOOK_ENTERPRISE_DEBUG", String.to_charlist(debug?())}
]
args = [ args = [
"-e", "-e",
@ -118,13 +161,18 @@ defmodule Livebook.EnterpriseServer do
args: args args: args
]) ])
wait_on_start(port) wait_on_start(state, port)
end end
defp prepare_database do defp fetch_url(state) do
mix(["ecto.drop", "--quiet"]) port = state.app_port || app_port()
mix(["ecto.create", "--quiet"]) "http://localhost:#{port}"
mix(["ecto.migrate", "--quiet"]) end
defp prepare_database(state) do
:ok = mix(state, ["ecto.drop", "--quiet"])
:ok = mix(state, ["ecto.create", "--quiet"])
:ok = mix(state, ["ecto.migrate", "--quiet"])
end end
defp ensure_app_dir! do defp ensure_app_dir! do
@ -148,51 +196,52 @@ defmodule Livebook.EnterpriseServer do
System.get_env("ENTERPRISE_PORT", "4043") System.get_env("ENTERPRISE_PORT", "4043")
end end
defp debug? do defp debug do
System.get_env("ENTERPRISE_DEBUG", "false") System.get_env("ENTERPRISE_DEBUG", "false")
end end
defp wait_on_start(port) do defp wait_on_start(state, port) do
case :httpc.request(:get, {~c"#{url()}/public/health", []}, [], []) do url = state.url || fetch_url(state)
case :httpc.request(:get, {~c"#{url}/public/health", []}, [], []) do
{:ok, _} -> {:ok, _} ->
port port
{:error, _} -> {:error, _} ->
Process.sleep(10) Process.sleep(10)
wait_on_start(port) wait_on_start(state, port)
end end
end end
defp mix(args, opts \\ []) do defp mix(state, args) do
env = [ cmd_opts = [
{"MIX_ENV", "livebook"}, stderr_to_stdout: true,
{"LIVEBOOK_ENTERPRISE_PORT", app_port()}, env: env(state),
{"LIVEBOOK_ENTERPRISE_DEBUG", debug?()} cd: app_dir(),
into: IO.stream(:stdio, :line)
] ]
cmd_opts = [stderr_to_stdout: true, env: env, cd: app_dir()]
args = ["--erl", "-elixir ansi_enabled true", "-S", "mix" | args] args = ["--erl", "-elixir ansi_enabled true", "-S", "mix" | args]
cmd_opts = case System.cmd(elixir_executable(), args, cmd_opts) do
if opts[:with_return], {_, 0} -> :ok
do: cmd_opts, _ -> :error
else: Keyword.put(cmd_opts, :into, IO.stream(:stdio, :line)) end
end
if opts[:with_return] do defp env(state) do
case System.cmd(elixir_executable(), args, cmd_opts) do app_port = state.app_port || app_port()
{result, 0} ->
result
{message, status} -> env = %{
error(""" "MIX_ENV" => "livebook",
"LIVEBOOK_ENTERPRISE_PORT" => to_string(app_port),
"LIVEBOOK_ENTERPRISE_DEBUG" => debug()
}
#{message}\ if state.env do
""") Map.merge(env, state.env)
System.halt(status)
end
else else
{_, 0} = System.cmd(elixir_executable(), args, cmd_opts) env
end end
end end