| # Copyright 2013 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from __future__ import print_function |
| import datetime |
| import hashlib |
| import logging |
| import os |
| import os.path |
| import random |
| import re |
| import shutil |
| import signal |
| import subprocess as subprocess |
| import sys |
| import tempfile |
| |
| import py_utils |
| from py_utils import cloud_storage |
| from py_utils import exc_util |
| |
| from telemetry.core import exceptions |
| from telemetry.internal.backends.chrome import chrome_browser_backend |
| from telemetry.internal.backends.chrome import minidump_finder |
| from telemetry.internal.backends.chrome import desktop_minidump_symbolizer |
| from telemetry.internal.util import format_for_logging |
| |
| |
| DEVTOOLS_ACTIVE_PORT_FILE = 'DevToolsActivePort' |
| |
| |
| class DesktopBrowserBackend(chrome_browser_backend.ChromeBrowserBackend): |
| """The backend for controlling a locally-executed browser instance, on Linux, |
| Mac or Windows. |
| """ |
| def __init__(self, desktop_platform_backend, browser_options, |
| browser_directory, profile_directory, |
| executable, flash_path, is_content_shell, |
| build_dir=None): |
| super(DesktopBrowserBackend, self).__init__( |
| desktop_platform_backend, |
| browser_options=browser_options, |
| browser_directory=browser_directory, |
| profile_directory=profile_directory, |
| supports_extensions=not is_content_shell, |
| supports_tab_control=not is_content_shell, |
| build_dir=build_dir) |
| self._executable = executable |
| self._flash_path = flash_path |
| self._is_content_shell = is_content_shell |
| |
| # Initialize fields so that an explosion during init doesn't break in Close. |
| self._proc = None |
| self._tmp_output_file = None |
| # pylint: disable=invalid-name |
| self._minidump_path_crashpad_retrieval = {} |
| # pylint: enable=invalid-name |
| |
| if not self._executable: |
| raise Exception('Cannot create browser, no executable found!') |
| |
| if self._flash_path and not os.path.exists(self._flash_path): |
| raise RuntimeError('Flash path does not exist: %s' % self._flash_path) |
| |
| if self.is_logging_enabled: |
| self._log_file_path = os.path.join(tempfile.mkdtemp(), 'chrome.log') |
| else: |
| self._log_file_path = None |
| |
| @property |
| def is_logging_enabled(self): |
| return self.browser_options.logging_verbosity in [ |
| self.browser_options.NON_VERBOSE_LOGGING, |
| self.browser_options.VERBOSE_LOGGING, |
| self.browser_options.SUPER_VERBOSE_LOGGING] |
| |
| @property |
| def log_file_path(self): |
| return self._log_file_path |
| |
| @property |
| def supports_uploading_logs(self): |
| return (self.browser_options.logs_cloud_bucket and self.log_file_path and |
| os.path.isfile(self.log_file_path)) |
| |
| def _GetDevToolsActivePortPath(self): |
| return os.path.join(self.profile_directory, DEVTOOLS_ACTIVE_PORT_FILE) |
| |
| def _FindDevToolsPortAndTarget(self): |
| devtools_file_path = self._GetDevToolsActivePortPath() |
| if not os.path.isfile(devtools_file_path): |
| raise EnvironmentError('DevTools file doest not exist yet') |
| # Attempt to avoid reading the file until it's populated. |
| # Both stat and open may raise IOError if not ready, the caller will retry. |
| lines = None |
| if os.stat(devtools_file_path).st_size > 0: |
| with open(devtools_file_path) as f: |
| lines = [line.rstrip() for line in f] |
| if not lines: |
| raise EnvironmentError('DevTools file empty') |
| |
| devtools_port = int(lines[0]) |
| browser_target = lines[1] if len(lines) >= 2 else None |
| return devtools_port, browser_target |
| |
| def Start(self, startup_args): |
| assert not self._proc, 'Must call Close() before Start()' |
| |
| self._dump_finder = minidump_finder.MinidumpFinder( |
| self.browser.platform.GetOSName(), self.browser.platform.GetArchName()) |
| |
| # macOS displays a blocking crash resume dialog that we need to suppress. |
| if self.browser.platform.GetOSName() == 'mac': |
| # Default write expects either the application name or the |
| # path to the application. self._executable has the path to the app |
| # with a few other bits tagged on after .app. Thus, we shorten the path |
| # to end with .app. If this is ineffective on your mac, please delete |
| # the saved state of the browser you are testing on here: |
| # /Users/.../Library/Saved\ Application State/... |
| # http://stackoverflow.com/questions/20226802 |
| dialog_path = re.sub(r'\.app\/.*', '.app', self._executable) |
| subprocess.check_call([ |
| 'defaults', 'write', '-app', dialog_path, 'NSQuitAlwaysKeepsWindows', |
| '-bool', 'false' |
| ]) |
| |
| cmd = [self._executable] |
| if self.browser.platform.GetOSName() == 'mac': |
| cmd.append('--use-mock-keychain') # crbug.com/865247 |
| cmd.extend(startup_args) |
| cmd.append('about:blank') |
| env = os.environ.copy() |
| env['CHROME_HEADLESS'] = '1' # Don't upload minidumps. |
| env['BREAKPAD_DUMP_LOCATION'] = self._tmp_minidump_dir |
| if self.is_logging_enabled: |
| sys.stderr.write( |
| 'Chrome log file will be saved in %s\n' % self.log_file_path) |
| env['CHROME_LOG_FILE'] = self.log_file_path |
| # Make sure we have predictable language settings that don't differ from the |
| # recording. |
| for name in ('LC_ALL', 'LC_MESSAGES', 'LANG'): |
| encoding = 'en_US.UTF-8' |
| if env.get(name, encoding) != encoding: |
| logging.warn('Overriding env[%s]=="%s" with default value "%s"', |
| name, env[name], encoding) |
| env[name] = 'en_US.UTF-8' |
| |
| self.LogStartCommand(cmd, env) |
| |
| if not self.browser_options.show_stdout: |
| self._tmp_output_file = tempfile.NamedTemporaryFile('w', 0) |
| self._proc = subprocess.Popen( |
| cmd, stdout=self._tmp_output_file, stderr=subprocess.STDOUT, env=env) |
| else: |
| self._proc = subprocess.Popen(cmd, env=env) |
| |
| self.BindDevToolsClient() |
| # browser is foregrounded by default on Windows and Linux, but not Mac. |
| if self.browser.platform.GetOSName() == 'mac': |
| subprocess.Popen([ |
| 'osascript', '-e', |
| ('tell application "%s" to activate' % self._executable) |
| ]) |
| if self._supports_extensions: |
| self._WaitForExtensionsToLoad() |
| |
| def LogStartCommand(self, command, env): |
| """Log the command used to start Chrome. |
| |
| In order to keep the length of logs down (see crbug.com/943650), |
| we sometimes trim the start command depending on browser_options. |
| The command may change between runs, but usually in innocuous ways like |
| --user-data-dir changes to a new temporary directory. Some benchmarks |
| do use different startup arguments for different stories, but this is |
| discouraged. This method could be changed to print arguments that are |
| different since the last run if need be. |
| """ |
| formatted_command = format_for_logging.ShellFormat( |
| command, trim=self.browser_options.trim_logs) |
| logging.info('Starting Chrome: %s\n', formatted_command) |
| if not self.browser_options.trim_logs: |
| logging.info('Chrome Env: %s', env) |
| |
| def BindDevToolsClient(self): |
| # In addition to the work performed by the base class, quickly check if |
| # the browser process is still alive. |
| if not self.IsBrowserRunning(): |
| raise exceptions.ProcessGoneException( |
| 'Return code: %d' % self._proc.returncode) |
| super(DesktopBrowserBackend, self).BindDevToolsClient() |
| |
| def GetPid(self): |
| if self._proc: |
| return self._proc.pid |
| return None |
| |
| def IsBrowserRunning(self): |
| return self._proc and self._proc.poll() is None |
| |
| def GetStandardOutput(self): |
| if not self._tmp_output_file: |
| if self.browser_options.show_stdout: |
| # This can happen in the case that loading the Chrome binary fails. |
| # We print rather than using logging here, because that makes a |
| # recursive call to this function. |
| print("Can't get standard output with --show-stdout", file=sys.stderr) |
| return '' |
| self._tmp_output_file.flush() |
| try: |
| with open(self._tmp_output_file.name) as f: |
| return f.read() |
| except IOError: |
| return '' |
| |
| def _IsExecutableStripped(self): |
| if self.browser.platform.GetOSName() == 'mac': |
| try: |
| symbols = subprocess.check_output(['/usr/bin/nm', self._executable]) |
| except subprocess.CalledProcessError as err: |
| logging.warning( |
| 'Error when checking whether executable is stripped: %s', |
| err.output) |
| # Just assume that binary is stripped to skip breakpad symbol generation |
| # if this check failed. |
| return True |
| num_symbols = len(symbols.splitlines()) |
| # We assume that if there are more than 10 symbols the executable is not |
| # stripped. |
| return num_symbols < 10 |
| else: |
| return False |
| |
| def _GetStackFromMinidump(self, minidump): |
| # Create an executable-specific directory if necessary to store symbols |
| # for re-use. We purposefully don't clean this up so that future |
| # tests can continue to use the same symbols that are unique to the |
| # executable. |
| symbols_dir = self._CreateExecutableUniqueDirectory('chrome_symbols_') |
| dump_symbolizer = desktop_minidump_symbolizer.DesktopMinidumpSymbolizer( |
| self.browser.platform.GetOSName(), |
| self.browser.platform.GetArchName(), |
| self._dump_finder, self.build_dir, symbols_dir=symbols_dir) |
| return dump_symbolizer.SymbolizeMinidump(minidump) |
| |
| def _CreateExecutableUniqueDirectory(self, prefix): |
| """Creates a semi-permanent directory unique to the browser executable. |
| |
| This directory will persist between different tests, and potentially |
| be available between different test suites, but is liable to be cleaned |
| up by the OS at any point outside of a test suite's run. |
| |
| Args: |
| prefix: A string to include before the unique identifier in the |
| directory name. |
| |
| Returns: |
| A string containing an absolute path to the created directory. |
| """ |
| hashfunc = hashlib.sha1() |
| with open(self._executable, 'rb') as infile: |
| hashfunc.update(infile.read()) |
| symbols_dirname = prefix + hashfunc.hexdigest() |
| # We can't use mkdtemp() directly since that will result in the directory |
| # being different, and thus not shared. So, create an unused directory |
| # and use the same parent directory. |
| unused_dir = tempfile.mkdtemp().rstrip(os.path.sep) |
| symbols_dir = os.path.join(os.path.dirname(unused_dir), symbols_dirname) |
| if not os.path.exists(symbols_dir) or not os.path.isdir(symbols_dir): |
| os.makedirs(symbols_dir) |
| shutil.rmtree(unused_dir) |
| return symbols_dir |
| |
| def _UploadMinidumpToCloudStorage(self, minidump_path): |
| """ Upload minidump_path to cloud storage and return the cloud storage url. |
| """ |
| remote_path = ('minidump-%s-%i.dmp' % |
| (datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S'), |
| random.randint(0, 1000000))) |
| try: |
| return cloud_storage.Insert(cloud_storage.TELEMETRY_OUTPUT, remote_path, |
| minidump_path) |
| except cloud_storage.CloudStorageError as err: |
| logging.error('Cloud storage error while trying to upload dump: %s', |
| repr(err)) |
| return '<Missing link>' |
| |
| def SymbolizeMinidump(self, minidump_path): |
| return self._InternalSymbolizeMinidump(minidump_path) |
| |
| def _InternalSymbolizeMinidump(self, minidump_path): |
| cloud_storage_link = self._UploadMinidumpToCloudStorage(minidump_path) |
| |
| stack = self._GetStackFromMinidump(minidump_path) |
| if not stack: |
| error_message = ('Failed to symbolize minidump. Raw stack is uploaded to' |
| ' cloud storage: %s.' % cloud_storage_link) |
| return (False, error_message) |
| |
| self._symbolized_minidump_paths.add(minidump_path) |
| return (True, stack) |
| |
| def __del__(self): |
| self.Close() |
| |
| def _TryCooperativeShutdown(self): |
| if self.browser.platform.IsCooperativeShutdownSupported(): |
| # Ideally there would be a portable, cooperative shutdown |
| # mechanism for the browser. This seems difficult to do |
| # correctly for all embedders of the content API. The only known |
| # problem with unclean shutdown of the browser process is on |
| # Windows, where suspended child processes frequently leak. For |
| # now, just solve this particular problem. See Issue 424024. |
| if self.browser.platform.CooperativelyShutdown(self._proc, "chrome"): |
| try: |
| # Use a long timeout to handle slow Windows debug |
| # (see crbug.com/815004) |
| # Allow specifying a custom shutdown timeout via the |
| # 'CHROME_SHUTDOWN_TIMEOUT' environment variable. |
| # TODO(sebmarchand): Remove this now that there's an option to shut |
| # down Chrome via Devtools. |
| py_utils.WaitFor(lambda: not self.IsBrowserRunning(), |
| timeout=int(os.getenv('CHROME_SHUTDOWN_TIMEOUT', 15)) |
| ) |
| logging.info('Successfully shut down browser cooperatively') |
| except py_utils.TimeoutException as e: |
| logging.warning('Failed to cooperatively shutdown. ' + |
| 'Proceeding to terminate: ' + str(e)) |
| |
| def Background(self): |
| raise NotImplementedError |
| |
| @exc_util.BestEffort |
| def Close(self): |
| super(DesktopBrowserBackend, self).Close() |
| |
| # First, try to cooperatively shutdown. |
| if self.IsBrowserRunning(): |
| self._TryCooperativeShutdown() |
| |
| # Second, try to politely shutdown with SIGINT. Use SIGINT instead of |
| # SIGTERM (or terminate()) here since the browser treats SIGTERM as a more |
| # urgent shutdown signal and may not free all resources. |
| if self.IsBrowserRunning() and self.browser.platform.GetOSName() != 'win': |
| self._proc.send_signal(signal.SIGINT) |
| try: |
| py_utils.WaitFor(lambda: not self.IsBrowserRunning(), |
| timeout=int(os.getenv('CHROME_SHUTDOWN_TIMEOUT', 5)) |
| ) |
| self._proc = None |
| except py_utils.TimeoutException: |
| logging.warning('Failed to gracefully shutdown.') |
| |
| # Shutdown aggressively if all above failed. |
| if self.IsBrowserRunning(): |
| logging.warning('Proceed to kill the browser.') |
| self._proc.kill() |
| self._proc = None |
| |
| if self._tmp_output_file: |
| self._tmp_output_file.close() |
| self._tmp_output_file = None |
| |
| if self._tmp_minidump_dir: |
| shutil.rmtree(self._tmp_minidump_dir, ignore_errors=True) |
| self._tmp_minidump_dir = None |