Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions py/selenium/webdriver/remote/websocket_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,13 @@ def __init__(self, url, timeout, interval):
self._id = 0
self._id_lock = threading.Lock()
self._messages = {}
self._started = False
self._started = threading.Event()
self._events = {}
self._events_lock = threading.Lock()

self._start_ws()
self._wait_until(lambda: self._started)
if not self._started.wait(timeout=self.response_wait_timeout):
raise WebDriverException("Timed out waiting for connection to start")
Comment on lines +104 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Startup timeout leaves thread 🐞 Bug ☼ Reliability

Python WebSocketConnection.__init__() raises on _started.wait timeout but does not close/join the
already-started WebSocket thread, leaving a background thread/socket attempt running after
constructor failure.
Agent Prompt
### Issue description
If the websocket never opens, `__init__` raises after timing out, but `_start_ws()` already started a daemon thread running `run_forever`. The failure path does not call `close()` or otherwise stop/join the thread.

### Issue Context
`WebDriver._start_bidi()` constructs this object directly; on constructor failure, callers will see an exception, but resources may still be active in the background.

### Fix Focus Areas
- py/selenium/webdriver/remote/websocket_connection.py[84-106]
- py/selenium/webdriver/remote/websocket_connection.py[187-207]

### Suggested fix
On `_started.wait` timeout, attempt cleanup before raising:
- call `self._ws.close()` (guarded if `self._ws` exists)
- join `self._ws_thread` briefly
- clear `_started`
Then raise the timeout exception.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


def close(self):
# Close the socket first so ``run_forever`` returns; only then join the
Expand All @@ -112,13 +115,18 @@ def close(self):
logger.debug(f"Error while closing websocket connection: {e}")
if self._ws_thread is not None:
self._ws_thread.join(timeout=self.response_wait_timeout)
self._started = False
self._started.clear()
self._ws = None

def execute(self, command):
with self._id_lock:
self._id += 1
current_id = self._id

event = threading.Event()
with self._events_lock:
self._events[current_id] = event

payload = self._serialize_command(command)
payload["id"] = current_id
if self.session_id:
Comment on lines +126 to 132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Events leak on send error 🐞 Bug ☼ Reliability

Python WebSocketConnection.execute() stores a per-command Event in self._events before
serialization/send, but if serialization or self._ws.send raises, the entry is never removed and can
accumulate in long-lived sessions.
Agent Prompt
### Issue description
`WebSocketConnection.execute()` registers `self._events[current_id]` before building/sending the command. If any exception occurs before the normal timeout/success cleanup paths (e.g., generator `_serialize_command`, `json.dumps`, or `_ws.send`), the entry remains in `_events` and can grow unbounded.

### Issue Context
The new event-based waiting removes the polling loop but introduces new bookkeeping that must be cleaned on *all* error paths.

### Fix Focus Areas
- py/selenium/webdriver/remote/websocket_connection.py[121-154]

### Suggested fix
Wrap the body after inserting into `_events` in `try/except/finally` so `_events.pop(current_id, None)` runs on **any** exception (including during serialization and `send`). Optionally also guard `self._messages.pop(current_id)` with a default and raise a clearer error if missing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand All @@ -128,8 +136,9 @@ def execute(self, command):
logger.debug(f"-> {data}"[: self._max_log_message_size])
self._ws.send(data)

self._wait_until(lambda: current_id in self._messages)
if current_id not in self._messages:
if not event.wait(timeout=self.response_wait_timeout):
with self._events_lock:
self._events.pop(current_id, None)
raise WebDriverException(f"Timed out waiting for response to BiDi command {current_id}")
response = self._messages.pop(current_id)

Expand Down Expand Up @@ -177,7 +186,7 @@ def _deserialize_result(self, result, command):

def _start_ws(self):
def on_open(ws):
self._started = True
self._started.set()

def on_message(ws, message):
self._process_message(message)
Expand All @@ -201,21 +210,14 @@ def _process_message(self, message):
logger.debug(f"<- {message}"[: self._max_log_message_size])

if "id" in message:
self._messages[message["id"]] = message
msg_id = message["id"]
self._messages[msg_id] = message
with self._events_lock:
event = self._events.pop(msg_id, None)
if event:
event.set()

if "method" in message:
params = message["params"]
for callback in self.callbacks.get(message["method"], []):
Thread(target=callback, args=(params,), daemon=True).start()

def _wait_until(self, condition):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

4. Unused sleep import remains 🐞 Bug ⚙ Maintainability

Python websocket_connection.py still imports sleep even though the polling helper that used it was
deleted, leaving dead code (and potentially failing lint, depending on CI settings).
Agent Prompt
### Issue description
The `_wait_until` polling helper was removed, but `from time import sleep` remains unused.

### Issue Context
Keeping unused imports increases noise and may be flagged by static analysis tooling.

### Fix Focus Areas
- py/selenium/webdriver/remote/websocket_connection.py[18-25]

### Suggested fix
Remove `from time import sleep` (and any other now-unused imports) if nothing else in the module references it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

timeout = self.response_wait_timeout
interval = self.response_wait_interval

while timeout > 0:
result = condition()
if result:
return result
else:
timeout -= interval
sleep(interval)
35 changes: 33 additions & 2 deletions rb/lib/selenium/webdriver/common/websocket_connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def initialize(url:)
@closing = false
@session_id = nil
@url = url
@pending_responses = {}

process_handshake
@socket_thread = attach_socket_listener
Expand Down Expand Up @@ -103,13 +104,33 @@ def send_cmd(**payload)
data = JSON.generate(data)
out_frame = WebSocket::Frame::Outgoing::Client.new(version: ws.version, data: data, type: 'text')

cond = ConditionVariable.new
@messages_mtx.synchronize do
@pending_responses[id] = { cond: cond, response: nil }
end

begin
socket.write(out_frame.to_s)
rescue *CONNECTION_ERRORS => e
@messages_mtx.synchronize { @pending_responses.delete(id) }
raise e, "WebSocket is closed (#{e.class}: #{e.message})"
end

wait.until { @messages_mtx.synchronize { messages.delete(id) } }
@messages_mtx.synchronize do
timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + RESPONSE_WAIT_TIMEOUT
while @pending_responses[id] && @pending_responses[id][:response].nil?
remaining = timeout_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
if remaining <= 0
@pending_responses.delete(id)
raise Error::TimeoutError, "timed out after #{RESPONSE_WAIT_TIMEOUT} seconds waiting for response to command #{id}"
end
cond.wait(@messages_mtx, remaining)
end
entry = @pending_responses.delete(id)
raise Error::TimeoutError, "timed out after #{RESPONSE_WAIT_TIMEOUT} seconds waiting for response to command #{id}" unless entry && entry[:response]

entry[:response]
end
Comment on lines +107 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Close doesn't unblock waiters 🐞 Bug ☼ Reliability

Ruby send_cmd now waits on a per-command ConditionVariable in @pending_responses, but close never
signals/broadcasts those CVs or clears pending entries, so callers can block until timeout after the
socket is already closed.
Agent Prompt
### Issue description
`send_cmd` blocks on `cond.wait` until `process_frame` signals it. If `close` is called (or the listener dies) while commands are pending, there is no mechanism to wake waiting threads early; they will typically wait until `RESPONSE_WAIT_TIMEOUT` and raise a timeout error.

### Issue Context
The new `@pending_responses` structure is the authoritative wait mechanism for command replies, but `close` currently only closes the socket and joins threads.

### Fix Focus Areas
- rb/lib/selenium/webdriver/common/websocket_connection.rb[54-75]
- rb/lib/selenium/webdriver/common/websocket_connection.rb[100-134]

### Suggested fix
In `close`, under `@messages_mtx`, iterate `@pending_responses` and:
- mark each entry as terminal (e.g., set a `:closed`/`:error` sentinel response)
- `broadcast` or `signal` the condition variables
- clear the hash
Then update `send_cmd` to detect the sentinel and raise a connection-closed error (instead of a misleading timeout).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

end

private
Expand Down Expand Up @@ -159,7 +180,17 @@ def process_frame(frame)
return {} if message.empty?

msg = JSON.parse(message)
@messages_mtx.synchronize { messages[msg['id']] = msg if msg.key?('id') }
if msg.key?('id')
msg_id = msg['id']
@messages_mtx.synchronize do
if @pending_responses.key?(msg_id)
@pending_responses[msg_id][:response] = msg
@pending_responses[msg_id][:cond].signal
else
messages[msg_id] = msg
end
end
end

WebDriver.logger.debug "WebSocket <- #{msg}"[...MAX_LOG_MESSAGE_SIZE], id: :ws
msg
Expand Down
Loading