Confirm WebSocket `on_ping` default behavior (or bug)

Hello folks,

I was trying to proxy a WebSocket (using WebSocketHandler) and forward it to a remote socket.

Logged all the details of this issue in this gist.

Now that some days have passed and my brain is fresh, I think I found the issue, and connects back to this:

Which was implemented long time ago:

I’m wondering if both on_ping and on_pong default behaviors should be replaced once on_ping or on_pong callbacks are defined by the user?

Any thoughts?

Thank you.

1 Like

This sounds like something that would be easily overlooked and cause disconnections. I feel like the current implementation does the right thing for the vast majority of use cases. I’m not sure protocol-level pings should be expected to have their pongs overridden for the general case. Are there any use cases for a manual pong other than a proxy?

I wasn’t even sure it even made sense for a proxy, but I checked nginx and it did forward my pings to the upstream. However, I don’t know if it does it synchronously.

There are a few possible solutions if you do want this specific behavior. You could define a type inheriting from HTTP::WebSocket and override run with this line removed. If you’re concerned with HTTP::WebSocket changing, you can copy/paste the entire class definition into a type you own the hierarchy for. The entire file is only 213 lines, including whitespace and documentation comments. It’s surprisingly approachable.

If you want to work within the stdlib implementation, though, since pongs are sent after the on_ping block, you can block the pong by waiting for a response from the upstream inside the on_ping block:

require "http"
require "http/web_socket"
require "log"

Log.setup :debug

websocket_upstream = HTTP::WebSocketHandler.new do |ws, context|
  log = Log.for("upstream")
  # ...
  ws.on_ping do |msg|
    log.debug &.emit "ping", msg: msg
    sleep 100.milliseconds
  end
end
upstream = HTTP::Server.new([websocket_upstream])
spawn upstream.listen 1081

websocket_proxy = HTTP::WebSocketHandler.new do |client, context|
  log = Log.for("proxy")
  upstream_pongs = Channel(String).new(10)
  upstream_connection = HTTP::WebSocket.new("ws://localhost:1081")
  spawn upstream_connection.run

  client.on_ping do |msg|
    log.debug &.emit "ping", msg: msg
    upstream_connection.ping msg
    response = upstream_pongs.receive
    log.debug &.emit "pong", msg: response
  end

  upstream_connection.on_pong do |msg|
    upstream_pongs.send msg
  end

  client.on_close { upstream_connection.close rescue nil }
  upstream_connection.on_close { client.close rescue nil }
end
proxy = HTTP::Server.new([websocket_proxy])
spawn proxy.listen 1080

ws = HTTP::WebSocket.new("ws://localhost:1080")
ws.on_pong do |msg|
  Log.for("client").debug &.emit "pong", msg: msg
end
spawn ws.run
100.times do |i|
  sleep 1.second
  ws.ping i.to_s
end

The tradeoff is that this blocks the WebSocket fiber until the pong is received from the upstream, so head-of-line blocking situations could be a consideration. In this example, I added 100ms of latency at the upstream.