Skip to content

Commit bfdb55d

Browse files
russclaude
andauthored
Catch backend failures during Connection#initialize so clients get a clean close code instead of 1006. Fixes #105 (#106)
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) <noreply@anthropic.com>
1 parent cb4765a commit bfdb55d

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

spec/cable/connection_spec.cr

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,32 @@ describe Cable::Connection do
172172
end
173173
end
174174

175+
describe "when the backend fails during initialize" do
176+
it "closes the socket cleanly and reports via on_error instead of leaking the exception" do
177+
Cable.reset_server
178+
Cable.temp_config(backend_class: FailingSubscribeBackend) do
179+
# Force the server to materialize so the failing backend is wired up
180+
Cable.server
181+
182+
socket = DummySocket.new(IO::Memory.new)
183+
connection = ConnectionTest.new(builds_request(token: "98"), socket)
184+
185+
# The rescue should mark the connection as rejected and close the socket
186+
# rather than letting IO::Error escape to the WebSocketHandler (which would
187+
# cause a 1006 abrupt close on the client).
188+
connection.connection_rejected?.should be_true
189+
socket.closed?.should be_true
190+
191+
# The error is surfaced via on_error so operators can see it.
192+
FakeExceptionService.exceptions.any? do |report|
193+
report.exception.is_a?(IO::Error) &&
194+
report.message.includes?("ConnectionTest#initialize")
195+
end.should be_true
196+
end
197+
Cable.reset_server
198+
end
199+
end
200+
175201
describe "#message" do
176202
it "ignore a message for a non valid channel" do
177203
connect do |connection, socket|
@@ -578,6 +604,15 @@ private class UnauthorizedConnectionTest < Cable::Connection
578604
end
579605
end
580606

607+
# Simulates the failure mode in issue #105: the backend's underlying connection
608+
# is dead, so any subscribe call (including the one done during Connection#initialize)
609+
# raises IO::Error.
610+
private class FailingSubscribeBackend < Cable::DevBackend
611+
def subscribe(stream_identifier : String)
612+
raise IO::Error.new("Broken pipe")
613+
end
614+
end
615+
581616
def connect(connection_class : Cable::Connection.class = ConnectionTest, token : String? = "98", &)
582617
socket = DummySocket.new(IO::Memory.new)
583618
connection = connection_class.new(builds_request(token: token), socket)

src/cable/connection.cr

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ module Cable
4242
unsubscribe_from_internal_channel
4343
socket.close(HTTP::WebSocket::CloseCode::NormalClosure, "Farewell")
4444
Cable::Logger.info { ("An unauthorized connection attempt was rejected") }
45+
rescue e : Exception
46+
# Anything else (e.g. a dead Redis backend causing IO::Error during
47+
# subscribe_to_internal_channel) would otherwise escape to the
48+
# WebSocketHandler and tear down the TCP socket with no close frame
49+
# (client sees 1006). Convert it to a clean InternalServerError (1011)
50+
# close so clients have a meaningful signal, and report via on_error.
51+
reject_connection!
52+
begin
53+
unsubscribe_from_internal_channel
54+
rescue
55+
# backend is likely the source of the error — don't mask it
56+
end
57+
begin
58+
socket.close(HTTP::WebSocket::CloseCode::InternalServerError, "Internal Server Error") unless socket.closed?
59+
rescue
60+
# socket may already be torn down; nothing more we can do here
61+
end
62+
Cable.settings.on_error.call(e, "Exception: #{e.message} -> #{self.class.name}#initialize", self)
4563
end
4664
end
4765

0 commit comments

Comments
 (0)