Posted on :: Updated on :: Tags: , , , ,

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:

  1. We want a Plug-N-play solution (i.e., we can't modify the frontend or make any changes on the client-side)
  2. We don't want to monkeypatch ActionCable (I hope there's no need to explain why)
  3. 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 :uuids. 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:

  1. Rails issue discussion - an interesting discussion of the problem
  2. W3C Bug Report - Enable keepalive on WebSocket API
  3. WebSocket Driver Ruby Issue
  4. Alternative Solution - includes frontend modifications
  5. Heroku Blog Post - includes helpful illustrations
  6. WebSocket Driver Source Code
  7. Faye WebSocket API