1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import contextlib 8import enum 9import logging 10from typing import TYPE_CHECKING, Iterable, Iterator, List, Optional, Tuple 11 12from crossbench.exception import TInfoStack 13from crossbench.flags.base import Flags 14from crossbench.flags.js_flags import JSFlags 15from crossbench.helper import ChangeCWD, Durations 16from crossbench.helper.state import BaseState, StateMachine 17from crossbench.probes.probe_context import ProbeSessionContext 18from crossbench.probes.results import EmptyProbeResult, ProbeResultDict 19from crossbench.runner.groups.base import RunGroup 20from crossbench.runner.probe_context_manager import ProbeContextManager 21from crossbench.runner.result_origin import ResultOrigin 22 23if TYPE_CHECKING: 24 from selenium.webdriver.common.options import ArgOptions 25 26 from crossbench.browsers.browser import Browser 27 from crossbench.env import HostEnvironment 28 from crossbench.network.base import Network 29 from crossbench.path import AnyPath, LocalPath 30 from crossbench.probes.probe import Probe 31 from crossbench.probes.results import ProbeResult 32 from crossbench.runner.run import Run 33 from crossbench.runner.timing import Timing 34 from crossbench.types import JsonDict 35 36 37@enum.unique 38class State(BaseState): 39 BUILDING = enum.auto() 40 READY = enum.auto() 41 SETUP = enum.auto() 42 STARTING = enum.auto() 43 RUNNING = enum.auto() 44 STOPPING = enum.auto() 45 DONE = enum.auto() 46 47 48class BrowserSessionRunGroup(RunGroup, ResultOrigin): 49 """ 50 Groups Run objects together that are run within the same browser session. 51 At the beginning of a new session the caches are cleared and the 52 browser is (re-)started. 53 """ 54 55 def __init__(self, env: HostEnvironment, probes: Iterable[Probe], 56 browser: Browser, extra_flags: Flags, index: int, 57 root_dir: LocalPath, create_symlinks: bool, throw: bool) -> None: 58 super().__init__(throw) 59 self._state: StateMachine[State] = StateMachine(State.BUILDING) 60 self._env = env 61 self._create_symlinks = create_symlinks 62 self._probes: Tuple[Probe, ...] = tuple(probes) 63 self._durations = Durations() 64 self._browser = browser 65 self._network: Network = browser.network 66 self._index: int = index 67 self._runs: List[Run] = [] 68 self._root_dir: LocalPath = root_dir 69 self._browser_tmp_dir: Optional[AnyPath] = None 70 self._extra_js_flags = JSFlags() 71 self._extra_flags = extra_flags 72 # Temporary objects, reset after all runs are ready (see set_ready). 73 self._probe_results = ProbeResultDict(root_dir) 74 self._probe_context_manager = ProbeSessionContextManager( 75 self, self._probe_results) 76 77 def append(self, run: Run) -> None: 78 self._state.expect(State.BUILDING) 79 assert run.browser_session == self 80 assert run.browser is self._browser 81 # TODO: assert that the runs have compatible flags (likely we're only 82 # allowing changes in the cache temperature) 83 # TODO: Add session/run switch for probe results 84 self._runs.append(run) 85 86 def set_ready(self) -> None: 87 self._state.transition(State.BUILDING, to=State.READY) 88 self._validate() 89 self._set_path(self._get_session_dir()) 90 self._probe_results = ProbeResultDict(self.path) 91 self._probe_context_manager = ProbeSessionContextManager( 92 self, self._probe_results) 93 94 def _validate(self) -> None: 95 if not self._runs: 96 raise ValueError("BrowserSessionRunGroup must be non-empty.") 97 self.browser.validate_env(self.env) 98 for run in self.runs: 99 run.validate_env(self.env) 100 self._validate_same_browser_probes() 101 102 def _validate_same_browser_probes(self) -> None: 103 first_run = self._runs[0] 104 first_probes = tuple(first_run.probes) 105 for index, run in enumerate(self.runs): 106 if first_run.browser is not run.browser: 107 raise ValueError("A browser session can only contain " 108 "Runs with the same Browser.\n" 109 f"runs[0].browser == {first_run.browser} vs. " 110 f"runs[{index}].browser == {run.browser}") 111 if first_probes != tuple(run.probes): 112 raise ValueError("Got conflicting Probes within a browser session.\n" 113 "All runs must have the same probes within a session.") 114 115 @property 116 def raw_session_dir(self) -> LocalPath: 117 return (self.root_dir / self.browser.unique_name / "sessions" / 118 str(self.index)) 119 120 @property 121 def is_single_run(self) -> bool: 122 return len(self._runs) == 1 123 124 @property 125 def first_run(self) -> Run: 126 return self._runs[0] 127 128 def _get_session_dir(self) -> LocalPath: 129 self._state.expect_at_least(State.READY) 130 if self.is_single_run: 131 return self.first_run.out_dir 132 if not self._runs: 133 raise ValueError("Cannot have empty browser session") 134 return self.raw_session_dir 135 136 @property 137 def out_dir(self) -> LocalPath: 138 return self._get_session_dir() 139 140 @property 141 def browser_dir(self) -> LocalPath: 142 return self.root_dir / self.browser.unique_name 143 144 @property 145 def durations(self) -> Durations: 146 return self._durations 147 148 @property 149 def env(self) -> HostEnvironment: 150 return self._env 151 152 @property 153 def probes(self) -> Iterable[Probe]: 154 return iter(self._probes) 155 156 @property 157 def network(self) -> Network: 158 return self._network 159 160 @property 161 def browser(self) -> Browser: 162 return self._browser 163 164 @property 165 def index(self) -> int: 166 return self._index 167 168 @property 169 def is_running(self) -> bool: 170 return self._state == State.RUNNING 171 172 @property 173 def root_dir(self) -> LocalPath: 174 return self._root_dir 175 176 @property 177 def runs(self) -> Iterable[Run]: 178 return iter(self._runs) 179 180 @property 181 def timing(self) -> Timing: 182 return self._runs[0].timing 183 184 @property 185 def extra_js_flags(self) -> JSFlags: 186 self._state.expect_before(State.RUNNING) 187 return self._extra_js_flags 188 189 @property 190 def extra_flags(self) -> Flags: 191 self._state.expect_before(State.RUNNING) 192 return self._extra_flags 193 194 def add_flag_details(self, details_json: JsonDict) -> None: 195 assert isinstance(details_json["js_flags"], tuple) 196 details_json["js_flags"] += tuple(self._extra_js_flags) 197 assert isinstance(details_json["flags"], tuple) 198 details_json["flags"] += tuple(self._extra_flags) 199 200 def setup_selenium_options(self, options: ArgOptions): 201 # Using only the first run, since all runs need to have the same probes. 202 self.first_run.setup_selenium_options(options) 203 204 @property 205 def info_stack(self) -> TInfoStack: 206 return ("Merging results from multiple browser sessions", 207 f"browser={self.browser.unique_name}", f"session={self.index}") 208 209 @property 210 def info(self) -> JsonDict: 211 info_dict = super().info 212 info_dict.update({"index": self.index}) 213 return info_dict 214 215 def __str__(self) -> str: 216 return f"Session({self.browser}, {self.index})" 217 218 @property 219 def browser_tmp_dir(self) -> AnyPath: 220 if not self._browser_tmp_dir: 221 prefix = f"cb_browser_session_{self.index}" 222 self._browser_tmp_dir = self.browser_platform.mkdtemp(prefix) 223 return self._browser_tmp_dir 224 225 def merge(self, probes: Iterable[Probe]) -> None: 226 # TODO: implement merging of session probes 227 pass 228 229 def _merge_probe_results(self, probe: Probe) -> ProbeResult: 230 return EmptyProbeResult() 231 232 @contextlib.contextmanager 233 def open(self, is_dry_run: bool = False) -> Iterator[bool]: 234 self._state.transition(State.READY, to=State.SETUP) 235 yielded = False 236 with self.exceptions.capture(): 237 self._setup_session_dir() 238 self._setup_browser() 239 with ChangeCWD(self.path): 240 with self._open(is_dry_run): 241 yielded = True 242 yield self.is_success 243 # Contextmanager always needs to yield, even in the case of early 244 # exceptions, the caller is responsible for skipping the body. 245 if not yielded: 246 assert not self.is_success 247 yield False 248 249 @contextlib.contextmanager 250 def _open(self, is_dry_run: bool) -> Iterator[None]: 251 self._state.expect(State.SETUP) 252 with self.measure("browser-session-setup"): 253 self._setup(is_dry_run) 254 try: 255 with self._start_network(), self._start_probes(is_dry_run): 256 self._start(is_dry_run) 257 try: 258 self._state.expect(State.RUNNING) 259 yield 260 except Exception as e: 261 logging.debug( 262 "BrowserSessionRunGroup: got unexpected inner exception: %s", e) 263 raise e 264 finally: 265 self._teardown(is_dry_run) 266 267 def _setup(self, is_dry_run: bool) -> None: 268 self._state.expect(State.SETUP) 269 self._probe_context_manager.setup(self.probes, is_dry_run) 270 # TODO: handle session vs run probe. 271 for run in self.runs: 272 with self._exceptions.annotate(f"Setting up {run}"): 273 label = "RUN" 274 if run.is_warmup: 275 label = "WARMUP RUN" 276 logging.info("Preparing SESSION %s, %s %s", self.index, label, 277 run.index) 278 run.setup(is_dry_run) 279 280 def _setup_browser(self) -> None: 281 self._state.expect(State.SETUP) 282 self.browser.setup_binary() 283 284 def _setup_session_dir(self) -> None: 285 self._state.expect(State.SETUP) 286 with self.measure("browser-session-setup-dir"): 287 self.path.mkdir(parents=True, exist_ok=True) 288 if not self._create_symlinks: 289 logging.debug("Symlink disabled by command line option") 290 return 291 if self.host_platform.is_win: 292 logging.debug("Skipping session_dir symlink on windows.") 293 return 294 if self.is_single_run: 295 # If there is a single run per session we reuse the run-dir. 296 self.raw_session_dir.parent.mkdir(parents=True, exist_ok=True) 297 self.raw_session_dir.symlink_to(self.path) 298 299 @contextlib.contextmanager 300 def _start_network(self): 301 logging.debug("Starting network: %s", self.network) 302 with self._exceptions.annotate(f"Starting Network: {self.network}"): 303 with self.network.open(self): 304 yield 305 306 @contextlib.contextmanager 307 def _start_probes(self, is_dry_run: bool): 308 with self._exceptions.annotate("Starting Session Probes"): 309 with self._probe_context_manager.open(is_dry_run): 310 yield 311 312 def _start(self, is_dry_run: bool) -> None: 313 self._state.transition(State.SETUP, to=State.STARTING) 314 with self.measure("browser-session-start"): 315 with self._exceptions.annotate(f"Starting Browser: {self.browser}"): 316 self._start_browser(is_dry_run) 317 self._state.transition(State.STARTING, to=State.RUNNING) 318 319 def _start_browser(self, is_dry_run: bool) -> None: 320 self._state.expect(State.STARTING) 321 assert self.network.is_running, "Network isn't running yet" 322 if is_dry_run: 323 logging.info("BROWSER: %s", self.browser.path) 324 return 325 assert self._probe_context_manager.is_running 326 browser_log_file = self.path / "browser.log" 327 assert not browser_log_file.exists(), ( 328 f"Default browser log file {browser_log_file} already exists.") 329 self._browser.set_log_file(browser_log_file) 330 331 with self.measure("browser-setup"): 332 try: 333 # pytype somehow gets the package path wrong here, disabling for now. 334 self._browser.setup(self) 335 except Exception as e: 336 logging.debug("Browser setup failed: %s", e) 337 # Clean up half-setup browser instances 338 self._browser.force_quit() 339 raise 340 341 def _teardown(self, is_dry_run: bool) -> None: 342 self._state.transition( 343 State.SETUP, State.STARTING, State.RUNNING, to=State.STOPPING) 344 with self.measure("browser-session-teardown"): 345 try: 346 self._stop_browser(is_dry_run) 347 finally: 348 self._state.transition(State.STOPPING, to=State.DONE) 349 self._probe_context_manager.teardown(is_dry_run) 350 351 def _stop_browser(self, is_dry_run: bool) -> None: 352 self._state.expect(State.STOPPING) 353 # TODO: move complete implementation here 354 # This can happen if a browser / probe setup error occurs and we're 355 # in a unclean state. 356 if self.browser.is_running: 357 self._runs[-1]._teardown_browser(is_dry_run) # pylint: disable=protected-access 358 359 # TODO: remove once cleanly implemented 360 def is_first_run(self, run: Run) -> bool: 361 return self.first_run is run 362 363 # TODO: remove once cleanly implemented 364 def is_last_run(self, run: Run) -> bool: 365 return self._runs[-1] is run 366 367 368class ProbeSessionContextManager(ProbeContextManager[BrowserSessionRunGroup, 369 ProbeSessionContext]): 370 371 def __init__(self, session: BrowserSessionRunGroup, 372 probe_results: ProbeResultDict): 373 super().__init__(session, probe_results) 374 375 def get_probe_context(self, probe: Probe) -> Optional[ProbeSessionContext]: 376 return probe.get_session_context(self._origin) 377