From 326cbd97b7b7ef754303c6ad09f7bf0c0d49af3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Thu, 21 May 2026 11:12:23 +0200 Subject: [PATCH] - server/base: tolerate connection close failures in #restart ActionCable::Server::Base#restart iterates @connections and calls the iteration and propagates out. Since restart is wired to Rails' before_class_unload reloader callback, that raise tears down the unload callback chain and 500s the request that triggered the reload. We hit this in development under async-cable, whose Socket#transmit pushes to a Thread::Queue and raises ClosedQueueError when the queue is already closed (stale connection, second restart in flight, client disconnect racing with restart): async-cable/lib/async/cable/socket.rb:52:in `Thread::Queue#push` async-cable/lib/async/cable/socket.rb:52:in `transmit` action_cable/connection/base.rb:117:in `transmit` action_cable/connection/base.rb:126:in `close` action_cable/server/base.rb:75:in `block in restart` action_cable/server/base.rb:74:in `each` action_cable/server/base.rb:74:in `restart` The root cause is adapter-specific, but the fix belongs at the restart boundary too: it runs from a reloader callback and shouldn't propagate per-connection failures regardless of which adapter is serving the socket. We catch and log around each close so one bad connection cannot abort the iteration. The remaining connections still close and the worker pool, executor, and pub-sub adapter still shut down. --- lib/action_cable/server/base.rb | 2 + test/server/base_test.rb | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/lib/action_cable/server/base.rb b/lib/action_cable/server/base.rb index 3da7643..f0f7360 100644 --- a/lib/action_cable/server/base.rb +++ b/lib/action_cable/server/base.rb @@ -73,6 +73,8 @@ def disconnect(identifiers) def restart connections.each do |connection| connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart]) + rescue => e + logger&.error "Failed to close connection during restart: [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" end @mutex.synchronize do diff --git a/test/server/base_test.rb b/test/server/base_test.rb index d46debe..7d17125 100644 --- a/test/server/base_test.rb +++ b/test/server/base_test.rb @@ -15,6 +15,12 @@ def close end end + class RaisingConnection + def close(*) + raise ClosedQueueError, "queue closed" + end + end + test "#restart closes all open connections" do conn = FakeConnection.new @server.add_connection(conn) @@ -35,4 +41,64 @@ def close @server.restart end end + + test "#restart does not propagate exceptions raised by connection#close" do + @server.add_connection(RaisingConnection.new) + + assert_nothing_raised do + @server.restart + end + end + + test "#restart still closes remaining connections when one #close raises" do + @server.add_connection(RaisingConnection.new) + survivor = FakeConnection.new + @server.add_connection(survivor) + + assert_called(survivor, :close) do + @server.restart + end + end + + test "#restart still shuts down worker pool when connection#close raises" do + @server.add_connection(RaisingConnection.new) + + assert_called(@server.worker_pool, :halt) do + @server.restart + end + end + + test "#restart still shuts down pub/sub when connection#close raises" do + @server.add_connection(RaisingConnection.new) + + assert_called(@server.pubsub, :shutdown) do + @server.restart + end + end + + test "#restart is safe to call twice when a connection#close raises" do + @server.add_connection(RaisingConnection.new) + + @server.restart + assert_nothing_raised do + @server.restart + end + end + + test "#restart logs an error including the exception class when connection#close raises" do + @server.add_connection(RaisingConnection.new) + + log = StringIO.new + old_logger = @server.config.logger + @server.config.logger = Logger.new(log) + + begin + @server.restart + + log.rewind + assert_match(/ClosedQueueError.*queue closed/, log.read) + ensure + @server.config.logger = old_logger + end + end end