TLDR: Complete code snippet is available at the end of the article.
Ping -> PONG, we will use this technique later
If you've ever worked closely with WebSockets in Ruby, particularly with handling disconnections, you should know that ActionCable (the package for working with WebSockets) has a peculiarity—or rather, a defect—that occurs in scenarios such as Internet connection loss. The main issue is that ActionCable, out of the box, doesn't react quickly enough to client connection losses.
The easiest way to reproduce this issue is to connect to the channel from a mobile phone and then switch the phone to airplane mode. In this case, ActionCable will continue transmitting messages to the WebSocket channel, despite the subscriber being physically unreachable.
If you think the problem is contrived, check out this link: https://github.com/rails/rails/issues/29307
This is a real problem for mobile clients. Our goal is to implement a solution that eliminates this defect.
Main conditions:
- We want a Plug-N-play solution (i.e., we can't modify the frontend or make any changes on the client-side)
- We don't want to monkeypatch ActionCable (I hope there's no need to explain why)
- We don't want to use Redis or any other external storage
Step by step: Some circles
First, let's create some ActionCable code so we can create subscriptions. I assume you have a Rails app with ActionCable initialized and routed.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :uuid
def connect
self.uuid = SecureRandom.uuid
logger.add_tags 'ActionCable', uuid
end
def disconnect
ActionCable.server.remote_connections.where(uuid: uuid).disconnect
end
end
end
# app/channels/sample_channel.rb
class SampleChannel < ApplicationCable::Channel
def subscribed
stream_from "sample_channel_#{uuid}"
end
def unsubscribed; end
end
This code allows us to easily create/destroy connections and identify them by :uuid
s. Now we've drawn two circles, and it's time to create an owl.
Step by step: The Owl
Let's return to our problem. There aren't many options to resolve this case except using ping/pong functionality with regular Keep-Alive checks. Fortunately, we have the beat
method right in ActionCable:
# File actioncable/lib/action_cable/connection/base.rb, line 116
def beat
transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
end
But what about getting a response? The short answer: No.
Any network-related events like disconnections need to be handled by the caller if your TCP socket becomes disconnected, the driver cannot detect that and you need to handle it yourself. >Understandable, have a great ping!
So, we need to create our own ping that can also receive pong messages from the client. Let's start by adding periodically
. This method, found in the depths of ActionCable docs, allows us to define tasks that will be performed periodically on the channel. We'll periodically send ping messages and create postponed tasks using Concurrent::TimerTask
to unsubscribe users:
module ApplicationCable
class Channel < ActionCable::Channel::Base
CONNECTION_TIMEOUT = 4.seconds
CONNECTION_PING_INTERVAL = 5.seconds
periodically :track_users, every: CONNECTION_PING_INTERVAL
def track_users
ActionCable.server.connections.each do |conn|
order66 = Concurrent::TimerTask.new(execution_interval: CONNECTION_TIMEOUT) do
conn.disconnect
end
order66.execute
if pong_received # something we will surely add
order66.shutdown
end
end
end
end
The final puzzle piece we need to solve is pong_received
. To find it, we need to check all ActionCable dependencies and understand where to get access to the WebSocket client. ActionCable dependencies are nio4r and websocket-driver.
A Ctrl+F search in websocket-driver sources reveals a better version of the ping
method, which according to specs responds with true
if the client is alive:
# lib/websocket/driver/hybi.rb:131
def ping(message = '', &callback)
@ping_callbacks[message] = callback if callback
frame(message, :ping)
end
# spec/websocket/driver/hybi_spec.rb:449
it "runs the given callback on matching pong" do
driver.ping("Hi") { @reply = true }
driver.parse [0x8a, 0x02, 72, 105].pack("C*")
expect(@reply).to eq true
end
Now let's find an interface to this driver in ActionCable. Ironically, they wrapped the real socket to minimize our ability to modify it:
I prefer code snippets, but this one looks better as an image
Since we decided against monkeypatching, our solution isn't beautiful, but it works:
connection.instance_values['websocket'].instance_values['websocket'].instance_variable_get(:@driver)
Here's the complete code (TLDR):
module ApplicationCable
class Channel < ActionCable::Channel::Base
CONNECTION_TIMEOUT = 4.seconds
CONNECTION_PING_INTERVAL = 5.seconds
periodically :track_users, every: CONNECTION_PING_INTERVAL
def track_users
ActionCable.server.connections.each do |conn|
order66 = Concurrent::TimerTask.new(execution_interval: CONNECTION_TIMEOUT) do
conn.disconnect
end
order66.execute
if connection.instance_values[‘websocket’].instance_values[‘websocket’].instance_variable_get(:@driver).ping do
order66.shutdown
end
end
end
end
Or another version, if you prefer not to use unknown concurrent libraries:
module ApplicationCable
class Channel < ActionCable::Channel::Base
after_subscribe :connection_monitor
CONNECTION_TIMEOUT = 10.seconds
CONNECTION_PING_INTERVAL = 5.seconds
periodically every: CONNECTION_PING_INTERVAL do
@driver&.ping
if Time.now - @_last_request_at > @_timeout
connection.disconnect
end
end
def connection_monitor
@_last_request_at ||= Time.now
@_timeout = CONNECTION_TIMEOUT
@driver = connection.instance_variable_get('@websocket').possible?&.instance_variable_get('@driver')
@driver.on(:pong) { @_last_request_at = Time.now }
end
end
end
I hope this article has been helpful!
Resources for further research:
- Rails issue discussion - an interesting discussion of the problem
- W3C Bug Report - Enable keepalive on WebSocket API
- WebSocket Driver Ruby Issue
- Alternative Solution - includes frontend modifications
- Heroku Blog Post - includes helpful illustrations
- WebSocket Driver Source Code
- Faye WebSocket API