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