Merge pull request #934 from poanetwork/924
Reconnect, rerequest, and resubscribe when websocket disconnectspull/939/merge
commit
c23c10520d
@ -0,0 +1,17 @@ |
||||
defmodule EthereumJSONRPC.WebSocket.WebSocketClient.Options do |
||||
@moduledoc """ |
||||
`t:EthereumJSONRPC.WebSocket.options/0` for `EthereumJSONRPC.WebSocket.WebSocketClient` `t:EthereumJSONRPC.Subscription.t/0` `transport_options`. |
||||
""" |
||||
|
||||
alias EthereumJSONRPC.Subscription |
||||
|
||||
@enforce_keys ~w(web_socket)a |
||||
defstruct ~w(web_socket event params)a |
||||
|
||||
@typedoc """ |
||||
* `web_socket` - the `t:pid/0` of the `EthereumJSONRPC.WebSocket.WebSocketClient`. |
||||
* `event` - the event that should be resubscribed to after disconnect. |
||||
* `params` - the parameters that should be used to customized `event` when resubscribing after disconnect. |
||||
""" |
||||
@type t :: %__MODULE__{web_socket: pid(), event: Subscription.event(), params: Subscription.params()} |
||||
end |
@ -0,0 +1,223 @@ |
||||
defmodule EthereumJSONRPC.WebSocket.WebSocketClientTest do |
||||
use ExUnit.Case |
||||
|
||||
alias EthereumJSONRPC.Subscription |
||||
alias EthereumJSONRPC.WebSocket.{Registration, WebSocketClient} |
||||
|
||||
import EthereumJSONRPC, only: [unique_request_id: 0] |
||||
|
||||
describe "ondisconnect/2" do |
||||
setup :example_state |
||||
|
||||
test "reconnects", %{state: state} do |
||||
assert {:reconnect, _} = WebSocketClient.ondisconnect({:closed, :remote}, state) |
||||
end |
||||
|
||||
test "treats in-progress unsubscribes as successful", %{state: state} do |
||||
subscription_id = 1 |
||||
|
||||
state = put_subscription(state, subscription_id) |
||||
|
||||
%Registration{from: {_, ref}} = |
||||
registration = registration(%{type: :unsubscribe, subscription_id: subscription_id}) |
||||
|
||||
state = put_registration(state, registration) |
||||
|
||||
assert {_, disconnected_state} = WebSocketClient.ondisconnect({:closed, :remote}, state) |
||||
|
||||
assert Enum.empty?(disconnected_state.request_id_to_registration) |
||||
assert Enum.empty?(disconnected_state.subscription_id_to_subscription_reference) |
||||
assert Enum.empty?(disconnected_state.subscription_reference_to_subscription) |
||||
assert Enum.empty?(disconnected_state.subscription_reference_to_subscription_id) |
||||
|
||||
assert_receive {^ref, :ok} |
||||
end |
||||
|
||||
test "keeps :json_rpc requests for re-requesting on reconnect", %{state: state} do |
||||
state = put_registration(state, %{type: :json_rpc, method: "eth_getBlockByNumber", params: [1, true]}) |
||||
|
||||
assert {_, disconnected_state} = WebSocketClient.ondisconnect({:closed, :remote}, state) |
||||
|
||||
assert Enum.count(disconnected_state.request_id_to_registration) == 1 |
||||
end |
||||
|
||||
test "keeps :subscribe requests for re-requesting on reconnect", %{state: state} do |
||||
state = put_registration(state, %{type: :subscribe}) |
||||
|
||||
assert {_, disconnected_state} = WebSocketClient.ondisconnect({:closed, :remote}, state) |
||||
|
||||
assert Enum.count(disconnected_state.request_id_to_registration) == 1 |
||||
end |
||||
end |
||||
|
||||
describe "websocket_handle/3" do |
||||
setup :example_state |
||||
|
||||
test "Jason.decode errors are broadcast to all subscribers", %{state: %WebSocketClient{url: url} = state} do |
||||
subscription_id = 1 |
||||
subscription_reference = make_ref() |
||||
subscription = subscription(%{url: url, reference: subscription_reference}) |
||||
state = put_subscription(state, subscription_id, subscription) |
||||
|
||||
assert {:ok, ^state} = WebSocketClient.websocket_handle({:text, ""}, nil, state) |
||||
assert_receive {^subscription, {:error, %Jason.DecodeError{}}} |
||||
end |
||||
end |
||||
|
||||
describe "websocket_terminate/3" do |
||||
setup :example_state |
||||
|
||||
test "broadcasts close to all subscribers", %{state: %WebSocketClient{url: url} = state} do |
||||
subscription_id = 1 |
||||
subscription_reference = make_ref() |
||||
subscription = subscription(%{url: url, reference: subscription_reference}) |
||||
state = put_subscription(state, subscription_id, subscription) |
||||
|
||||
assert {:ok, ^state} = WebSocketClient.websocket_handle({:text, ""}, nil, state) |
||||
assert_receive {^subscription, {:error, %Jason.DecodeError{}}} |
||||
end |
||||
end |
||||
|
||||
describe "reconnect" do |
||||
setup do |
||||
dispatch = :cowboy_router.compile([{:_, [{"/websocket", EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler, []}]}]) |
||||
{:ok, _} = :cowboy.start_http(EthereumJSONRPC.WebSocket.Cowboy, 100, [], env: [dispatch: dispatch]) |
||||
|
||||
on_exit(fn -> |
||||
:ranch.stop_listener(EthereumJSONRPC.WebSocket.Cowboy) |
||||
end) |
||||
|
||||
port = :ranch.get_port(EthereumJSONRPC.WebSocket.Cowboy) |
||||
|
||||
pid = start_supervised!({WebSocketClient, ["ws://localhost:#{port}/websocket", []]}) |
||||
|
||||
%{pid: pid, port: port} |
||||
end |
||||
|
||||
test "resubscribes", %{pid: pid, port: port} do |
||||
assert {:ok, subscription} = WebSocketClient.subscribe(pid, "newHeads", []) |
||||
|
||||
assert_receive {^subscription, {:ok, %{}}}, 500 |
||||
|
||||
assert :ok = :ranch.stop_listener(EthereumJSONRPC.WebSocket.Cowboy) |
||||
|
||||
refute_receive {^subscription, {:ok, %{}}}, 100 |
||||
|
||||
cowboy(port) |
||||
|
||||
assert_receive {^subscription, {:ok, %{}}}, 500 |
||||
end |
||||
|
||||
test "rerequests", %{pid: pid, port: port} do |
||||
first_params = [1] |
||||
|
||||
# json_rpc requests work before connection is closed |
||||
assert {:ok, ^first_params} = |
||||
WebSocketClient.json_rpc( |
||||
pid, |
||||
EthereumJSONRPC.request(%{id: :erlang.unique_integer(), method: "echo", params: first_params}) |
||||
) |
||||
|
||||
assert :ok = :ranch.stop_listener(EthereumJSONRPC.WebSocket.Cowboy) |
||||
|
||||
spawn_link(fn -> |
||||
Process.sleep(500) |
||||
cowboy(port) |
||||
end) |
||||
|
||||
second_params = [2] |
||||
|
||||
assert {:ok, ^second_params} = |
||||
WebSocketClient.json_rpc( |
||||
pid, |
||||
EthereumJSONRPC.request(%{id: :erlang.unique_integer(), method: "echo", params: second_params}) |
||||
) |
||||
end |
||||
end |
||||
|
||||
defp cowboy(0) do |
||||
dispatch = :cowboy_router.compile([{:_, [{"/websocket", EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler, []}]}]) |
||||
{:ok, _} = :cowboy.start_http(EthereumJSONRPC.WebSocket.Cowboy, 100, [], env: [dispatch: dispatch]) |
||||
:ranch.get_port(EthereumJSONRPC.WebSocket.Cowboy) |
||||
end |
||||
|
||||
defp cowboy(port) do |
||||
dispatch = :cowboy_router.compile([{:_, [{"/websocket", EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler, []}]}]) |
||||
{:ok, _} = :cowboy.start_http(EthereumJSONRPC.WebSocket.Cowboy, 100, [port: port], env: [dispatch: dispatch]) |
||||
port |
||||
end |
||||
|
||||
defp example_state(_) do |
||||
%{state: %WebSocketClient{url: "ws://example.com"}} |
||||
end |
||||
|
||||
defp put_registration(%WebSocketClient{} = state, %Registration{request: %{id: request_id}} = registration) do |
||||
%WebSocketClient{state | request_id_to_registration: %{request_id => registration}} |
||||
end |
||||
|
||||
defp put_registration(%WebSocketClient{} = state, map) when is_map(map) do |
||||
put_registration(state, registration(map)) |
||||
end |
||||
|
||||
defp put_subscription(%WebSocketClient{url: url} = state, subscription_id) when is_integer(subscription_id) do |
||||
subscription_reference = make_ref() |
||||
put_subscription(state, subscription_id, subscription(%{url: url, reference: subscription_reference})) |
||||
end |
||||
|
||||
defp put_subscription( |
||||
%WebSocketClient{url: url} = state, |
||||
subscription_id, |
||||
%Subscription{ |
||||
reference: subscription_reference, |
||||
transport_options: %EthereumJSONRPC.WebSocket{url: url} |
||||
} = subscription |
||||
) do |
||||
%WebSocketClient{ |
||||
state |
||||
| subscription_id_to_subscription_reference: %{subscription_id => subscription_reference}, |
||||
subscription_reference_to_subscription: %{subscription_reference => subscription}, |
||||
subscription_reference_to_subscription_id: %{subscription_reference => subscription_id} |
||||
} |
||||
end |
||||
|
||||
defp registration(%{type: :subscribe = type}) do |
||||
%Registration{ |
||||
type: type, |
||||
from: {self(), make_ref()}, |
||||
request: %{id: unique_request_id(), method: "eth_subscribe", params: ["newHeads"]} |
||||
} |
||||
end |
||||
|
||||
defp registration(%{type: :unsubscribe = type, subscription_id: subscription_id}) do |
||||
%Registration{ |
||||
type: type, |
||||
from: {self(), make_ref()}, |
||||
request: %{id: unique_request_id(), method: "eth_unsubscribe", params: [subscription_id]} |
||||
} |
||||
end |
||||
|
||||
defp registration(%{type: type, method: method, params: params}) do |
||||
%Registration{ |
||||
type: type, |
||||
from: {self(), make_ref()}, |
||||
request: %{id: unique_request_id(), method: method, params: params} |
||||
} |
||||
end |
||||
|
||||
defp subscription(%{reference: reference, url: url}) do |
||||
%Subscription{ |
||||
reference: reference, |
||||
subscriber_pid: self(), |
||||
transport: EthereumJSONRPC.WebSocket, |
||||
transport_options: %EthereumJSONRPC.WebSocket{ |
||||
url: url, |
||||
web_socket: WebSocketClient, |
||||
web_socket_options: %WebSocketClient.Options{ |
||||
web_socket: self(), |
||||
event: "newHeads", |
||||
params: [] |
||||
} |
||||
} |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,71 @@ |
||||
# See https://github.com/ninenines/cowboy/blob/1.1.x/examples/websocket/src/ws_handler.erl |
||||
defmodule EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler do |
||||
@behaviour :cowboy_websocket_handler |
||||
|
||||
defstruct subscription_id_set: MapSet.new(), |
||||
new_heads_timer_reference: nil |
||||
|
||||
def init({:tcp, :http}, _request, _opts) do |
||||
{:upgrade, :protocol, :cowboy_websocket} |
||||
end |
||||
|
||||
@impl :cowboy_websocket_handler |
||||
def websocket_init(_transport_name, request, _opts) do |
||||
{:ok, request, %__MODULE__{}} |
||||
end |
||||
|
||||
@impl :cowboy_websocket_handler |
||||
def websocket_handle( |
||||
{:text, text}, |
||||
request, |
||||
%__MODULE__{subscription_id_set: subscription_id_set, new_heads_timer_reference: new_heads_timer_reference} = |
||||
state |
||||
) do |
||||
json = Jason.decode!(text) |
||||
|
||||
case json do |
||||
%{"id" => id, "method" => "eth_subscribe", "params" => ["newHeads"]} -> |
||||
subscription_id = :erlang.unique_integer() |
||||
response = %{id: id, result: subscription_id} |
||||
frame = {:text, Jason.encode!(response)} |
||||
|
||||
new_heads_timer_reference = |
||||
case new_heads_timer_reference do |
||||
nil -> |
||||
{:ok, timer_reference} = :timer.send_interval(10, :new_head) |
||||
timer_reference |
||||
|
||||
_ -> |
||||
new_heads_timer_reference |
||||
end |
||||
|
||||
{:reply, frame, request, |
||||
%__MODULE__{ |
||||
state |
||||
| new_heads_timer_reference: new_heads_timer_reference, |
||||
subscription_id_set: MapSet.put(subscription_id_set, subscription_id) |
||||
}} |
||||
|
||||
%{"id" => id, "method" => "echo", "params" => params} -> |
||||
response = %{id: id, result: params} |
||||
frame = {:text, Jason.encode!(response)} |
||||
{:reply, frame, request, state} |
||||
end |
||||
end |
||||
|
||||
@impl :cowboy_websocket_handler |
||||
def websocket_info(:new_head, request, %__MODULE__{subscription_id_set: subscription_id_set} = state) do |
||||
frames = |
||||
Enum.map(subscription_id_set, fn subscription_id -> |
||||
response = %{method: "eth_subscription", params: %{result: %{}, subscription: subscription_id}} |
||||
{:text, Jason.encode!(response)} |
||||
end) |
||||
|
||||
{:reply, frames, request, state} |
||||
end |
||||
|
||||
@impl :cowboy_websocket_handler |
||||
def websocket_terminate(_reason, _request, _state) do |
||||
:ok |
||||
end |
||||
end |
Loading…
Reference in new issue