Skip to content

Commit f562aae

Browse files
committed
[rb] add synchronization and error handling for socket interactions
1 parent b27ea58 commit f562aae

File tree

3 files changed

+75
-41
lines changed

3 files changed

+75
-41
lines changed

rb/lib/selenium/webdriver/common/websocket_connection.rb

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ module WebDriver
2424
class WebSocketConnection
2525
CONNECTION_ERRORS = [
2626
Errno::ECONNRESET, # connection is aborted (browser process was killed)
27-
Errno::EPIPE # broken pipe (browser process was killed)
27+
Errno::EPIPE, # broken pipe (browser process was killed)
28+
Errno::EBADF, # file descriptor already closed (double-close or GC)
29+
IOError, # Ruby socket read/write after close
30+
EOFError # socket reached EOF after remote closed cleanly
2831
].freeze
2932

3033
RESPONSE_WAIT_TIMEOUT = 30
@@ -34,7 +37,8 @@ class WebSocketConnection
3437

3538
def initialize(url:)
3639
@callback_threads = ThreadGroup.new
37-
40+
@mtx = Mutex.new
41+
@closing = false
3842
@session_id = nil
3943
@url = url
4044

@@ -43,72 +47,99 @@ def initialize(url:)
4347
end
4448

4549
def close
46-
@callback_threads.list.each(&:exit)
47-
@socket_thread.exit
48-
socket.close
50+
@mtx.synchronize do
51+
return if @closing
52+
53+
@closing = true
54+
end
55+
56+
begin
57+
socket.close
58+
rescue *CONNECTION_ERRORS => e
59+
WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws
60+
# already closed
61+
end
62+
63+
# Let threads unwind instead of calling exit
64+
@socket_thread&.join(0.5)
65+
@callback_threads.list.each do |thread|
66+
thread.join(0.5)
67+
rescue StandardError
68+
nil
69+
end
4970
end
5071

5172
def callbacks
5273
@callbacks ||= Hash.new { |callbacks, event| callbacks[event] = [] }
5374
end
5475

5576
def add_callback(event, &block)
56-
callbacks[event] << block
57-
block.object_id
77+
@mtx.synchronize do
78+
callbacks[event] << block
79+
block.object_id
80+
end
5881
end
5982

6083
def remove_callback(event, id)
61-
return if callbacks[event].reject! { |callback| callback.object_id == id }
84+
removed = @mtx.synchronize { callbacks[event].reject! { |cb| cb.object_id == id } }
85+
return if removed || @closing
6286

63-
ids = callbacks[event]&.map(&:object_id)
87+
ids = @mtx.synchronize { callbacks[event]&.map(&:object_id) }
6488
raise Error::WebDriverError, "Callback with ID #{id} does not exist for event #{event}: #{ids}"
6589
end
6690

6791
def send_cmd(**payload)
6892
id = next_id
6993
data = payload.merge(id: id)
70-
WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :bidi
94+
WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :ws
7195
data = JSON.generate(data)
7296
out_frame = WebSocket::Frame::Outgoing::Client.new(version: ws.version, data: data, type: 'text')
73-
socket.write(out_frame.to_s)
7497

75-
wait.until { messages.delete(id) }
98+
begin
99+
socket.write(out_frame.to_s)
100+
rescue *CONNECTION_ERRORS => e
101+
raise Error::WebDriverError, "WebSocket is closed (#{e.class}: #{e.message})"
102+
end
103+
104+
wait.until do
105+
@mtx.synchronize { messages.delete(id) }
106+
end
76107
end
77108

78109
private
79110

80-
# We should be thread-safe to use the hash without synchronization
81-
# because its keys are WebSocket message identifiers and they should be
82-
# unique within a devtools session.
83111
def messages
84112
@messages ||= {}
85113
end
86114

87115
def process_handshake
88116
socket.print(ws.to_s)
89-
ws << socket.readpartial(1024)
117+
ws << socket.readpartial(1024) until ws.finished?
90118
end
91119

92120
def attach_socket_listener
93121
Thread.new do
94-
Thread.current.abort_on_exception = true
95122
Thread.current.report_on_exception = false
96123

97-
until socket.eof?
124+
loop do
125+
break if @closing
126+
98127
incoming_frame << socket.readpartial(1024)
99128

100129
while (frame = incoming_frame.next)
130+
break if @closing
131+
101132
message = process_frame(frame)
102133
next unless message['method']
103134

104135
params = message['params']
105-
callbacks[message['method']].each do |callback|
136+
@mtx.synchronize { callbacks[message['method']].dup }.each do |callback|
106137
@callback_threads.add(callback_thread(params, &callback))
107138
end
108139
end
109140
end
110-
rescue *CONNECTION_ERRORS
111-
Thread.stop
141+
rescue *CONNECTION_ERRORS, WebSocket::Error => e
142+
WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws
112143
end
113144
end
114145

@@ -122,27 +153,26 @@ def process_frame(frame)
122153
# Firefox will periodically fail on unparsable empty frame
123154
return {} if message.empty?
124155

125-
message = JSON.parse(message)
126-
messages[message['id']] = message
127-
WebDriver.logger.debug "WebSocket <- #{message}"[...MAX_LOG_MESSAGE_SIZE], id: :bidi
156+
msg = JSON.parse(message)
157+
@mtx.synchronize { messages[msg['id']] = msg if msg.key?('id') }
128158

129-
message
159+
WebDriver.logger.debug "WebSocket <- #{msg}"[...MAX_LOG_MESSAGE_SIZE], id: :ws
160+
msg
130161
end
131162

132163
def callback_thread(params)
133164
Thread.new do
134-
Thread.current.abort_on_exception = true
135-
136-
# We might end up blocked forever when we have an error in event.
137-
# For example, if network interception event raises error,
138-
# the browser will keep waiting for the request to be proceeded
139-
# before returning back to the original thread. In this case,
140-
# we should at least print the error.
141-
Thread.current.report_on_exception = true
165+
Thread.current.abort_on_exception = false
166+
Thread.current.report_on_exception = false
167+
return if @closing
142168

143169
yield params
144-
rescue Error::WebDriverError, *CONNECTION_ERRORS
145-
Thread.stop
170+
rescue Error::WebDriverError, *CONNECTION_ERRORS => e
171+
WebDriver.logger.debug "Callback aborted: #{e.class}: #{e.message}", id: :ws
172+
rescue StandardError => e
173+
# Unexpected handler failure; log with a short backtrace.
174+
bt = Array(e.backtrace).first(5).join("\n")
175+
WebDriver.logger.error "Callback error: #{e.class}: #{e.message}\n#{bt}", id: :ws
146176
end
147177
end
148178

rb/lib/selenium/webdriver/remote/bidi_bridge.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ def refresh
4646
end
4747

4848
def quit
49-
super
50-
ensure
5149
bidi.close
50+
super
51+
rescue *QUIT_ERRORS
52+
nil
5253
end
5354

5455
def close

rb/lib/selenium/webdriver/remote/bridge.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,16 @@ def switch_to_default_content
206206
switch_to_frame nil
207207
end
208208

209-
QUIT_ERRORS = [IOError].freeze
209+
QUIT_ERRORS = [IOError, EOFError, WebSocket::Error].freeze
210210

211211
def quit
212212
execute :delete_session
213-
http.close
214-
rescue *QUIT_ERRORS
215-
nil
213+
ensure
214+
begin
215+
http.close
216+
rescue *QUIT_ERRORS
217+
nil
218+
end
216219
end
217220

218221
def close

0 commit comments

Comments
 (0)