Path: blob/trunk/py/selenium/webdriver/common/service.py
4032 views
# Licensed to the Software Freedom Conservancy (SFC) under one1# or more contributor license agreements. See the NOTICE file2# distributed with this work for additional information3# regarding copyright ownership. The SFC licenses this file4# to you under the Apache License, Version 2.0 (the5# "License"); you may not use this file except in compliance6# with the License. You may obtain a copy of the License at7#8# http://www.apache.org/licenses/LICENSE-2.09#10# Unless required by applicable law or agreed to in writing,11# software distributed under the License is distributed on an12# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY13# KIND, either express or implied. See the License for the14# specific language governing permissions and limitations15# under the License.1617import errno18import logging19import os20import subprocess21import sys22from abc import ABC, abstractmethod23from collections.abc import Mapping24from io import IOBase25from subprocess import PIPE26from time import sleep27from typing import IO, Any28from urllib import request29from urllib.error import URLError3031from selenium.common.exceptions import WebDriverException32from selenium.webdriver.common import utils3334logger = logging.getLogger(__name__)353637class Service(ABC):38"""Abstract base class for all service objects that manage driver processes.3940Services typically launch a child program in a new process as an interim process to41communicate with a browser.4243Args:44executable_path: (Optional) Install path of the executable.45port: (Optional) Port for the service to run on, defaults to 0 where the operating system will decide.46log_output: (Optional) int representation of STDOUT/DEVNULL, any IO instance or String path to file.47env: (Optional) Mapping of environment variables for the new process, defaults to `os.environ`.48driver_path_env_key: (Optional) Environment variable to use to get the path to the driver executable.49"""5051def __init__(52self,53executable_path: str | None = None,54port: int = 0,55log_output: int | str | IO[Any] | None = None,56env: Mapping[Any, Any] | None = None,57driver_path_env_key: str | None = None,58**kwargs,59) -> None:60self.log_output: int | IO[Any] | None61if isinstance(log_output, str):62self.log_output = open(log_output, "a+", encoding="utf-8")63elif log_output == subprocess.STDOUT:64self.log_output = None65elif log_output is None or log_output == subprocess.DEVNULL:66self.log_output = subprocess.DEVNULL67else:68self.log_output = log_output6970self.port = port or utils.free_port()71# Default value for every python subprocess: subprocess.Popen(..., creationflags=0)72self.popen_kw = kwargs.pop("popen_kw", {})73self.creation_flags = self.popen_kw.pop("creation_flags", 0)74self.env = env or os.environ75self.DRIVER_PATH_ENV_KEY = driver_path_env_key76self._path = self.env_path() or executable_path7778@property79def service_url(self) -> str:80"""Gets the url of the Service."""81return f"http://{utils.join_host_port('localhost', self.port)}"8283@abstractmethod84def command_line_args(self) -> list[str]:85"""A List of program arguments (excluding the executable)."""86raise NotImplementedError("This method needs to be implemented in a sub class")8788@property89def path(self) -> str:90return self._path or ""9192@path.setter93def path(self, value: str) -> None:94self._path = str(value)9596def start(self) -> None:97"""Starts the Service.9899Raises:100WebDriverException: Raised either when it can't start the service101or when it can't connect to the service102"""103if self._path is None:104raise WebDriverException("Service path cannot be None.")105self._start_process(self._path)106107count = 0108while True:109self.assert_process_still_running()110if self.is_connectable():111break112# sleep increasing: 0.01, 0.06, 0.11, 0.16, 0.21, 0.26, 0.31, 0.36, 0.41, 0.46, 0.5113sleep(min(0.01 + 0.05 * count, 0.5))114count += 1115if count == 70:116raise WebDriverException(f"Can not connect to the Service {self._path}")117118def assert_process_still_running(self) -> None:119"""Check if the underlying process is still running."""120return_code = self.process.poll()121if return_code:122raise WebDriverException(f"Service {self._path} unexpectedly exited. Status code was: {return_code}")123124def is_connectable(self) -> bool:125"""Check if the service is ready via the W3C WebDriver /status endpoint.126127This makes an HTTP request to the /status endpoint and verifies if it is ready to accept new sessions.128129Returns:130True if the service is ready to accept new sessions, False otherwise.131"""132return utils.is_url_connectable(self.port)133134def send_remote_shutdown_command(self) -> None:135"""Dispatch an HTTP request to the shutdown endpoint to stop the service."""136try:137request.urlopen(f"{self.service_url}/shutdown")138except URLError:139return140141for _ in range(30):142if not self.is_connectable():143break144sleep(1)145146def stop(self) -> None:147"""Stops the service."""148if self.log_output not in {PIPE, subprocess.DEVNULL}:149if isinstance(self.log_output, IOBase):150self.log_output.close()151elif isinstance(self.log_output, int):152os.close(self.log_output)153154if self.process is not None and self.process.poll() is None:155try:156self.send_remote_shutdown_command()157except TypeError:158pass159finally:160self._terminate_process()161162def _terminate_process(self) -> None:163"""Terminate the child process.164165On POSIX this attempts a graceful SIGTERM followed by a SIGKILL,166on a Windows OS kill is an alias to terminate. Terminating does167not raise itself if something has gone wrong but (currently)168silently ignores errors here.169"""170try:171stdin, stdout, stderr = (172self.process.stdin,173self.process.stdout,174self.process.stderr,175)176for stream in stdin, stdout, stderr:177try:178stream.close() # type: ignore179except AttributeError:180pass181self.process.terminate()182try:183self.process.wait(60)184except subprocess.TimeoutExpired:185logger.error(186"Service process refused to terminate gracefully with SIGTERM, escalating to SIGKILL.",187exc_info=True,188)189self.process.kill()190except OSError:191logger.error("Error terminating service process.", exc_info=True)192193def __del__(self) -> None:194# `subprocess.Popen` doesn't send signal on `__del__`;195# so we attempt to close the launched process when `__del__`196# is triggered.197# do not use globals here; interpreter shutdown may have already cleaned them up198# and they would be `None`. This goes for anything this method is referencing internally.199try:200self.stop()201except Exception:202pass203204def _start_process(self, path: str) -> None:205"""Creates a subprocess by executing the command provided.206207Args:208path: full command to execute209"""210cmd = [path]211cmd.extend(self.command_line_args())212close_file_descriptors = self.popen_kw.pop("close_fds", sys.platform != "win32")213try:214start_info = None215if sys.platform == "win32":216start_info = subprocess.STARTUPINFO()217start_info.dwFlags = subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW218start_info.wShowWindow = subprocess.SW_HIDE219220self.process = subprocess.Popen(221cmd,222env=self.env,223close_fds=close_file_descriptors,224stdout=self.log_output,225stderr=self.log_output,226stdin=PIPE,227creationflags=self.creation_flags,228startupinfo=start_info,229**self.popen_kw,230)231logger.debug(232"Started executable: `%s` in a child process with pid: %s using %s to output %s",233self._path,234self.process.pid,235self.creation_flags,236self.log_output,237)238except TypeError:239raise240except OSError as err:241if err.errno == errno.EACCES:242if self._path is None:243raise WebDriverException("Service path cannot be None.")244raise WebDriverException(245f"'{os.path.basename(self._path)}' executable may have wrong permissions."246) from err247raise248249def env_path(self) -> str | None:250if self.DRIVER_PATH_ENV_KEY:251return os.getenv(self.DRIVER_PATH_ENV_KEY, None)252return None253254255