Using puma, faye-websocket-ruby and eventmachine, I am trying to implement a WebSocket server that is extended to support channels using redis.rb. Each client will supply a channel using a route currently in development as: "/C#{random number}". All of this logic needs to reside in the server, as the clients will be microprocessor-based Python systems that will not support higher-level libraries.
My code was based on ruby-websockets-chat-demo, as a starting point. One major change was to configure it to support multiple channels during WebSocket "on open".
The code is working when run normally. However, often when one client drops, the server hangs until it is restarted. I am trying to resolve that issue, but have not been able to do so so far. Initially, Heroku would throw an H12 timeout. I've implemented rack-timeout. I've tried rescuing timeouts within the server, but those never fire. I've implemented an "on error" event within the server but it never fires. Most often, the server just goes away until restarted. The client should fend for itself, but I need the server to recover and continue.
config.ru:
require './app'
require './middlewares/myserver_backend'
require 'rack-timeout'
use Rack::Timeout, service_timeout: 20, wait_timeout: 30, wait_overtime: 60, service_past_wait: false
use Myserver::MyserverBackend
run Myserver::App
Rack middleware "backend":
%w(faye/websocket thread redis json erb).each { |m| require m }
module Myserver
class MyserverBackend
KEEPALIVE_TIME = ENV['KEEPALIVE_TIME']
def initialize(app)
#app = app
#clients = []
#uri = URI.parse(ENV["REDISCLOUD_URL"])
#redis = Redis.new(host: #uri.host, port: #uri.port, password: #uri.password)
end
def call(env)
begin
if Faye::WebSocket.websocket?(env)
ws = Faye::WebSocket.new(env, nil, {ping: KEEPALIVE_TIME})
ws.on :open do |event|
channel = URI.parse(event.target.url).path[1..URI.parse(event.target.url).path.length]
Thread.new do
redis_sub = Redis.new(host: #uri.host, port: #uri.port, password: #uri.password)
redis_sub.subscribe(channel) do |on|
on.message do |message_channel, message|
puts "MyserverBackend>> Redis message received on channel:#{message_channel}; Message is:#{message};"
#clients.each { |clients_ws, clients_channel| clients_ws.send(message) if clients_channel == message_channel }
end
end
end
#clients << [ws, channel]
#clients.each do |clients_ws, clients_channel|
puts "MyserverBackend>> Client:#{clients_ws.object_id}; Channel:#{clients_channel};"
end
end
ws.on :message do |event|
#clients.each do |clients_ws, clients_channel|
if clients_ws == ws
puts "MyserverBackend>> Websocket message received on channel:#{clients_channel}; Message is:#{event.data};"
#redis.publish(clients_channel, sanitize(event.data))
end
end
end
ws.on :close do |event|
# Close all channels for this client first
# ws gives a channel which we use to identify it here, but we're closing all of those that are open
#clients.each { |clients_ws, clients_channel| #redis.unsubscribe(clients_channel) if clients_ws == ws }
#clients.delete_if { |clients_ws, clients_channel| clients_ws == ws }
channel = URI.parse(event.target.url).path[1..URI.parse(event.target.url).path.length]
puts "MyserverBackend>> Websocket closure for:#{channel}; Event code:#{event.code} Event reason:#{event.reason};"
ws = nil
end
ws.on :error do |event|
puts "Error raised:#{nil}; ws:#{ws.object_id};"
ws.close unless ws.nil?
end
# Return async Rack response
ws.rack_response
else
#app.call(env)
end
rescue Rack::Timeout::RequestTimeoutError, Rack::Timeout::RequestExpiryError => exception
puts "Exception raised:#{exception}; ws:#{ws.object_id};"
ws.close(code=4999, reason=9999) unless ws.nil?
# ensure is executed immediately so it doesn't help...
end
end
private
def sanitize(message)
json = JSON.parse(message)
json.each { |key, value| json[key] = ERB::Util.html_escape(value) }
JSON.generate(json)
end
end
end
The Sinatra "frontend":
# https://github.com/heroku-examples/ruby-websockets-chat-demo
require 'rubygems'
require 'bundler'
require 'sinatra/base'
ENV['RACK_ENV'] ||= 'development'
Bundler.require
$: << File.expand_path('../', __FILE__)
$: << File.expand_path('../lib', __FILE__)
Dir["./lib/*.rb", "./lib/**/*.rb"].each { |file| require file }
env = ENV['OS'] == 'Windows_NT' ? 'development' : ENV['RACK_ENV']
module Myserver
class App < Sinatra::Base
get "/" do
erb :"index.html"
end
get "/assets/js/application.js" do
content_type :js
#scheme = env == "production" ? "wss://" : "ws://"
erb :"application.js"
end
end
end
The test client:
# https://github.com/faye/faye-websocket-ruby/issues/52
# https://github.com/faye/faye-websocket-ruby
%w(bundler/setup faye/websocket eventmachine json).each { |m| require m }
Dir["./lib/*.rb", "./lib/**/*.rb"].each { |file| require file }
class ClientWs
def self.em_run
env = ENV['OS'] == 'Windows_NT' ? 'development' : ENV['RACK_ENV']
EM.run do
uri = 'myserver.herokuapp.com'
#uri = 'localhost' if env == 'development'
channel = "C#{rand(999999999999).to_s}"
url = uri == 'localhost' ? "ws://#{uri}:3000/#{channel}" : "ws://#{uri}/#{channel}"
#ws = Faye::WebSocket::Client.new(url)
start = Time.now
count ||= 0
timer = EventMachine.add_periodic_timer(5+rand(5)) {
count += 1
send({'PING': channel, 'COUNT': count.to_s})
}
#ws.on :open do |event|
puts "{'OPEN':#{channel}}"
ClientWs.send({'OPEN': channel})
end
#ws.on :message do |event|
#ip_address ||= Addrinfo.ip(URI.parse(event.target.url).host).ip_address
begin
parsed = JSON.parse event.data
rescue => e
puts ">>>> [Error! Failed to parse JSON]"
puts ">>>> [#{e.message}]"
puts ">>>> #{event.data}"
end
puts ">> #{#ip_address}:#{channel}:#{event.data};"
end
#ws.on :close do |event|
timer.cancel
stop = Time.now - start
puts "#{stop} seconds;"
p [:close, event.code, event.reason]
ws = nil
ClientWs.em_run
end
end
end
def self.send message
payload = message.is_a?(Hash) ? message : {payload: message}
#ws.send(payload.to_json)
end
end
ClientWs.em_run
The Gemfile.lock:
GEM
remote: https://rubygems.org/
specs:
activesupport (4.2.5.1)
i18n (~> 0.7)
json (~> 1.7, >= 1.7.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
eventmachine (1.2.0.1-x86-mingw32)
faye-websocket (0.10.4)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
i18n (0.7.0)
json (1.8.3)
json_pure (1.8.3)
minitest (5.9.0)
multi_json (1.12.1)
oj (2.16.1)
permessage_deflate (0.1.3)
progressbar (0.21.0)
puma (3.4.0)
rack (1.6.4)
rack-protection (1.5.3)
rack
rack-timeout (0.4.2)
rake (11.2.2)
redis (3.3.0)
rollbar (2.11.5)
multi_json
sinatra (1.4.7)
rack (~> 1.5)
rack-protection (~> 1.4)
tilt (>= 1.3, < 3)
thread_safe (0.3.5)
tilt (2.0.5)
tzinfo (1.2.2)
thread_safe (~> 0.1)
websocket-driver (0.6.4)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
PLATFORMS
x86-mingw32
DEPENDENCIES
activesupport (= 4.2.5.1)
bundler
faye-websocket
json_pure
oj (~> 2.16.0)
permessage_deflate
progressbar
puma
rack
rack-timeout
rake
redis (>= 3.2.0)
rollbar
sinatra
RUBY VERSION
ruby 2.2.4p230
BUNDLED WITH
1.12.5
What client sees when attempting to connect to stalled server:
ruby client.rb
20.098119 seconds;
[:close, 1002, "Error during WebSocket handshake: Unexpected response code: 500"]
20.07921 seconds;
[:close, 1002, "Error during WebSocket handshake: Unexpected response code: 500"]
20.075731 seconds;
[:close, 1002, "Error during WebSocket handshake: Unexpected response code: 500"]
config/puma.rb:
env = ENV['OS'] == 'Windows_NT' ? 'development' : ENV['RACK_ENV']
if env.nil? || env == 'development' || env == 'test'
concurrency = 0 # Set to zero to ensure single mode, not clustered mode
max_threads = 1
end
# WEB_CONCURRENCY and RAILS_MAX_THREADS == 1 in Heroku for now.
concurrency ||= (ENV['WEB_CONCURRENCY'] || 2)
max_threads ||= (ENV['RAILS_MAX_THREADS'] || 5)
worker_timeout 15
workers Integer(concurrency)
threads_count = Integer(max_threads)
threads threads_count, threads_count
#preload_app!
rackup DefaultRackup
port ENV['PORT'] || 3000
environment ENV['RACK_ENV'] || 'development'
What I needed to do was complete the server's "on close" event. It needed to clean everything up and then restart itself, which it was not doing.
I don't like this as the final answer, however. The question would be, why is the server closing up shop, terminating and restarting just because a client dropped? Isn't there a cleaner way to sweep away the detritus of a failed client? Follow up: This fix does answer this particular question, in any case, in that completing onclose resolved the stated problem. Further enhancements threaded the client's WebSocket events in addition to the Redis events such that onclose only closes the client and not the server.
The new event is:
ws.on :close do |event|
if #debug
puts "MyserverBackend>> Close entered. Last error:#{$!.class}:#{$!.to_s};Module:#{$0};Line:#{$.};"
$#.each { |backtrace| puts backtrace }
exit
end
#clients.each do |clients_ws, clients_channel|
begin
#redis.unsubscribe(clients_channel)
rescue RuntimeError => exception
unless exception.to_s == "Can't unsubscribe if not subscribed."
raise
end
false
end
end
#clients.delete_if { |clients_ws, clients_channel| clients_ws == ws }
channel = URI.parse(event.target.url).path[1..URI.parse(event.target.url).path.length]
puts "MyserverBackend>> Websocket closure for:#{channel}; Event code:#{event.code} Event reason:#{event.reason};"
ws = nil
app = Myserver::App
myserver = MyserverBackend.new(app)
myserver
end
Related
I spent about two days without success trying to send custom headers with Rack::Test. I just can't send any header into my app.
I found a lot of examples in the net with the similar code -- method( address, body, headers ), but for me they don't work at all.
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
# grep rack Gemfile.lock
rack (2.0.4)
rack-protection (2.0.1)
rack
rack-test (1.0.0)
rack (>= 1.0, < 3)
rack-protection (>= 1.5.0)
rack (~> 2.0)
rack-protection (= 2.0.1)
rack-test
The code in app (sinatra):
$log = Logger.new STDERR
class MyApi < Sinatra::Application
before do
$log.debug{ "Headers: #{ headers.keys.inspect }" }
end
get '/offers' do
#... some code
end
post '/offers' do
# .. some another code
end
end
spec/api_spec.rb
RSpec.describe MyApi, '/offers' do
include Rack::Test::Methods
def app
MyApi
end
context 'авторизация' do
it 'правильная - get с токеном' do
get '/offers', nil, {
'X-Auth' => 'some key'
}
$log.debug{ "ENV was: '#{ last_request.env.keys }'" }
end
it 'правильная - post с токеном' do
post '/offers', '', {
'Content-Type' => 'application/json; charset: utf-8',
'X-Auth' => 'some long key'
}
end
end
end
Output contains for both tests:
Headers: ["Content-Type"]
...
ENV was: '["rack.version", "rack.input", "rack.errors",
"rack.multithread", "rack.multiprocess", "rack.run_once",
"REQUEST_METHOD", "SERVER_NAME", "SERVER_PORT", "QUERY_STRING",
"PATH_INFO", "rack.url_scheme", "HTTPS", "SCRIPT_NAME", "CONTENT_LENGTH",
"rack.test", "REMOTE_ADDR", "X-Auth", "HTTP_HOST", "HTTP_COOKIE",
"sinatra.commonlogger", "rack.logger", "rack.request.query_string",
"rack.request.query_hash"]'
This code is working:
get '/offers', nil, { 'X-Auth' => 'long key' }
And this also is correct:
header 'X-Auth', 'some key'
get '/offers'
I changed the manner I look for that header in app:
request.get_header('HTTP_X_AUTH') || request.env['X-Auth']
The first - get_header was triggered when I call my app with curl, the last - request.env - when in tests.
You have to use Rack::Test::Methods#header (which delegates to Rack::Test::Session#header):
it 'правильная - get с токеном' do
header 'X-Auth', 'some key'
get '/offers'
$log.debug{ "ENV was: '#{ last_request.env.keys }'" }
end
I would like to know to to pause a Root Fiber in ruby (if possible).
I have this Sinatra app, and I am making async calls to an external API with EventMachine.
I don't want to respond to the client until the api responds me.
For example, sleeping the Root Fiber in Sinatra until the EventMachine callback wake it up.
Thanks.
get '/some/route/' do
fib = Fiber.current
req = EM::SomeNonBlokingLib.request
req.callback do |response|
fib.resume(response)
end
req.errback do |err|
fib.resume(err)
end
Fiber.yield
end
EDIT
In your case you should spawn a Fiber for each request. So. Firstly create Rack config file and add some magick:
# config.ru
BOOT_PATH = File.expand_path('../http.rb', __FILE__)
require BOOT_PATH
class FiberSpawn
def initialize(app)
#app = app
end
def call(env)
fib = Fiber.new do
res = #app.call(env)
env['async.callback'].call(res)
end
EM.next_tick{ fib.resume }
throw :async
end
end
use FiberSpawn
run Http
Then your http Sinatra application:
# http.rb
require 'sinatra'
require 'fiber'
class Http < Sinatra::Base
get '/' do
f = Fiber.current
EM.add_timer(1) do
f.resume("Hello World")
end
Fiber.yield
end
end
Now you could run it under thin for example:
> thin start -R config.ru
Then if you will visit locakhost:3000 you'll see your Hello World message
I'm building a Ruby app that runs both an EM::WebSocket server as well as a Sinatra server. Individually, I believe both of these are equipped to handle a SIGINT. However, when running both in the same app, the app continues when I press Ctrl+C. My assumption is that one of them is capturing the SIGINT, preventing the other from capturing it as well. I'm not sure how to go about fixing it, though.
Here's the code in a nutshell:
require 'thin'
require 'sinatra/base'
require 'em-websocket'
EventMachine.run do
class Web::Server < Sinatra::Base
get('/') { erb :index }
run!(port: 3000)
end
EM::WebSocket.start(port: 3001) do |ws|
# connect/disconnect handlers
end
end
I had the same issue. The key for me seemed to be to start Thin in the reactor loop with signals: false:
Thin::Server.start(
App, '0.0.0.0', 3000,
signals: false
)
This is complete code for a simple chat server:
require 'thin'
require 'sinatra/base'
require 'em-websocket'
class App < Sinatra::Base
# threaded - False: Will take requests on the reactor thread
# True: Will queue request for background thread
configure do
set :threaded, false
end
get '/' do
erb :index
end
end
EventMachine.run do
# hit Control + C to stop
Signal.trap("INT") {
puts "Shutting down"
EventMachine.stop
}
Signal.trap("TERM") {
puts "Shutting down"
EventMachine.stop
}
#clients = []
EM::WebSocket.start(:host => '0.0.0.0', :port => '3001') do |ws|
ws.onopen do |handshake|
#clients << ws
ws.send "Connected to #{handshake.path}."
end
ws.onclose do
ws.send "Closed."
#clients.delete ws
end
ws.onmessage do |msg|
puts "Received message: #{msg}"
#clients.each do |socket|
socket.send msg
end
end
end
Thin::Server.start(
App, '0.0.0.0', 3000,
signals: false
)
end
I downgrade thin to version 1.5.1 and it just works. Wired.
I am just starting to figure how to create unit tests using "test/unit". I copied the code generated by Selenium IDE and paste it into my Ruby test method.
But when running it with Ruby.exe, for some reason it is throwing an error:
Finished tests in 31.835891s, 0.0314 tests/s, 0.0942 assertions/s.
1) Error:
test_method(MyTestClass):
NameError: uninitialized constant Test::Unit::AssertionFailedError
teste-noticia.rb:30:in `rescue in verify'
teste-noticia.rb:29:in `verify'
teste-noticia.rb:42:in `test_method'
1 tests, 3 assertions, 0 failures, 1 errors, 0 skips
Anyone could help me to how assert correctly desired strings? Any good practice is welcome ;-).
Here is the code:
# encoding: utf-8
require "selenium-webdriver"
require "test/unit"
class MyTestClass < Test::Unit::TestCase
def setup
#driver = Selenium::WebDriver.for :firefox
#base_url = "http://www.yoursite.com"
#driver.manage.timeouts.implicit_wait = 30
#verification_errors = []
#wait = Selenium::WebDriver::Wait.new :timeout => 10
end
def teardown
#driver.quit
assert_equal [], #verification_errors
end
def element_present?(how, what)
#driver.find_element(how, what)
true
rescue Selenium::WebDriver::Error::NoSuchElementError
false
end
def verify(&blk)
yield
rescue Test::Unit::AssertionFailedError => ex
#verification_errors << ex
end
#your test methods go here
def test_method
#driver.get(#base_url + "/my-desired-path")
verify { assert_equal "Obama wins and will move U.S. forward", #driver.find_element(:css, "h1").text }
end
end
EDIT
My local gems:
C:\Users\wmj>gem list
*** LOCAL GEMS ***
addressable (2.3.2)
bigdecimal (1.1.0)
childprocess (0.3.6)
ffi (1.1.5 x86-mingw32)
io-console (0.3)
json (1.5.4)
libwebsocket (0.1.5)
minitest (2.5.1)
multi_json (1.3.7)
rake (0.9.2.2)
rdoc (3.9.4)
rubyzip (0.9.9)
selenium-webdriver (2.26.0)
test-unit (2.5.2)
I believe the issue is that you have required the 'minitest' gem, but are trying to use the classes in the 'test-unit' gem. 'Minitest' is installed by default in Ruby 1.9 instead of 'Test-Unit' (which was installed by default in 1.8). Minitest is only partially backwards compatible with Test-Unit.
Possible solutions:
Switch to Minitest:
It is the Test::Unit::AssertionFailedError in the verify method that is causing the exception. You could change it to the minitest equivalent, which appears to be MiniTest::Assertion. So your verify method would become:
def verify(&blk)
yield
rescue MiniTest::Assertion => ex
#verification_errors << ex
end
Use Test-Unit instead of Minitest:
Assuming you have the test-unit gem already installed (gem install test-unit), manually specify that you want to use that gem when doing require 'test/unit':
gem "test-unit"
require "test/unit"
As a part of a larger application, I've got to setup some basic rate-limiting of outgoing requests across multiple workers. The idea behind this is rather simple: by publishing a "token"-message with the "immediate" flag, this message is automatically discarded if nobody is waiting for it. By having workers only subscribing to the token-queue just before sending an outgoing request, tokens are not "saved up", and each token is available for use only once. I thought this rather elegant.
Unfortunately, adding and removing subscribers is not entirely stable. I've setup a full example over at https://gist.github.com/1263921/ebdafa067ca09514183d3fc5d6e43c7094fc2733. The code is below:
require 'bundler'
Bundler.setup
require 'amqp'
puts "single-message consumer listening to rapid producer"
QUEUE_NAME = 'test.rapid-queue-unsubscription'
PRODUCE_RATE = 1.0/10
CONSUME_RATE = 1.0/9
def start_producer
exchange = AMQP::Exchange.new(AMQP::Channel.new, :direct, "")
n = 0
EM::PeriodicTimer.new(PRODUCE_RATE) do
message = "msg #{n}"
exchange.publish(message,
:immediate => true, # IMPORTANT, messages are dropped if nobody listening now
:routing_key => QUEUE_NAME)
puts "> PUT #{message}"
n += 1
end
end
def start_consumer
EM::PeriodicTimer.new(CONSUME_RATE) do
started = Time.now
AMQP::Channel.new do |channel_consumer|
channel_consumer.prefetch(1)
tick_queue = channel_consumer.queue(QUEUE_NAME)
consumer = AMQP::Consumer.new(channel_consumer, tick_queue, nil, exclusive = false, no_ack = true)
consumer.on_delivery do |_, message|
took = Time.now - started
puts "< GET #{message} [waited #{took.round(2)}s][#{(1.0/took).round(2)} reqs/sec]"
consumer.cancel
channel_consumer.close
end
consumer.consume
end
end
end
EM.run do
EM.set_quantum(50)
start_producer
start_consumer
end
Running that example for a few minutes ends up dying with one of two errors:
amq-client-0.8.3/lib/amq/client/async/consumer.rb:246:in `block in
<class:Consumer>': undefined method `handle_delivery' for
nil:NilClass (NoMethodError)
amq-client-0.8.3/lib/amq/client/async/adapter.rb:244:in
`send_frame': Trying to send frame through a closed connection.
Frame is #<AMQ::Protocol::MethodFrame:0x007fa6d29a35f0
#payload="\x00<\x00(\x00\x00\x00\x1Ftest.rapid-queue-unsubscription\x02",
#channel=1> (AMQ::Client::ConnectionClosedError)
The first error is due to the subscriber having been removed, but a message is still delivered to it, and the amq-client library never expects this to happen. The second error is from the publisher, which all of a sudden has a closed connection.
What am I missing to make this consistently work as expected?
Versions used:
OS X 10.7.1
ruby 1.9.2p312 (2011-08-11 revision 32926) [x86_64-darwin11.1.0]
RabbitMQ 2.6.1
Gemfile:
source 'http://rubygems.org'
gem 'amqp'
Gemfile.lock:
GEM
remote: http://rubygems.org/
specs:
amq-client (0.8.3)
amq-protocol (>= 0.8.0)
eventmachine
amq-protocol (0.8.1)
amqp (0.8.0)
amq-client (~> 0.8.3)
amq-protocol (~> 0.8.0)
eventmachine
eventmachine (0.12.10)
PLATFORMS
ruby
DEPENDENCIES
amqp
eventmachine
From the #rabbitmq channel (amqp author antares_): just use a single channel, and it'll work fine. Slightly changed, but stable version:
require 'bundler'
Bundler.setup
require 'amqp'
puts "single-message consumer listening to rapid producer"
QUEUE_NAME = 'test.rapid-queue-unsubscription'
PRODUCE_RATE = 1.0/10
CONSUME_RATE = 1.0/9
def start_producer channel
exchange = AMQP::Exchange.new(channel, :direct, "")
n = 0
EM::PeriodicTimer.new(PRODUCE_RATE) do
message = "msg #{n}"
exchange.publish(message,
:immediate => true, # IMPORTANT, messages are dropped if nobody listening now
:routing_key => QUEUE_NAME)
puts "> PUT #{message}"
n += 1
end
end
def start_consumer channel
EM::PeriodicTimer.new(CONSUME_RATE) do
started = Time.now
tick_queue = channel.queue(QUEUE_NAME)
consumer = AMQP::Consumer.new(channel, tick_queue, nil, exclusive = false, no_ack = true)
consumer.on_delivery do |_, message|
took = Time.now - started
puts "< GET #{message} [waited #{took.round(2)}s][#{(1.0/took).round(2)} reqs/sec]"
consumer.cancel do
puts "< GET #{message} (CANCEL DONE)"
end
end
consumer.consume
end
end
EM.run do
EM.set_quantum(50)
AMQP::Channel.new do |channel|
start_producer channel
end
AMQP::Channel.new do |channel|
channel.prefetch(1)
start_consumer channel
end
end