From 82bc3e6a62f77f889ba0036b36efac4cb958d7c3 Mon Sep 17 00:00:00 2001 From: Ismael Celis Date: Thu, 24 Sep 2026 16:02:22 +0100 Subject: [PATCH] Catch exceptions that wrap known client-disconnection errors, trigger on_client_disconnect Falcon server's protocol-http1 >= 0.41 now wraps Errno::ECONNRESET in its own exception class. This change makes this library make sure to inspect an exception's cause (root exception) to check whether it's a client disconnect that should run the on_client_disconnect callbacks. --- lib/datastar/dispatcher.rb | 29 +++++++++++++++++----- spec/dispatcher_spec.rb | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/lib/datastar/dispatcher.rb b/lib/datastar/dispatcher.rb index 56c6585..49afbd3 100644 --- a/lib/datastar/dispatcher.rb +++ b/lib/datastar/dispatcher.rb @@ -386,14 +386,33 @@ def wrap_socket(socket) @compressor.wrap_socket(socket) end + # Errors raised when writing to a socket the client has closed. + DISCONNECT_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze + + # Whether an error means the client went away. + # + # Servers may wrap the underlying socket error in their own class + # (e.g. protocol-http1 >= 0.41 raises +Protocol::HTTP::RemoteError+ + # from a +rescue Errno::EPIPE+), so the +cause+ chain is checked too. + # + # @param error [Exception] + # @return [Boolean] + def client_disconnect?(error) + while error + return true if DISCONNECT_ERRORS.any? { |klass| error.is_a?(klass) } + + error = error.cause + end + false + end + # Handle errors caught during streaming # @param error [Exception] the error that occurred # @param socket [IO] the socket to pass to error handlers def handle_streaming_error(error, socket) - case error - when IOError, Errno::EPIPE, Errno::ECONNRESET + if client_disconnect?(error) @on_client_disconnect.each { |callable| callable.call(socket) } - when Exception + else @on_error.each { |callable| callable.call(error) } end end @@ -407,10 +426,8 @@ def handling_sync_errors(generator, socket, &) yield @on_server_disconnect.each { |callable| callable.call(generator) } - rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e - @on_client_disconnect.each { |callable| callable.call(socket) } rescue Exception => e - @on_error.each { |callable| callable.call(e) } + handle_streaming_error(e, socket) end # Parse signals from the request diff --git a/spec/dispatcher_spec.rb b/spec/dispatcher_spec.rb index dd68f8c..3e8159c 100644 --- a/spec/dispatcher_spec.rb +++ b/spec/dispatcher_spec.rb @@ -33,6 +33,18 @@ def wait_for_close(&) end end +# A server-side wrapper around the socket error, as raised by e.g. +# protocol-http1 >= 0.41 (Protocol::HTTP::RemoteError from a rescue Errno::EPIPE). +class WrappedDisconnectError < StandardError; end + +class WrappingTestSocket < TestSocket + def <<(line) + super + rescue Errno::EPIPE + raise WrappedDisconnectError, 'Remote connection closed during flush!' + end +end + RSpec.describe Datastar::Dispatcher do include DispatcherExamples @@ -635,6 +647,43 @@ def self.render_in(view_context) = %(
\n#{view_context}