#!/usr/bin/env python # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at http://mozilla.org/MPL/2.0/. import copy import glob import json import os import posixpath import subprocess import sys import time # load modules from parent dir sys.path.insert(1, os.path.dirname(sys.path[0])) from mozharness.base.script import BaseScript, PreScriptAction from mozharness.mozilla.automation import EXIT_STATUS_DICT, TBPL_RETRY from mozharness.mozilla.mozbase import MozbaseMixin from mozharness.mozilla.testing.android import AndroidMixin from mozharness.mozilla.testing.testbase import TestingMixin, testing_config_options PAGES = [ "js-input/webkit/PerformanceTests/Speedometer/index.html", "js-input/webkit/PerformanceTests/Speedometer3/index.html?startAutomatically=true", # TODO: Add support for the pgo-extended-corpus to get JetStream3 running here. "blueprint/sample.html", "blueprint/forms.html", "blueprint/grid.html", "blueprint/elements.html", "js-input/3d-thingy.html", "js-input/crypto-otp.html", "js-input/collator_bench.html", "js-input/normalizer_bench.html", "js-input/sunspider/3d-cube.html", "js-input/sunspider/3d-morph.html", "js-input/sunspider/3d-raytrace.html", "js-input/sunspider/access-binary-trees.html", "js-input/sunspider/access-fannkuch.html", "js-input/sunspider/access-nbody.html", "js-input/sunspider/access-nsieve.html", "js-input/sunspider/bitops-3bit-bits-in-byte.html", "js-input/sunspider/bitops-bits-in-byte.html", "js-input/sunspider/bitops-bitwise-and.html", "js-input/sunspider/bitops-nsieve-bits.html", "js-input/sunspider/controlflow-recursive.html", "js-input/sunspider/crypto-aes.html", "js-input/sunspider/crypto-md5.html", "js-input/sunspider/crypto-sha1.html", "js-input/sunspider/date-format-tofte.html", "js-input/sunspider/date-format-xparb.html", "js-input/sunspider/math-cordic.html", "js-input/sunspider/math-partial-sums.html", "js-input/sunspider/math-spectral-norm.html", "js-input/sunspider/regexp-dna.html", "js-input/sunspider/string-base64.html", "js-input/sunspider/string-fasta.html", "js-input/sunspider/string-tagcloud.html", "js-input/sunspider/string-unpack-code.html", "js-input/sunspider/string-validate-input.html", ] class AndroidProfileRun(TestingMixin, BaseScript, MozbaseMixin, AndroidMixin): """ Mozharness script to generate an android PGO profile using the emulator """ config_options = copy.deepcopy(testing_config_options) def __init__(self, require_config_file=False): super().__init__( config_options=self.config_options, all_actions=[ "download", "create-virtualenv", "start-emulator", "verify-device", "install", "run-tests", ], require_config_file=require_config_file, config={ "virtualenv_modules": [], "virtualenv_requirements": [], "require_test_zip": True, "mozbase_requirements": "mozbase_source_requirements.txt", }, ) # these are necessary since self.config is read only c = self.config self.installer_path = c.get("installer_path") self.device_serial = "emulator-5554" def query_abs_dirs(self): if self.abs_dirs: return self.abs_dirs abs_dirs = super().query_abs_dirs() dirs = {} dirs["abs_test_install_dir"] = os.path.join(abs_dirs["abs_src_dir"], "testing") # On macOS, use checkout dir; on Linux, use /builds/worker if sys.platform == "darwin": base_dir = abs_dirs["abs_src_dir"] else: base_dir = "/builds/worker" dirs["abs_artifacts_dir"] = os.path.join(base_dir, "artifacts") dirs["abs_blob_upload_dir"] = os.path.join( dirs["abs_artifacts_dir"], "blobber_upload_dir" ) dirs["abs_workspace_dir"] = os.path.join(base_dir, "workspace") work_dir = os.environ.get("MOZ_FETCHES_DIR") or abs_dirs["abs_work_dir"] dirs["abs_xre_dir"] = os.path.join(work_dir, "hostutils") sdk_dirname = ( "android-sdk-macosx" if sys.platform == "darwin" else "android-sdk-linux" ) dirs["abs_sdk_dir"] = os.path.join(work_dir, sdk_dirname) dirs["abs_avds_dir"] = os.path.join(work_dir, "android-device") dirs["abs_bundletool_path"] = os.path.join(work_dir, "bundletool.jar") for key in dirs.keys(): if key not in abs_dirs: abs_dirs[key] = dirs[key] self.abs_dirs = abs_dirs return self.abs_dirs ########################################## # Actions for AndroidProfileRun # ########################################## def preflight_install(self): # in the base class, this checks for mozinstall, but we don't use it pass @PreScriptAction("create-virtualenv") def pre_create_virtualenv(self, action): dirs = self.query_abs_dirs() self.register_virtualenv_module( "marionette", os.path.join(dirs["abs_test_install_dir"], "marionette", "client"), ) def download(self): """ Download host utilities """ dirs = self.query_abs_dirs() self.xre_path = dirs["abs_xre_dir"] def install(self): """ Install APKs on the device. """ assert self.installer_path is not None, ( "Either add installer_path to the config or use --installer-path." ) self.install_android_app(self.installer_path) self.info("Finished installing apps for %s" % self.device_serial) def _wait_for_process(self, adbdevice, process_name, running, timeout=10): deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: output = adbdevice.shell_output(f"pgrep {process_name}").strip() found = bool(output) except Exception: found = False if found == running: return True time.sleep(0.5) return False def _stop_and_pull_recording(self, adbdevice, recording_path, video_filename): try: self.info("Stopping screen recording...") try: adbdevice.shell_output("pkill -SIGINT screenrecord", timeout=30) except Exception: # pkill exits with 1 if no process was found; ignore and still pull pass if not self._wait_for_process( adbdevice, "screenrecord", running=False, timeout=15 ): self.warning("Timed out waiting for screenrecord to stop") dirs = self.query_abs_dirs() blob_dir = dirs["abs_blob_upload_dir"] os.makedirs(blob_dir, exist_ok=True) video_dest = os.path.join(blob_dir, video_filename) adbdevice.pull(recording_path, video_dest) self.info(f"Screen recording saved to {video_dest}") adbdevice.shell_output(f"rm {recording_path}") except Exception as e: self.warning(f"Failed to stop/pull screen recording: {e}") def run_tests(self): """ Generate the PGO profile data """ from marionette_driver.marionette import Marionette from mozdevice import ADBDeviceFactory, ADBTimeoutError from mozhttpd import MozHttpd from mozprofile import Preferences app = self.query_package_name() IP = "10.0.2.2" PORT = 8888 PATH_MAPPINGS = { "/js-input/webkit/PerformanceTests": "third_party/webkit/PerformanceTests", } dirs = self.query_abs_dirs() topsrcdir = dirs["abs_src_dir"] adb = self.query_exe("adb") path_mappings = { k: os.path.join(topsrcdir, v) for k, v in PATH_MAPPINGS.items() } httpd = MozHttpd( port=PORT, docroot=os.path.join(topsrcdir, "build", "pgo"), path_mappings=path_mappings, ) httpd.start(block=False) profile_data_dir = os.path.join(topsrcdir, "testing", "profiles") with open(os.path.join(profile_data_dir, "profiles.json")) as fh: base_profiles = json.load(fh)["profileserver"] prefpaths = [ os.path.join(profile_data_dir, profile, "user.js") for profile in base_profiles ] prefs = {} for path in prefpaths: prefs.update(Preferences.read_prefs(path)) interpolation = {"server": "%s:%d" % httpd.httpd.server_address, "OOP": "false"} for k, v in prefs.items(): if isinstance(v, str): v = v.format(**interpolation) prefs[k] = Preferences.cast(v) adbdevice = ADBDeviceFactory(adb=adb, device="emulator-5554") outputdir = posixpath.join(adbdevice.test_root, "pgo_profile") jarlog = posixpath.join(outputdir, "en-US.log") profdata = posixpath.join(outputdir, "default_%p_random_%m.profraw") env = {} env["XPCOM_DEBUG_BREAK"] = "warn" env["MOZ_IN_AUTOMATION"] = "1" env["MOZ_JAR_LOG_FILE"] = jarlog env["LLVM_PROFILE_FILE"] = profdata if self.query_minidump_stackwalk(): os.environ["MINIDUMP_STACKWALK"] = self.minidump_stackwalk_path os.environ["MINIDUMP_SAVE_PATH"] = self.query_abs_dirs()["abs_blob_upload_dir"] if not self.symbols_path: self.symbols_path = os.environ.get("MOZ_FETCHES_DIR") adbdevice.mkdir(outputdir, parents=True) # Start screen recording of PGO training recording_path = "/sdcard/pgo-training.mp4" recording_started = False try: self.info("Starting screen recording of PGO training session...") sdk = int( adbdevice.shell_output( "getprop ro.build.version.sdk", timeout=5 ).strip() ) # --time-limit > 180 is only supported on Android 14 (API 34) and above time_limit_arg = "--time-limit 900 " if sdk >= 34 else "" adbdevice.shell_output( f"sh -c 'screenrecord --verbose {time_limit_arg}{recording_path} > /dev/null 2>&1 &'", timeout=10, ) if self._wait_for_process( adbdevice, "screenrecord", running=True, timeout=10 ): recording_started = True self.info("Screen recording started") else: self.warning("screenrecord did not start within timeout") except Exception as e: self.warning(f"Failed to start screen recording: {e}") try: # Run Fennec a first time to initialize its profile driver = Marionette( app="fennec", package_name=app, adb_path=adb, bin="geckoview-androidTest.apk", prefs=prefs, connect_to_running_emulator=True, startup_timeout=1000, env=env, symbols_path=self.symbols_path, ) driver.start_session() # Now generate the profile and wait for it to complete for page in PAGES: driver.navigate("http://%s:%d/%s" % (IP, PORT, page)) timeout = 2 if "Speedometer" in page: # The Speedometer[23] test actually runs many tests internally in # javascript, so it needs extra time to run through them. The # emulator doesn't get very far through the whole suite, but # this extra time at least lets some of them process. timeout = 360 time.sleep(timeout) driver.quit(in_app=True) if recording_started: self._stop_and_pull_recording( adbdevice, recording_path, "pgo-training.mp4" ) # Pull all the profraw files and en-US.log dirs = self.query_abs_dirs() workspace_dir = dirs["abs_workspace_dir"] os.makedirs(workspace_dir, exist_ok=True) adbdevice.pull(outputdir, workspace_dir) except ADBTimeoutError: if recording_started: self._stop_and_pull_recording( adbdevice, recording_path, "pgo-training-partial.mp4" ) self.fatal( "INFRA-ERROR: Failed with an ADBTimeoutError", EXIT_STATUS_DICT[TBPL_RETRY], ) dirs = self.query_abs_dirs() workspace_dir = dirs["abs_workspace_dir"] profraw_files = glob.glob(os.path.join(workspace_dir, "*.profraw")) if not profraw_files: self.fatal(f"Could not find any profraw files in {workspace_dir}") elif len(profraw_files) == 1: self.fatal( "Only found 1 profraw file. Did child processes terminate early?" ) merged_profdata = os.path.join(workspace_dir, "merged.profdata") merge_cmd = [ os.path.join(os.environ["MOZ_FETCHES_DIR"], "clang/bin/llvm-profdata"), "merge", "-o", merged_profdata, ] + profraw_files rc = subprocess.call(merge_cmd) if rc != 0: self.fatal( "INFRA-ERROR: Failed to merge profile data. Corrupt profile?", EXIT_STATUS_DICT[TBPL_RETRY], ) # tarfile doesn't support xz in this version of Python artifacts_dir = dirs["abs_artifacts_dir"] os.makedirs(artifacts_dir, exist_ok=True) tar_cmd = [ "tar", "-acvf", os.path.join(artifacts_dir, "profdata.tar.xz"), "-C", workspace_dir, "merged.profdata", "en-US.log", ] subprocess.check_call(tar_cmd) httpd.stop() if __name__ == "__main__": test = AndroidProfileRun() test.run_and_exit()