Skip to content
110 changes: 73 additions & 37 deletions rb/lib/selenium/webdriver/common/websocket_connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -43,72 +51,100 @@ 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
@callbacks ||= Hash.new { |callbacks, event| callbacks[event] = [] }
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

Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions rb/lib/selenium/webdriver/remote/bidi_bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ def refresh
end

def quit
super
ensure
bidi.close
rescue *QUIT_ERRORS
nil
ensure
super
end

def close
Expand Down
16 changes: 12 additions & 4 deletions rb/lib/selenium/webdriver/remote/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ module Selenium

@callback_threads: untyped

@protocol: Symbol?

@session_id: untyped

@url: untyped
Expand Down Expand Up @@ -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

Expand All @@ -47,6 +49,10 @@ module Selenium

private

def bidi?: -> bool

def devtools?: -> bool

def messages: () -> untyped

def process_handshake: () -> untyped
Expand Down
9 changes: 4 additions & 5 deletions rb/spec/integration/selenium/webdriver/devtools_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions rb/spec/integration/selenium/webdriver/remote/driver_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rb/spec/rspec_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading