diff --git a/rb/lib/selenium/webdriver/common/websocket_connection.rb b/rb/lib/selenium/webdriver/common/websocket_connection.rb index 26f02ebc9bf1c..585089fcc3cd0 100644 --- a/rb/lib/selenium/webdriver/common/websocket_connection.rb +++ b/rb/lib/selenium/webdriver/common/websocket_connection.rb @@ -24,7 +24,10 @@ module WebDriver class WebSocketConnection CONNECTION_ERRORS = [ Errno::ECONNRESET, # connection is aborted (browser process was killed) - Errno::EPIPE # broken pipe (browser process was killed) + Errno::EPIPE, # broken pipe (browser process was killed) + Errno::EBADF, # file descriptor already closed (double-close or GC) + IOError, # Ruby socket read/write after close + EOFError # socket reached EOF after remote closed cleanly ].freeze RESPONSE_WAIT_TIMEOUT = 30 @@ -35,6 +38,11 @@ class WebSocketConnection def initialize(url:) @callback_threads = ThreadGroup.new + @callbacks_mtx = Mutex.new + @messages_mtx = Mutex.new + @closing_mtx = Mutex.new + + @closing = false @session_id = nil @url = url @@ -43,9 +51,26 @@ def initialize(url:) end def close - @callback_threads.list.each(&:exit) - @socket_thread.exit - socket.close + @closing_mtx.synchronize do + return if @closing + + @closing = true + end + + begin + socket.close + rescue *CONNECTION_ERRORS => e + WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws + # already closed + end + + # Let threads unwind instead of calling exit + @socket_thread&.join(0.5) + @callback_threads.list.each do |thread| + thread.join(0.5) + rescue StandardError => e + WebDriver.logger.debug "Failed to join thread during close: #{e.class}: #{e.message}", id: :ws + end end def callbacks @@ -53,62 +78,73 @@ def callbacks end def add_callback(event, &block) - callbacks[event] << block - block.object_id + @callbacks_mtx.synchronize do + callbacks[event] << block + block.object_id + end end def remove_callback(event, id) - return if callbacks[event].reject! { |callback| callback.object_id == id } + @callbacks_mtx.synchronize do + return if @closing + + callbacks_for_event = callbacks[event] + return if callbacks_for_event.reject! { |cb| cb.object_id == id } - ids = callbacks[event]&.map(&:object_id) - raise Error::WebDriverError, "Callback with ID #{id} does not exist for event #{event}: #{ids}" + ids = callbacks_for_event.map(&:object_id) + raise Error::WebDriverError, "Callback with ID #{id} does not exist for event #{event}: #{ids}" + end end def send_cmd(**payload) id = next_id data = payload.merge(id: id) - WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :bidi + WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :ws data = JSON.generate(data) out_frame = WebSocket::Frame::Outgoing::Client.new(version: ws.version, data: data, type: 'text') - socket.write(out_frame.to_s) - wait.until { messages.delete(id) } + begin + socket.write(out_frame.to_s) + rescue *CONNECTION_ERRORS => e + raise e, "WebSocket is closed (#{e.class}: #{e.message})" + end + + wait.until { @messages_mtx.synchronize { messages.delete(id) } } end private - # We should be thread-safe to use the hash without synchronization - # because its keys are WebSocket message identifiers and they should be - # unique within a devtools session. def messages @messages ||= {} end def process_handshake socket.print(ws.to_s) - ws << socket.readpartial(1024) + ws << socket.readpartial(1024) until ws.finished? end def attach_socket_listener Thread.new do - Thread.current.abort_on_exception = true Thread.current.report_on_exception = false - until socket.eof? + loop do + break if @closing + incoming_frame << socket.readpartial(1024) while (frame = incoming_frame.next) + break if @closing + message = process_frame(frame) next unless message['method'] - params = message['params'] - callbacks[message['method']].each do |callback| - @callback_threads.add(callback_thread(params, &callback)) + @messages_mtx.synchronize { callbacks[message['method']].dup }.each do |callback| + @callback_threads.add(callback_thread(message['params'], &callback)) end end end - rescue *CONNECTION_ERRORS - Thread.stop + rescue *CONNECTION_ERRORS, WebSocket::Error => e + WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws end end @@ -122,27 +158,27 @@ def process_frame(frame) # Firefox will periodically fail on unparsable empty frame return {} if message.empty? - message = JSON.parse(message) - messages[message['id']] = message - WebDriver.logger.debug "WebSocket <- #{message}"[...MAX_LOG_MESSAGE_SIZE], id: :bidi + msg = JSON.parse(message) + @messages_mtx.synchronize { messages[msg['id']] = msg if msg.key?('id') } - message + WebDriver.logger.debug "WebSocket <- #{msg}"[...MAX_LOG_MESSAGE_SIZE], id: :ws + msg end def callback_thread(params) Thread.new do - Thread.current.abort_on_exception = true - - # We might end up blocked forever when we have an error in event. - # For example, if network interception event raises error, - # the browser will keep waiting for the request to be proceeded - # before returning back to the original thread. In this case, - # we should at least print the error. - Thread.current.report_on_exception = true + Thread.current.abort_on_exception = false + Thread.current.report_on_exception = false + return if @closing yield params - rescue Error::WebDriverError, *CONNECTION_ERRORS - Thread.stop + rescue Error::WebDriverError, *CONNECTION_ERRORS => e + WebDriver.logger.debug "Callback aborted: #{e.class}: #{e.message}", id: :ws + rescue StandardError => e + return if @closing + + bt = Array(e.backtrace).first(5).join("\n") + WebDriver.logger.error "Callback error: #{e.class}: #{e.message}\n#{bt}", id: :ws end end diff --git a/rb/lib/selenium/webdriver/remote/bidi_bridge.rb b/rb/lib/selenium/webdriver/remote/bidi_bridge.rb index c95ddec538f85..fe5d3e8cc6677 100644 --- a/rb/lib/selenium/webdriver/remote/bidi_bridge.rb +++ b/rb/lib/selenium/webdriver/remote/bidi_bridge.rb @@ -46,9 +46,11 @@ def refresh end def quit - super - ensure bidi.close + rescue *QUIT_ERRORS + nil + ensure + super end def close diff --git a/rb/lib/selenium/webdriver/remote/bridge.rb b/rb/lib/selenium/webdriver/remote/bridge.rb index d4318a25b0088..ca35dc42ab89c 100644 --- a/rb/lib/selenium/webdriver/remote/bridge.rb +++ b/rb/lib/selenium/webdriver/remote/bridge.rb @@ -206,12 +206,20 @@ def switch_to_default_content switch_to_frame nil end - QUIT_ERRORS = [IOError].freeze + QUIT_ERRORS = [IOError, EOFError, WebSocket::Error].freeze def quit - execute :delete_session - http.close - rescue *QUIT_ERRORS + begin + execute :delete_session + rescue *QUIT_ERRORS => e + WebDriver.logger.debug "delete_session failed during quit: #{e.class}: #{e.message}", id: :quit + ensure + begin + http.close + rescue *QUIT_ERRORS => e + WebDriver.logger.debug "http.close failed during quit: #{e.class}: #{e.message}", id: :quit + end + end nil end diff --git a/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs b/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs index 98ac289f081ed..acc3c7bfefaee 100644 --- a/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs +++ b/rb/sig/lib/selenium/webdriver/common/websocket_connection.rbs @@ -5,6 +5,8 @@ module Selenium @callback_threads: untyped + @protocol: Symbol? + @session_id: untyped @url: untyped @@ -33,7 +35,7 @@ module Selenium MAX_LOG_MESSAGE_SIZE: Integer - def initialize: (url: untyped) -> void + def initialize: (url: String, ?protocol?: Symbol) -> void def add_callback: (untyped event) { () -> void } -> untyped @@ -47,6 +49,10 @@ module Selenium private + def bidi?: -> bool + + def devtools?: -> bool + def messages: () -> untyped def process_handshake: () -> untyped diff --git a/rb/spec/integration/selenium/webdriver/devtools_spec.rb b/rb/spec/integration/selenium/webdriver/devtools_spec.rb index 4506ecbbed5a1..a552a05e81c65 100644 --- a/rb/spec/integration/selenium/webdriver/devtools_spec.rb +++ b/rb/spec/integration/selenium/webdriver/devtools_spec.rb @@ -45,13 +45,12 @@ module WebDriver }.to yield_control end - it 'propagates errors in events' do + it 'logs errors in events' do + driver.devtools.page.enable + driver.devtools.page.on(:load_event_fired) { raise 'This is fine!' } expect { - driver.devtools.page.enable - driver.devtools.page.on(:load_event_fired) { raise 'This is fine!' } driver.navigate.to url_for('xhtmlTest.html') - sleep 0.5 - }.to raise_error(RuntimeError, 'This is fine!') + }.to have_error(:ws, /This is fine!/) end describe '#target' do diff --git a/rb/spec/integration/selenium/webdriver/remote/driver_spec.rb b/rb/spec/integration/selenium/webdriver/remote/driver_spec.rb index bd6a5a200fa82..614870bee456e 100644 --- a/rb/spec/integration/selenium/webdriver/remote/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/remote/driver_spec.rb @@ -83,8 +83,7 @@ module Remote end end - it 'errors when not set', {except: {browser: :firefox, reason: 'grid always sets true and firefox returns it'}, - exclude: {browser: :safari, reason: 'grid hangs'}} do + it 'errors when not set', exclude: {browser: :safari, reason: 'grid hangs'} do reset_driver!(enable_downloads: false) do |driver| expect { driver.downloadable_files diff --git a/rb/spec/rspec_matchers.rb b/rb/spec/rspec_matchers.rb index 88ba20289cd8b..e668368475beb 100644 --- a/rb/spec/rspec_matchers.rb +++ b/rb/spec/rspec_matchers.rb @@ -17,7 +17,7 @@ # specific language governing permissions and limitations # under the License. -LEVELS = %w[warning info deprecated].freeze +LEVELS = %w[error warning info deprecated].freeze LEVELS.each do |level| RSpec::Matchers.define "have_#{level}" do |entry|