From 0f251c574024e08bbe751d1f679e7b8437534773 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:44:05 -0400 Subject: [PATCH 1/4] [py] Configure WebSocket timeout and wait interval via ClientConfig --- py/selenium/webdriver/remote/client_config.py | 19 ++++++++++++------- py/selenium/webdriver/remote/webdriver.py | 9 +++++++-- .../webdriver/remote/websocket_connection.py | 14 +++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/py/selenium/webdriver/remote/client_config.py b/py/selenium/webdriver/remote/client_config.py index 050dd18e05482..480c0c7e2fe41 100644 --- a/py/selenium/webdriver/remote/client_config.py +++ b/py/selenium/webdriver/remote/client_config.py @@ -50,22 +50,19 @@ class ClientConfig: keep_alive = _ClientConfigDescriptor("_keep_alive") """Gets and Sets Keep Alive value.""" proxy = _ClientConfigDescriptor("_proxy") - """Gets and Sets the proxy used for communicating to the driver/server.""" + """Gets and Sets the proxy used for communicating with the driver/server.""" ignore_certificates = _ClientConfigDescriptor("_ignore_certificates") """Gets and Sets the ignore certificate check value.""" init_args_for_pool_manager = _ClientConfigDescriptor("_init_args_for_pool_manager") """Gets and Sets the ignore certificate check.""" timeout = _ClientConfigDescriptor("_timeout") - """Gets and Sets the timeout (in seconds) used for communicating to the - driver/server.""" + """Gets and Sets the timeout (in seconds) used for communicating with the driver/server.""" ca_certs = _ClientConfigDescriptor("_ca_certs") """Gets and Sets the path to bundle of CA certificates.""" username = _ClientConfigDescriptor("_username") - """Gets and Sets the username used for basic authentication to the - remote.""" + """Gets and Sets the username used for basic authentication to the remote.""" password = _ClientConfigDescriptor("_password") - """Gets and Sets the password used for basic authentication to the - remote.""" + """Gets and Sets the password used for basic authentication to the remote.""" auth_type = _ClientConfigDescriptor("_auth_type") """Gets and Sets the type of authentication to the remote server.""" token = _ClientConfigDescriptor("_token") @@ -74,6 +71,10 @@ class ClientConfig: """Gets and Sets user agent to be added to the request headers.""" extra_headers = _ClientConfigDescriptor("_extra_headers") """Gets and Sets extra headers to be added to the request.""" + websocket_timeout = _ClientConfigDescriptor("_websocket_timeout") + """Gets and Sets the WebSocket response wait timeout (in seconds) used for communicating with the browser.""" + websocket_interval = _ClientConfigDescriptor("_websocket_interval") + """Gets and Sets the WebSocket response wait interval (in seconds) used for communicating with the browser.""" def __init__( self, @@ -90,6 +91,8 @@ def __init__( token: Optional[str] = None, user_agent: Optional[str] = None, extra_headers: Optional[dict] = None, + websocket_timeout: Optional[float] = 30.0, + websocket_interval: Optional[float] = 0.1, ) -> None: self.remote_server_addr = remote_server_addr self.keep_alive = keep_alive @@ -103,6 +106,8 @@ def __init__( self.token = token self.user_agent = user_agent self.extra_headers = extra_headers + self.websocket_timeout = websocket_timeout + self.websocket_interval = websocket_interval self.ca_certs = ( (os.getenv("REQUESTS_CA_BUNDLE") if "REQUESTS_CA_BUNDLE" in os.environ else certifi.where()) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index fa6db4ba1c1e9..03ea0f254b980 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + """The WebDriver implementation.""" import base64 @@ -1211,7 +1212,9 @@ def start_devtools(self): return self._devtools, self._websocket_connection if self.caps["browserName"].lower() == "firefox": raise RuntimeError("CDP support for Firefox has been removed. Please switch to WebDriver BiDi.") - self._websocket_connection = WebSocketConnection(ws_url) + self._websocket_connection = WebSocketConnection( + ws_url, self.client_config.websocket_timeout, self.client_config.websocket_interval + ) targets = self._websocket_connection.execute(self._devtools.target.get_targets()) for target in targets: if target.target_id == self.current_window_handle: @@ -1260,7 +1263,9 @@ def _start_bidi(self): else: raise WebDriverException("Unable to find url to connect to from capabilities") - self._websocket_connection = WebSocketConnection(ws_url) + self._websocket_connection = WebSocketConnection( + ws_url, self.client_config.websocket_timeout, self.client_config.websocket_interval + ) @property def network(self): diff --git a/py/selenium/webdriver/remote/websocket_connection.py b/py/selenium/webdriver/remote/websocket_connection.py index 55dc83471b1a4..5ff1ca0c974c8 100644 --- a/py/selenium/webdriver/remote/websocket_connection.py +++ b/py/selenium/webdriver/remote/websocket_connection.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + import json import logging from ssl import CERT_NONE @@ -28,15 +29,14 @@ class WebSocketConnection: - _response_wait_timeout = 30 - _response_wait_interval = 0.1 - _max_log_message_size = 9999 - def __init__(self, url): + def __init__(self, url, timeout, interval): self.callbacks = {} self.session_id = None self.url = url + self.response_wait_timeout = timeout + self.response_wait_interval = interval self._id = 0 self._messages = {} @@ -46,7 +46,7 @@ def __init__(self, url): self._wait_until(lambda: self._started) def close(self): - self._ws_thread.join(timeout=self._response_wait_timeout) + self._ws_thread.join(timeout=self.response_wait_timeout) self._ws.close() self._started = False self._ws = None @@ -142,8 +142,8 @@ def _process_message(self, message): Thread(target=callback, args=(params,)).start() def _wait_until(self, condition): - timeout = self._response_wait_timeout - interval = self._response_wait_interval + timeout = self.response_wait_timeout + interval = self.response_wait_interval while timeout > 0: result = condition() From 431ba5b9c3224bddc7f754513b5a80e6b6ed156a Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:59:11 -0400 Subject: [PATCH 2/4] [py] Add input validation for timeout and interval --- py/selenium/webdriver/remote/websocket_connection.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/py/selenium/webdriver/remote/websocket_connection.py b/py/selenium/webdriver/remote/websocket_connection.py index 5ff1ca0c974c8..931da65971de4 100644 --- a/py/selenium/webdriver/remote/websocket_connection.py +++ b/py/selenium/webdriver/remote/websocket_connection.py @@ -32,12 +32,17 @@ class WebSocketConnection: _max_log_message_size = 9999 def __init__(self, url, timeout, interval): - self.callbacks = {} - self.session_id = None + if not isinstance(timeout, (int, float)) or timeout < 0: + raise WebDriverException("timeout must be a positive number") + if not isinstance(interval, (int, float)) or timeout < 0: + raise WebDriverException("interval must be a positive number") + self.url = url self.response_wait_timeout = timeout self.response_wait_interval = interval + self.callbacks = {} + self.session_id = None self._id = 0 self._messages = {} self._started = False From 33b192fd31a958b699af5b8f248e7dc233216f12 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Sat, 23 Aug 2025 13:16:13 -0400 Subject: [PATCH 3/4] [py] Add unit test --- .../selenium/webdriver/remote/remote_connection_tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py index e2efccde7221f..5b860bda80c7f 100644 --- a/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/unit/selenium/webdriver/remote/remote_connection_tests.py @@ -57,6 +57,12 @@ def test_execute_custom_command(mock_request, remote_connection): assert response == {"status": 200, "value": "OK"} +def test_default_websocket_settings(): + config = ClientConfig(remote_server_addr="http://localhost:4444") + assert config.websocket_timeout == 30.0 + assert config.websocket_interval == 0.1 + + def test_get_remote_connection_headers_defaults(): url = "http://remote" headers = RemoteConnection.get_remote_connection_headers(parse.urlparse(url)) From 3dbba421200ddc7691104add62eb7f6f9401a562 Mon Sep 17 00:00:00 2001 From: Corey Goldberg <1113081+cgoldberg@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:38:00 -0400 Subject: [PATCH 4/4] Add test and update fixtures --- py/conftest.py | 16 ++++++---- py/selenium/webdriver/remote/webdriver.py | 8 +++-- .../remote/remote_connection_tests.py | 30 +++++++++++++++++-- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/py/conftest.py b/py/conftest.py index aeff257648150..a78c7779c3011 100644 --- a/py/conftest.py +++ b/py/conftest.py @@ -489,6 +489,10 @@ def firefox_options(request): except (AttributeError, TypeError): raise Exception("This test requires a --driver to be specified") + # skip if not Firefox or Remote + if driver_class not in ("firefox", "remote"): + pytest.skip(f"This test requires Firefox or Remote. Got {driver_class}") + # skip tests in the 'remote' directory if run with a local driver if request.node.path.parts[-2] == "remote" and getattr(_supported_drivers, driver_class) != "Remote": pytest.skip(f"Remote tests can't be run with driver '{driver_class}'") @@ -506,15 +510,17 @@ def chromium_options(request): except (AttributeError, TypeError): raise Exception("This test requires a --driver to be specified") - # Skip if not Chrome or Edge - if driver_class not in ("chrome", "edge"): - pytest.skip(f"This test requires Chrome or Edge, got {driver_class}") + # skip if not Chrome, Edge, or Remote + if driver_class not in ("chrome", "edge", "remote"): + pytest.skip(f"This test requires Chrome, Edge, or Remote. Got {driver_class}") # skip tests in the 'remote' directory if run with a local driver if request.node.path.parts[-2] == "remote" and getattr(_supported_drivers, driver_class) != "Remote": pytest.skip(f"Remote tests can't be run with driver '{driver_class}'") - if driver_class in ("chrome", "edge"): - options = Driver.clean_options(driver_class, request) + if driver_class in ("chrome", "remote"): + options = Driver.clean_options("chrome", request) + else: + options = Driver.clean_options("edge", request) return options diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index cee3b082bb86e..143064f1a6697 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -1212,7 +1212,9 @@ def start_devtools(self): if self.caps["browserName"].lower() == "firefox": raise RuntimeError("CDP support for Firefox has been removed. Please switch to WebDriver BiDi.") self._websocket_connection = WebSocketConnection( - ws_url, self.client_config.websocket_timeout, self.client_config.websocket_interval + ws_url, + self.command_executor.client_config.websocket_timeout, + self.command_executor.client_config.websocket_interval, ) targets = self._websocket_connection.execute(self._devtools.target.get_targets()) for target in targets: @@ -1263,7 +1265,9 @@ def _start_bidi(self): raise WebDriverException("Unable to find url to connect to from capabilities") self._websocket_connection = WebSocketConnection( - ws_url, self.client_config.websocket_timeout, self.client_config.websocket_interval + ws_url, + self.command_executor.client_config.websocket_timeout, + self.command_executor.client_config.websocket_interval, ) @property diff --git a/py/test/selenium/webdriver/remote/remote_connection_tests.py b/py/test/selenium/webdriver/remote/remote_connection_tests.py index 2ca8d57a7ec04..a1a9e05c5d2f5 100644 --- a/py/test/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/selenium/webdriver/remote/remote_connection_tests.py @@ -16,6 +16,7 @@ # under the License. import base64 +import time import filetype import pytest @@ -40,8 +41,8 @@ def test_remote_webdriver_with_http_timeout(firefox_options, webserver): set less than the implicit wait timeout, and verifies the http timeout is triggered first when waiting for an element. """ - http_timeout = 6 - wait_timeout = 8 + http_timeout = 4 + wait_timeout = 6 server_addr = f"http://{webserver.host}:{webserver.port}" client_config = ClientConfig(remote_server_addr=server_addr, timeout=http_timeout) assert client_config.timeout == http_timeout @@ -50,3 +51,28 @@ def test_remote_webdriver_with_http_timeout(firefox_options, webserver): driver.implicitly_wait(wait_timeout) with pytest.raises(ReadTimeoutError): driver.find_element(By.ID, "no_element_to_be_found") + + +def test_remote_webdriver_with_websocket_timeout(firefox_options, webserver): + """This test starts a remote webdriver that uses websockets, and has a websocket + client timeout less than the default. It verifies the websocket times out according + to this value. + """ + websocket_timeout = 2.0 + websocket_interval = 1.0 + + server_addr = f"http://{webserver.host}:{webserver.port}" + client_config = ClientConfig( + remote_server_addr=server_addr, websocket_timeout=websocket_timeout, websocket_interval=websocket_interval + ) + assert client_config.websocket_timeout == websocket_timeout + firefox_options.enable_bidi = True + with webdriver.Remote(options=firefox_options, client_config=client_config) as driver: + driver._start_bidi() + assert driver._websocket_connection.response_wait_timeout == websocket_timeout + assert driver._websocket_connection.response_wait_interval == websocket_interval + start = time.time() + driver._websocket_connection.close() + elapsed = time.time() - start + assert elapsed >= websocket_timeout + assert elapsed < websocket_timeout + 10