#!/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. """ from __future__ import print_function 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, debug=False): """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. device: (DeviceUtils) Android device selected to be used to generate orderfile. debug: (bool) Use simpler, non-representative debugging profile. """ assert device, 'Expected a valid device' self._device = 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._debug = debug 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:\n%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') profile_benchmark = 'orderfile_generation.training' if self._debug: logging.info('Using reduced debugging profile') profile_benchmark = 'orderfile_generation.debugging' self._SetUpDeviceFolders() self._RunCommand(['tools/perf/run_benchmark', '--device={}'.format(self._device.serial), '--browser=exact', '--browser-executable={}'.format(apk), profile_benchmark]) 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 /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') devices = device_utils.DeviceUtils.HealthyDevices() assert devices, 'Expected at least one connected device' profiler = AndroidProfileTool( args.output_directory, host_profile_dir=trace_directory, use_wpr=not args.no_wpr, urls=args.urls, simulate_user=args.simulate_user, device=devices[0]) profiler.CollectProfile(args.apk_path, package_info) return 0 if __name__ == '__main__': sys.exit(main())