Skip to content

Commit

Permalink
Merge pull request #2 from zvkemp/specs
Browse files Browse the repository at this point in the history
specs with phoenix app (via docker-compose)
  • Loading branch information
zvkemp authored Sep 23, 2017
2 parents 1fbe493 + be5ea2a commit 39c9cd0
Show file tree
Hide file tree
Showing 32 changed files with 804 additions and 46 deletions.
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
phoenix:
build: spec_example
ports:
- "4000:4000"
command: mix phx.server
116 changes: 83 additions & 33 deletions lib/phoenix/socket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Phoenix
class Socket
include MonitorMixin
attr_reader :path, :socket, :inbox, :topic, :join_options
attr_accessor :verbose

def initialize(topic, join_options: {}, path: 'ws://localhost:4000/socket/websocket')
@path = path
Expand All @@ -17,17 +18,33 @@ def initialize(topic, join_options: {}, path: 'ws://localhost:4000/socket/websoc
@inbox_cond = new_cond
@thread_ready = new_cond
@topic_cond = new_cond
@join_ref = SecureRandom.uuid
reset_state_conditions
end

# Simulate a synchronous call over the websocket
def request_reply(event:, payload: {})
# TODO: use a queue/inbox/outbox here instead
def request_reply(event:, payload: {}, timeout: 5) # timeout in seconds
ref = SecureRandom.uuid
ensure_thread
synchronize do
ensure_connection
@topic_cond.wait_until { @topic_joined }
EM.next_tick { socket.send({ topic: topic, event: event, payload: payload, ref: ref }.to_json) }
inbox_cond.wait_until { inbox.key?(ref) || @dead }
log [event, ref]

# Ruby's condition variables only support timeout on the basic 'wait' method;
# This should behave roughly as if wait_until also support a timeout:
# `inbox_cond.wait_until(timeout) { inbox.key?(ref) || @dead }
#
# Note that this serves only to unblock the main thread, and should not halt execution of the
# socket connection. Therefore, there is a possibility that the inbox may pile up with
# unread messages if a lot of timeouts are encountered. A self-sweeping inbox will
# be implemented to prevent this.
ts = Time.now
loop do
inbox_cond.wait(timeout) # waits until time expires or signaled
break if inbox.key?(ref) || @dead
raise 'timeout' if timeout && Time.now > (ts + timeout)
end
inbox.delete(ref) { raise "reply #{ref} not found" }
end
end
Expand All @@ -36,12 +53,17 @@ def request_reply(event:, payload: {})

attr_reader :inbox_cond, :thread_ready

def ensure_thread
def log(msg)
return unless @verbose
puts "[#{Thread.current[:id]}] #{msg} (#@topic_joined)"
end

def ensure_connection
connection_alive? or synchronize do
spawn_thread
thread_ready.wait(3)
if @dead
@spawning = false
@spawned = false
raise 'dead connection timeout'
end
end
Expand All @@ -51,45 +73,73 @@ def connection_alive?
@ws_thread&.alive? && !@dead
end

def handle_close(event)
synchronize do
reset_state_conditions
inbox_cond.signal
thread_ready.signal
end
end

def reset_state_conditions
@dead = true # no EM thread active, or the connection has been closed
@socket = nil # the Faye::Websocket::Client instance
@spawned = false # The thread running (or about to run) EventMachine has been launched
@join_ref = SecureRandom.uuid # unique id that Phoenix uses to identify the socket <-> channel connection
@topic_joined = false # The initial join request has been acked by the remote server
end

def handle_message(event)
data = JSON.parse(event.data)
log event.data
synchronize do
if data['event'] == 'phx_close'
log('handling close from message')
handle_close(event)
elsif data['ref'] == @join_ref && data['event'] == 'phx_error'
# NOTE: For some reason, on errors phx will send the join ref instead of the message ref
inbox_cond.broadcast
elsif data['ref'] == @join_ref
log ['join_ref', @join_ref]
@topic_joined = true
@topic_cond.broadcast
else
inbox[data['ref']] = data
inbox_cond.broadcast
end
end
end

def handle_open(event)
log 'open'
socket.send({ topic: topic, event: "phx_join", payload: join_options, ref: @join_ref, join_ref: @join_ref }.to_json)
synchronize do
@dead = false
thread_ready.broadcast
end
end

def spawn_thread
return if @spawning
puts 'spawn_thread'
@spawning = true
return if @spawned || connection_alive?
log 'spawning...'
@spawned = true
@ws_thread = Thread.new do
Thread.current[:id] = "WSTHREAD_#{SecureRandom.hex(3)}"
EM.run do
synchronize do
log 'em.run.sync'
@socket = Faye::WebSocket::Client.new(path)
socket.on :open do |event|
p [:open]
socket.send({ topic: topic, event: "phx_join", payload: join_options, ref: @join_ref }.to_json)
synchronize do
@dead = false
@spawning = false
thread_ready.broadcast
end
handle_open(event)
end

socket.on :message do |event|
data = JSON.parse(event.data)
synchronize do
if data['ref'] == @join_ref
@topic_joined = true
@topic_cond.broadcast
else
inbox[data['ref']] = data
inbox_cond.broadcast
end
end
handle_message(event)
end

socket.on :close do |event|
p [:close, event.code, event.reason]
synchronize do
@socket = nil
@dead = true
inbox_cond.signal
thread_ready.signal
end
log [:close, event.code, event.reason]
handle_close(event)
EM::stop
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/phoenix/socket/version.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Rb
module Phoenix
module Socket
VERSION = "0.1.1"
VERSION = "0.2.0"
end
end
end
69 changes: 69 additions & 0 deletions spec/phoenix/socket_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
require 'pry-byebug'
require "spec_helper"

RSpec.describe Phoenix::Socket do
it "has a version number" do
expect(Rb::Phoenix::Socket::VERSION).not_to be nil
end

let(:socket_handler) do
Phoenix::Socket.new("rspec:default", path: "ws://#{`docker-machine ip`.strip}:4000/socket/websocket")
end

it 'echoes back the requested payload' do
response = socket_handler.request_reply(event: :echo, payload: { foo: :bar })
expect(response['event']).to eq('phx_reply')
expect(response['topic']).to eq('rspec:default')
expect(response['payload']).to eq({ 'status' => 'ok', 'response' => { 'foo' => 'bar' }})
end

it 'handles concurrent threads' do
# NOTE: This is a proof of concept, and is WAY more than anyone would ever want/need
# to spawn in a runtime process. I.e. don't do this. If one at a time isn't enough,
# do it in Elixir. Although you should probably also ask yourself why you need 500 processes
# to share a single websocket.
responses = (0..500).map do |n|
Thread.new do
Thread.current[:id] = n
socket_handler.request_reply(event: :echo, payload: { n: n }, timeout: nil)
end
end.map(&:value)

responses.each_with_index do |response, index|
expect(response['payload']).to eq({ 'status' => 'ok', 'response' => { 'n' => index }})
end
end

describe 're-spawn' do
20.times do |n|
# NOTE: Running this multiple times because there was some unexpected thread scheduling
# behavior that came up during development. Generally came up at least 1 / 5 times;
# running 20 for safety.
it "handles termination but respawns the connection handler" do
expect { socket_handler.request_reply(event: :unsupported) }.to raise_error(RuntimeError, /reply .* not found/)
expect(socket_handler.request_reply(event: :echo)['payload']['status']).to eq('ok')

# Ensure dead handler threads have been cleaned up; we should have at most
# the live main thread and a live respawned handler
expect(Thread.list.count).to be < 3
end
end
end

describe 'timeout handling' do
specify 'small sleep' do
response = socket_handler.request_reply(event: :sleep, payload: { ms: 50 })
expect(response.dig('payload', 'status')).to eq('ok')
end

specify 'long sleep' do
response = socket_handler.request_reply(event: :sleep, payload: { ms: 1000 })
expect(response.dig('payload', 'status')).to eq('ok')
end

specify 'sleep exceeding timeout' do
expect { socket_handler.request_reply(timeout: 0.5, event: :sleep, payload: { ms: 1000 }) }.to raise_error(RuntimeError, /timeout/)
expect { socket_handler.request_reply(timeout: 0.5, event: :sleep, payload: { ms: 10 }) }.not_to raise_error(RuntimeError, /timeout/)
end
end
end
11 changes: 0 additions & 11 deletions spec/rb/phoenix/socket_spec.rb

This file was deleted.

2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
require "bundler/setup"
require "rb/phoenix/socket"
require "phoenix/socket"

RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
Expand Down
5 changes: 5 additions & 0 deletions spec_example/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# App artifacts
/_build
/db
/deps
/*.ez
16 changes: 16 additions & 0 deletions spec_example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# App artifacts
/_build
/db
/deps
/*.ez

# Generated on crash by the VM
erl_crash.dump

# Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control.
#
# Alternatively, you may comment the line below and commit the
# secrets files as long as you replace their contents by environment
# variables.
/config/*.secret.exs
15 changes: 15 additions & 0 deletions spec_example/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM elixir:1.5.1-alpine

ENV LANG=C.UTF-8
WORKDIR /usr/src/app

ENV MIX_ENV=dev
RUN mix local.hex --force && mix local.rebar --force

COPY mix* ./
RUN mix deps.get && mix compile

COPY . ./
RUN mix deps.get && mix compile

CMD ["mix", "phx.server"]
18 changes: 18 additions & 0 deletions spec_example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SpecExample

To start your Phoenix server:

* Install dependencies with `mix deps.get`
* Start Phoenix endpoint with `mix phx.server`

Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.

Ready to run in production? Please [check our deployment guides](http://www.phoenixframework.org/docs/deployment).

## Learn more

* Official website: http://www.phoenixframework.org/
* Guides: http://phoenixframework.org/docs/overview
* Docs: https://hexdocs.pm/phoenix
* Mailing list: http://groups.google.com/group/phoenix-talk
* Source: https://github.com/phoenixframework/phoenix
23 changes: 23 additions & 0 deletions spec_example/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
use Mix.Config

# Configures the endpoint
config :spec_example, SpecExampleWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "7Y7mZ/GEwoVarXDx6yNFzs6nXyuh3r93hggda16yzFtarWlLCillTwenNrNjjyra",
render_errors: [view: SpecExampleWeb.ErrorView, accepts: ~w(json)],
pubsub: [name: SpecExample.PubSub,
adapter: Phoenix.PubSub.PG2]

# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env}.exs"
37 changes: 37 additions & 0 deletions spec_example/config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use Mix.Config

# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with brunch.io to recompile .js and .css sources.
config :spec_example, SpecExampleWeb.Endpoint,
http: [port: 4000],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: []

# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# command from your terminal:
#
# openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem
#
# The `http:` config above can be replaced with:
#
# https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.

# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"

# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
Loading

0 comments on commit 39c9cd0

Please sign in to comment.