• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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