| #!/usr/bin/env vpython |
| # Copyright (c) 2015 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. |
| |
| """Utility library for running a startup profile on an Android device. |
| |
| Sets up a device for cygprofile, disables sandboxing permissions, and sets up |
| support for web page replay, device forwarding, and fake certificate authority |
| to make runs repeatable. |
| """ |
| |
| import argparse |
| import logging |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import time |
| |
| _SRC_PATH = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) |
| sys.path.append(os.path.join(_SRC_PATH, 'third_party', 'catapult', 'devil')) |
| from devil.android import apk_helper |
| from devil.android import device_errors |
| from devil.android import device_utils |
| from devil.android import flag_changer |
| from devil.android import forwarder |
| from devil.android.sdk import intent |
| |
| sys.path.append(os.path.join(_SRC_PATH, 'build', 'android')) |
| import devil_chromium |
| from pylib import constants |
| |
| sys.path.append(os.path.join(_SRC_PATH, 'tools', 'perf')) |
| from core import path_util |
| sys.path.append(path_util.GetTelemetryDir()) |
| from telemetry.internal.util import webpagereplay_go_server |
| from telemetry.internal.util import binary_manager |
| |
| |
| class NoProfileDataError(Exception): |
| """An error used to indicate that no profile data was collected.""" |
| |
| def __init__(self, value): |
| super(NoProfileDataError, self).__init__() |
| self.value = value |
| |
| def __str__(self): |
| return repr(self.value) |
| |
| |
| def _DownloadFromCloudStorage(bucket, sha1_file_name): |
| """Download the given file based on a hash file.""" |
| cmd = ['download_from_google_storage', '--no_resume', |
| '--bucket', bucket, '-s', sha1_file_name] |
| print 'Executing command ' + ' '.join(cmd) |
| process = subprocess.Popen(cmd) |
| process.wait() |
| if process.returncode != 0: |
| raise Exception('Exception executing command %s' % ' '.join(cmd)) |
| |
| |
| def _SimulateSwipe(device, x1, y1, x2, y2): |
| """Simulates a swipe on a device from (x1, y1) to (x2, y2). |
| |
| Coordinates are in (device dependent) pixels, and the origin is at the upper |
| left corner. |
| The simulated swipe will take 300ms. |
| |
| Args: |
| device: (device_utils.DeviceUtils) device to run the command on. |
| x1, y1, x2, y2: (int) Coordinates. |
| """ |
| args = [str(x) for x in (x1, y1, x2, y2)] |
| device.RunShellCommand(['input', 'swipe'] + args) |
| |
| |
| class WprManager(object): |
| """A utility to download a WPR archive, host it, and forward device ports to |
| it. |
| """ |
| |
| _WPR_BUCKET = 'chrome-partner-telemetry' |
| |
| def __init__(self, wpr_archive, device, cmdline_file, package): |
| self._device = device |
| self._wpr_archive = wpr_archive |
| self._wpr_archive_hash = wpr_archive + '.sha1' |
| self._cmdline_file = cmdline_file |
| self._wpr_server = None |
| self._host_http_port = None |
| self._host_https_port = None |
| self._flag_changer = None |
| self._package = package |
| |
| def Start(self): |
| """Set up the device and host for WPR.""" |
| self.Stop() |
| self._BringUpWpr() |
| self._StartForwarder() |
| |
| def Stop(self): |
| """Clean up the device and host's WPR setup.""" |
| self._StopForwarder() |
| self._StopWpr() |
| |
| def __enter__(self): |
| self.Start() |
| |
| def __exit__(self, unused_exc_type, unused_exc_val, unused_exc_tb): |
| self.Stop() |
| |
| def _BringUpWpr(self): |
| """Start the WPR server on the host and the forwarder on the device.""" |
| print 'Starting WPR on host...' |
| _DownloadFromCloudStorage(self._WPR_BUCKET, self._wpr_archive_hash) |
| if binary_manager.NeedsInit(): |
| binary_manager.InitDependencyManager([]) |
| self._wpr_server = webpagereplay_go_server.ReplayServer( |
| self._wpr_archive, '127.0.0.1', 0, 0, replay_options=[]) |
| ports = self._wpr_server.StartServer() |
| self._host_http_port = ports['http'] |
| self._host_https_port = ports['https'] |
| |
| def _StopWpr(self): |
| """ Stop the WPR and forwarder.""" |
| print 'Stopping WPR on host...' |
| if self._wpr_server: |
| self._wpr_server.StopServer() |
| self._wpr_server = None |
| |
| def _StartForwarder(self): |
| """Sets up forwarding of device ports to the host, and configures chrome |
| to use those ports. |
| """ |
| if not self._wpr_server: |
| logging.warning('No host WPR server to forward to.') |
| return |
| print 'Starting device forwarder...' |
| forwarder.Forwarder.Map([(0, self._host_http_port), |
| (0, self._host_https_port)], |
| self._device) |
| device_http = forwarder.Forwarder.DevicePortForHostPort( |
| self._host_http_port) |
| device_https = forwarder.Forwarder.DevicePortForHostPort( |
| self._host_https_port) |
| self._flag_changer = flag_changer.FlagChanger( |
| self._device, self._cmdline_file) |
| self._flag_changer.AddFlags([ |
| '--host-resolver-rules=MAP * 127.0.0.1,EXCLUDE localhost', |
| '--testing-fixed-http-port=%s' % device_http, |
| '--testing-fixed-https-port=%s' % device_https, |
| |
| # Allows to selectively avoid certificate errors in Chrome. Unlike |
| # --ignore-certificate-errors this allows exercising the HTTP disk cache |
| # and avoids re-establishing socket connections. The value is taken from |
| # the WprGo documentation at: |
| # https://github.com/catapult-project/catapult/blob/master/web_page_replay_go/README.md |
| '--ignore-certificate-errors-spki-list=' + |
| 'PhrPvGIaAMmd29hj8BCZOq096yj7uMpRNHpn5PDxI6I=', |
| |
| # The flag --ignore-certificate-errors-spki-list (above) requires |
| # specifying the profile directory, otherwise it is silently ignored. |
| '--user-data-dir=/data/data/{}'.format(self._package)]) |
| |
| def _StopForwarder(self): |
| """Shuts down the port forwarding service.""" |
| if self._flag_changer: |
| print 'Restoring flags while stopping forwarder, but why?...' |
| self._flag_changer.Restore() |
| self._flag_changer = None |
| print 'Stopping device forwarder...' |
| forwarder.Forwarder.UnmapAllDevicePorts(self._device) |
| |
| |
| class AndroidProfileTool(object): |
| """A utility for generating orderfile profile data for chrome on android. |
| |
| Runs cygprofile_unittest found in output_directory, does profiling runs, |
| and pulls the data to the local machine in output_directory/profile_data. |
| """ |
| |
| _DEVICE_PROFILE_DIR = '/data/local/tmp/chrome/orderfile' |
| |
| # Old profile data directories that used to be used. These are cleaned up in |
| # order to keep devices tidy. |
| _LEGACY_PROFILE_DIRS = ['/data/local/tmp/chrome/cyglog'] |
| |
| TEST_URL = 'http://en.m.wikipedia.org/wiki/Science' |
| _WPR_ARCHIVE = os.path.join( |
| os.path.dirname(__file__), 'memory_top_10_mobile_000.wprgo') |
| |
| def __init__(self, output_directory, host_profile_dir, use_wpr, urls, |
| simulate_user, device=None): |
| """Constructor. |
| |
| Args: |
| output_directory: (str) Chrome build directory. |
| host_profile_dir: (str) Where to store the profiles on the host. |
| use_wpr: (bool) Whether to use Web Page Replay. |
| urls: (str) URLs to load. Have to be contained in the WPR archive if |
| use_wpr is True. |
| simulate_user: (bool) Whether to simulate a user. |
| """ |
| if device is None: |
| devices = device_utils.DeviceUtils.HealthyDevices() |
| assert len(devices) == 1, 'Expected exactly one connected device' |
| self._device = devices[0] |
| else: |
| self._device = device_utils.DeviceUtils(device) |
| self._cygprofile_tests = os.path.join( |
| output_directory, 'cygprofile_unittests') |
| self._host_profile_dir = host_profile_dir |
| self._use_wpr = use_wpr |
| self._urls = urls |
| self._simulate_user = simulate_user |
| self._SetUpDevice() |
| self._pregenerated_profiles = None |
| |
| def SetPregeneratedProfiles(self, files): |
| """Set pregenerated profiles. |
| |
| The pregenerated files will be returned as profile data instead of running |
| an actual profiling step. |
| |
| Args: |
| files: ([str]) List of pregenerated files. |
| """ |
| logging.info('Using pregenerated profiles') |
| self._pregenerated_profiles = files |
| |
| def RunCygprofileTests(self): |
| """Run the cygprofile unit tests suite on the device. |
| |
| Returns: |
| The exit code for the tests. |
| """ |
| device_path = '/data/local/tmp/cygprofile_unittests' |
| self._device.PushChangedFiles([(self._cygprofile_tests, device_path)]) |
| try: |
| self._device.RunShellCommand(device_path, check_return=True) |
| except (device_errors.CommandFailedError, |
| device_errors.DeviceUnreachableError): |
| # TODO(jbudorick): Let the exception propagate up once clients can |
| # handle it. |
| logging.exception('Failure while running cygprofile_unittests:') |
| return 1 |
| return 0 |
| |
| def CollectProfile(self, apk, package_info): |
| """Run a profile and collect the log files. |
| |
| Args: |
| apk: The location of the chrome apk to profile. |
| package_info: A PackageInfo structure describing the chrome apk, |
| as from pylib/constants. |
| |
| Returns: |
| A list of cygprofile data files. |
| |
| Raises: |
| NoProfileDataError: No data was found on the device. |
| """ |
| if self._pregenerated_profiles: |
| logging.info('Using pregenerated profiles instead of running profile') |
| logging.info('Profile files: %s', '\n'.join(self._pregenerated_profiles)) |
| return self._pregenerated_profiles |
| self._device.adb.Logcat(clear=True) |
| self._Install(apk) |
| try: |
| changer = self._SetChromeFlags(package_info) |
| self._SetUpDeviceFolders() |
| if self._use_wpr: |
| with WprManager(self._WPR_ARCHIVE, self._device, |
| package_info.cmdline_file, package_info.package): |
| self._RunProfileCollection(package_info, self._simulate_user) |
| else: |
| self._RunProfileCollection(package_info, self._simulate_user) |
| except device_errors.CommandFailedError as exc: |
| logging.error('Exception %s; dumping logcat', exc) |
| for logcat_line in self._device.adb.Logcat(dump=True): |
| logging.error(logcat_line) |
| raise |
| finally: |
| self._RestoreChromeFlags(changer) |
| |
| data = self._PullProfileData() |
| self._DeleteDeviceData() |
| return data |
| |
| def CollectSystemHealthProfile(self, apk): |
| """Run the orderfile system health benchmarks and collect log files. |
| |
| Args: |
| apk: The location of the chrome apk file to profile. |
| |
| Returns: |
| A list of cygprofile data files. |
| |
| Raises: |
| NoProfileDataError: No data was found on the device. |
| """ |
| if self._pregenerated_profiles: |
| logging.info('Using pregenerated profiles instead of running ' |
| 'system health profile') |
| logging.info('Profile files: %s', '\n'.join(self._pregenerated_profiles)) |
| return self._pregenerated_profiles |
| logging.info('Running system health profile') |
| self._SetUpDeviceFolders() |
| self._RunCommand(['tools/perf/run_benchmark', |
| '--device={}'.format(self._device.serial), |
| '--browser=exact', |
| '--browser-executable={}'.format(apk), |
| 'orderfile_generation.training']) |
| data = self._PullProfileData() |
| self._DeleteDeviceData() |
| return data |
| |
| @classmethod |
| def _RunCommand(cls, command): |
| """Run a command from current build directory root. |
| |
| Args: |
| command: A list of command strings. |
| |
| Returns: |
| The process's return code. |
| """ |
| root = constants.DIR_SOURCE_ROOT |
| print 'Executing {} in {}'.format(' '.join(command), root) |
| process = subprocess.Popen(command, cwd=root, env=os.environ) |
| process.wait() |
| return process.returncode |
| |
| def _RunProfileCollection(self, package_info, simulate_user): |
| """Runs the profile collection tasks. |
| |
| If |simulate_user| is True, then try to simulate a real user, with swiping. |
| Also do a first load of the page instead of about:blank, in order to |
| exercise the cache. This is not desirable with a page that only contains |
| cachable resources, as in this instance the network code will not be called. |
| |
| Args: |
| package_info: Which Chrome package to use. |
| simulate_user: (bool) Whether to try to simulate a user interacting with |
| the browser. |
| """ |
| initial_url = self._urls[0] if simulate_user else 'about:blank' |
| # Start up chrome once with a page, just to get the one-off |
| # activities out of the way such as apk resource extraction and profile |
| # creation. |
| self._StartChrome(package_info, initial_url) |
| time.sleep(15) |
| self._KillChrome(package_info) |
| self._SetUpDeviceFolders() |
| for url in self._urls: |
| self._StartChrome(package_info, url) |
| time.sleep(15) |
| if simulate_user: |
| # Down, down, up, up. |
| _SimulateSwipe(self._device, 200, 700, 200, 300) |
| _SimulateSwipe(self._device, 200, 700, 200, 300) |
| _SimulateSwipe(self._device, 200, 700, 200, 1000) |
| _SimulateSwipe(self._device, 200, 700, 200, 1000) |
| time.sleep(30) |
| self._AssertRunning(package_info) |
| self._KillChrome(package_info) |
| |
| def Cleanup(self): |
| """Delete all local and device files left over from profiling. """ |
| self._DeleteDeviceData() |
| self._DeleteHostData() |
| |
| def _Install(self, apk): |
| """Installs Chrome.apk on the device. |
| |
| Args: |
| apk: The location of the chrome apk to profile. |
| """ |
| print 'Installing apk...' |
| self._device.Install(apk) |
| |
| def _SetUpDevice(self): |
| """When profiling, files are output to the disk by every process. This |
| means running without sandboxing enabled. |
| """ |
| # We need to have adb root in order to pull profile data |
| try: |
| print 'Enabling root...' |
| self._device.EnableRoot() |
| # SELinux need to be in permissive mode, otherwise the process cannot |
| # write the log files. |
| print 'Putting SELinux in permissive mode...' |
| self._device.RunShellCommand(['setenforce', '0'], check_return=True) |
| except device_errors.CommandFailedError as e: |
| # TODO(jbudorick) Handle this exception appropriately once interface |
| # conversions are finished. |
| logging.error(str(e)) |
| |
| def _SetChromeFlags(self, package_info): |
| print 'Setting Chrome flags...' |
| changer = flag_changer.FlagChanger( |
| self._device, package_info.cmdline_file) |
| changer.AddFlags(['--no-sandbox', '--disable-fre']) |
| return changer |
| |
| def _RestoreChromeFlags(self, changer): |
| print 'Restoring Chrome flags...' |
| if changer: |
| changer.Restore() |
| |
| def _SetUpDeviceFolders(self): |
| """Creates folders on the device to store profile data.""" |
| print 'Setting up device folders...' |
| self._DeleteDeviceData() |
| self._device.RunShellCommand(['mkdir', '-p', self._DEVICE_PROFILE_DIR], |
| check_return=True) |
| |
| def _DeleteDeviceData(self): |
| """Clears out profile storage locations on the device. """ |
| for profile_dir in [self._DEVICE_PROFILE_DIR] + self._LEGACY_PROFILE_DIRS: |
| self._device.RunShellCommand( |
| ['rm', '-rf', str(profile_dir)], |
| check_return=True) |
| |
| def _StartChrome(self, package_info, url): |
| print 'Launching chrome...' |
| self._device.StartActivity( |
| intent.Intent(package=package_info.package, |
| activity=package_info.activity, |
| data=url, |
| extras={'create_new_tab': True}), |
| blocking=True, force_stop=True) |
| |
| def _AssertRunning(self, package_info): |
| assert self._device.GetApplicationPids(package_info.package), ( |
| 'Expected at least one pid associated with {} but found none'.format( |
| package_info.package)) |
| |
| def _KillChrome(self, package_info): |
| self._device.ForceStop(package_info.package) |
| |
| def _DeleteHostData(self): |
| """Clears out profile storage locations on the host.""" |
| shutil.rmtree(self._host_profile_dir, ignore_errors=True) |
| |
| def _SetUpHostFolders(self): |
| self._DeleteHostData() |
| os.mkdir(self._host_profile_dir) |
| |
| def _PullProfileData(self): |
| """Pulls the profile data off of the device. |
| |
| Returns: |
| A list of profile data files which were pulled. |
| |
| Raises: |
| NoProfileDataError: No data was found on the device. |
| """ |
| print 'Pulling profile data...' |
| self._SetUpHostFolders() |
| self._device.PullFile(self._DEVICE_PROFILE_DIR, self._host_profile_dir, |
| timeout=300) |
| |
| # Temporary workaround/investigation: if (for unknown reason) 'adb pull' of |
| # the directory 'orderfile' '.../Release/profile_data' produces |
| # '...profile_data/orderfile/files' instead of the usual |
| # '...profile_data/files', list the files deeper in the tree. |
| files = [] |
| redundant_dir_root = os.path.basename(self._DEVICE_PROFILE_DIR) |
| for root_file in os.listdir(self._host_profile_dir): |
| if root_file == redundant_dir_root: |
| profile_dir = os.path.join(self._host_profile_dir, root_file) |
| files.extend(os.path.join(profile_dir, f) |
| for f in os.listdir(profile_dir)) |
| else: |
| files.append(os.path.join(self._host_profile_dir, root_file)) |
| |
| if len(files) == 0: |
| raise NoProfileDataError('No profile data was collected') |
| |
| return files |
| |
| |
| def AddProfileCollectionArguments(parser): |
| """Adds the profiling collection arguments to |parser|.""" |
| parser.add_argument( |
| '--no-wpr', action='store_true', help='Don\'t use WPR.') |
| parser.add_argument('--urls', type=str, help='URLs to load.', |
| default=[AndroidProfileTool.TEST_URL], |
| nargs='+') |
| parser.add_argument( |
| '--simulate-user', action='store_true', help='More realistic collection.') |
| |
| |
| def CreateArgumentParser(): |
| """Creates and return the argument parser.""" |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| '--adb-path', type=os.path.realpath, |
| help='adb binary') |
| parser.add_argument( |
| '--apk-path', type=os.path.realpath, required=True, |
| help='APK to profile') |
| parser.add_argument( |
| '--output-directory', type=os.path.realpath, required=True, |
| help='Chromium output directory (e.g. out/Release)') |
| parser.add_argument( |
| '--trace-directory', type=os.path.realpath, |
| help='Directory in which profile traces will be stored. ' |
| 'Defaults to <output-directory>/profile_data') |
| AddProfileCollectionArguments(parser) |
| return parser |
| |
| |
| def main(): |
| parser = CreateArgumentParser() |
| args = parser.parse_args() |
| |
| devil_chromium.Initialize( |
| output_directory=args.output_directory, adb_path=args.adb_path) |
| |
| apk = apk_helper.ApkHelper(args.apk_path) |
| package_info = None |
| for p in constants.PACKAGE_INFO.itervalues(): |
| if p.package == apk.GetPackageName(): |
| package_info = p |
| break |
| else: |
| raise Exception('Unable to determine package info for %s' % args.apk_path) |
| |
| trace_directory = args.trace_directory |
| if not trace_directory: |
| trace_directory = os.path.join(args.output_directory, 'profile_data') |
| profiler = AndroidProfileTool( |
| args.output_directory, host_profile_dir=trace_directory, |
| use_wpr=not args.no_wpr, urls=args.urls, simulate_user=args.simulate_user) |
| profiler.CollectProfile(args.apk_path, package_info) |
| return 0 |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |