Skip to content

Commit daffab2

Browse files
authored
[py][bidi]: close BiDi websocket on quit() (#17663)
1 parent 8c50418 commit daffab2

3 files changed

Lines changed: 98 additions & 2 deletions

File tree

py/selenium/webdriver/remote/webdriver.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,11 @@ def close(self) -> None:
637637
def quit(self) -> None:
638638
"""Quits the driver and closes every associated window."""
639639
try:
640+
# Close the BiDi/CDP websocket before deleting the session so the
641+
# close is initiated from our side.
642+
if self._websocket_connection is not None:
643+
self._websocket_connection.close()
644+
self._websocket_connection = None
640645
self.execute(Command.QUIT)
641646
finally:
642647
if self._request is not None:

py/selenium/webdriver/remote/websocket_connection.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,16 @@ def __init__(self, url, timeout, interval):
102102
self._wait_until(lambda: self._started)
103103

104104
def close(self):
105-
self._ws_thread.join(timeout=self.response_wait_timeout)
106-
self._ws.close()
105+
# Close the socket first so ``run_forever`` returns; only then join the
106+
# thread. Joining first would block for the full ``response_wait_timeout``
107+
# because the thread does not exit until the connection is closed.
108+
if self._ws is not None:
109+
try:
110+
self._ws.close()
111+
except Exception as e:
112+
logger.debug(f"Error while closing websocket connection: {e}")
113+
if self._ws_thread is not None:
114+
self._ws_thread.join(timeout=self.response_wait_timeout)
107115
self._started = False
108116
self._ws = None
109117

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import logging
19+
20+
import pytest
21+
22+
from selenium.webdriver.common.bidi.browsing_context import ReadinessState
23+
24+
25+
class _WebSocketErrorRecorder(logging.Handler):
26+
"""Captures ERROR-level records emitted by the websocket machinery."""
27+
28+
def __init__(self) -> None:
29+
super().__init__(level=logging.ERROR)
30+
self.records: list[logging.LogRecord] = []
31+
32+
def emit(self, record: logging.LogRecord) -> None:
33+
self.records.append(record)
34+
35+
36+
@pytest.mark.xfail_safari
37+
def test_quit_closes_bidi_websocket_without_error(clean_driver, clean_options, webserver):
38+
"""quit() must tear down the BiDi websocket cleanly."""
39+
clean_options.web_socket_url = True
40+
41+
recorder = _WebSocketErrorRecorder()
42+
ws_logger = logging.getLogger("websocket")
43+
conn_logger = logging.getLogger("selenium.webdriver.remote.websocket_connection")
44+
ws_logger.addHandler(recorder)
45+
conn_logger.addHandler(recorder)
46+
47+
driver = clean_driver(options=clean_options)
48+
quit_called = False
49+
try:
50+
context_id = driver.current_window_handle
51+
driver.browsing_context.navigate(
52+
context=context_id,
53+
url=webserver.where_is("simpleTest.html"),
54+
wait=ReadinessState.COMPLETE,
55+
)
56+
title = driver.script.execute("() => document.title", context_id=context_id)
57+
assert title.get("value") == "Hello WebDriver"
58+
59+
ws_connection = driver._websocket_connection
60+
assert ws_connection is not None, "expected an open BiDi websocket connection"
61+
ws_thread = ws_connection._ws_thread
62+
63+
driver.quit()
64+
quit_called = True
65+
66+
# The connection is closed from our side and the background thread stops.
67+
assert driver._websocket_connection is None
68+
assert not ws_thread.is_alive(), "BiDi websocket thread should be stopped after quit()"
69+
finally:
70+
ws_logger.removeHandler(recorder)
71+
conn_logger.removeHandler(recorder)
72+
if not quit_called:
73+
try:
74+
driver.quit()
75+
except Exception:
76+
pass
77+
78+
ws_errors = [
79+
r
80+
for r in recorder.records
81+
if "Connection to remote host was lost" in r.getMessage() or "goodbye" in r.getMessage()
82+
]
83+
assert not ws_errors, f"websocket error(s) surfaced on quit: {[r.getMessage() for r in ws_errors]}"

0 commit comments

Comments
 (0)