From c62d630277574bf3c5ef190fd1712b7b52e86969 Mon Sep 17 00:00:00 2001 From: Russ Smith Date: Thu, 7 May 2026 14:43:55 -0700 Subject: [PATCH] Catch backend failures during Connection#initialize so clients get a clean close code instead of 1006. Fixes #105 When the backend (e.g. Redis) connection is dead, subscribe_to_internal_channel raised IO::Error which escaped the narrow rescue (UnauthorizedConnectionException only) and propagated through HTTP::WebSocketHandler, tearing down the TCP socket without a close frame. Clients saw close code 1006 with zero messages received, and recovery only happened on process restart. Widen the rescue to catch any Exception, mark the connection rejected, send a proper InternalServerError (1011) close frame, and report via on_error so the failure is visible to operators. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/cable/connection_spec.cr | 35 +++++++++++++++++++++++++++++++++++ src/cable/connection.cr | 18 ++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/spec/cable/connection_spec.cr b/spec/cable/connection_spec.cr index 79406d0..b56bc30 100644 --- a/spec/cable/connection_spec.cr +++ b/spec/cable/connection_spec.cr @@ -172,6 +172,32 @@ describe Cable::Connection do end end + describe "when the backend fails during initialize" do + it "closes the socket cleanly and reports via on_error instead of leaking the exception" do + Cable.reset_server + Cable.temp_config(backend_class: FailingSubscribeBackend) do + # Force the server to materialize so the failing backend is wired up + Cable.server + + socket = DummySocket.new(IO::Memory.new) + connection = ConnectionTest.new(builds_request(token: "98"), socket) + + # The rescue should mark the connection as rejected and close the socket + # rather than letting IO::Error escape to the WebSocketHandler (which would + # cause a 1006 abrupt close on the client). + connection.connection_rejected?.should be_true + socket.closed?.should be_true + + # The error is surfaced via on_error so operators can see it. + FakeExceptionService.exceptions.any? do |report| + report.exception.is_a?(IO::Error) && + report.message.includes?("ConnectionTest#initialize") + end.should be_true + end + Cable.reset_server + end + end + describe "#message" do it "ignore a message for a non valid channel" do connect do |connection, socket| @@ -578,6 +604,15 @@ private class UnauthorizedConnectionTest < Cable::Connection end end +# Simulates the failure mode in issue #105: the backend's underlying connection +# is dead, so any subscribe call (including the one done during Connection#initialize) +# raises IO::Error. +private class FailingSubscribeBackend < Cable::DevBackend + def subscribe(stream_identifier : String) + raise IO::Error.new("Broken pipe") + end +end + def connect(connection_class : Cable::Connection.class = ConnectionTest, token : String? = "98", &) socket = DummySocket.new(IO::Memory.new) connection = connection_class.new(builds_request(token: token), socket) diff --git a/src/cable/connection.cr b/src/cable/connection.cr index acff7e0..25d4685 100644 --- a/src/cable/connection.cr +++ b/src/cable/connection.cr @@ -42,6 +42,24 @@ module Cable unsubscribe_from_internal_channel socket.close(HTTP::WebSocket::CloseCode::NormalClosure, "Farewell") Cable::Logger.info { ("An unauthorized connection attempt was rejected") } + rescue e : Exception + # Anything else (e.g. a dead Redis backend causing IO::Error during + # subscribe_to_internal_channel) would otherwise escape to the + # WebSocketHandler and tear down the TCP socket with no close frame + # (client sees 1006). Convert it to a clean InternalServerError (1011) + # close so clients have a meaningful signal, and report via on_error. + reject_connection! + begin + unsubscribe_from_internal_channel + rescue + # backend is likely the source of the error — don't mask it + end + begin + socket.close(HTTP::WebSocket::CloseCode::InternalServerError, "Internal Server Error") unless socket.closed? + rescue + # socket may already be torn down; nothing more we can do here + end + Cable.settings.on_error.call(e, "Exception: #{e.message} -> #{self.class.name}#initialize", self) end end