Skip to content

Ensure socket is closed even when transmit raises in Connection#close#17

Merged
palkan merged 1 commit into
anycable:mainfrom
ioquatix:fix/close-ensure-socket
May 22, 2026
Merged

Ensure socket is closed even when transmit raises in Connection#close#17
palkan merged 1 commit into
anycable:mainfrom
ioquatix:fix/close-ensure-socket

Conversation

@ioquatix
Copy link
Copy Markdown
Contributor

Problem

ActionCable::Server::Base#restart iterates its connections and calls connection.close on each. Connection#close transmits the disconnect message and then calls socket.close. If the socket has already been closed (e.g. by a prior restart call whose middleware cleanup hasn't run yet), transmit raises ClosedQueueError and socket.close is never reached.

Real-world backtrace:

ClosedQueueError: queue closed
  socket.rb transmit → connection/base.rb transmit → connection/base.rb close
    → server/base.rb restart (block in restart) → server/base.rb restart (Array#each)

This happens because the middleware's ensure block (which calls remove_connection) runs asynchronously after the WebSocket loop exits. Between connection.close (socket closed) and that cleanup, the connection is still in the server's connections list. A second restart call during this window finds the stale entry and tries to close it again.

Fix

Move socket.close into an ensure block so the socket is always closed regardless of whether transmitting the disconnect message succeeds. Errors during transmit are logged and swallowed — the socket is being torn down anyway.

def close(reason: nil, reconnect: true)
  transmit(
    type: ActionCable::INTERNAL[:message_types][:disconnect],
    reason: reason,
    reconnect: reconnect
  )
rescue => error
  logger.error("Failed to transmit disconnect message: #{error.message}")
ensure
  socket.close
end

Tests

Added test_socket_is_closed_even_when_transmit_raises_during_close to test/connection/base_test.rb which stubs socket.transmit to raise ClosedQueueError and asserts that socket.close is still called.

When ActionCable::Server::Base#restart iterates its connections and calls
connection.close on each, the disconnect message is transmitted and then
socket.close is called. If the socket has already been closed (e.g. by a
prior restart call that hasn't been fully cleaned up yet), transmit raises
ClosedQueueError and socket.close is never reached.

Move socket.close into an ensure block so the socket is always closed
regardless of whether transmitting the disconnect message succeeds. Errors
during transmission are logged and swallowed since the socket is being torn
down anyway.

Fixes a ClosedQueueError reported when restart is called while a previous
restart's connection cleanup is still pending.
@ioquatix
Copy link
Copy Markdown
Contributor Author

ioquatix commented May 22, 2026

Copilot authored this fix.

However, I think it's the correct semantic. The logging line is optional - I actually think we don't really need it as there is no reasonable action that can be taken by the user IMHO.

On closing, we are trying to gracefully transmit the shutdown message, but there is no guarantee that transmit will work – ever. Since we don't want close to raise, it needs to ignore transmit failures.

Alternative to #16.

To be really defensive, socket.close rescue nil is also an option. However, I think close should not raise - so if the provided adapter is raising on close, that issue should be fixed by the adapter.

@palkan palkan merged commit eba28e4 into anycable:main May 22, 2026
@palkan
Copy link
Copy Markdown
Member

palkan commented May 22, 2026

Thanks! Looks good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants