Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions spec/cable/connection_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions src/cable/connection.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading