tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit f51e07f34bb20bb7abac5966cbd23931646b6388
parent 54d5bd08f37314363b2b6de2fec6ac389b8bc2ce
Author: Marc Leclair <mleclair@mozilla.com>
Date:   Fri, 12 Dec 2025 21:07:45 +0000

Bug 1983904: Geckoprofile can now be captured in CI  r=sparky,mozperftest-reviewers,mstange,perftest-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D262612

Diffstat:
Mpython/mozperftest/mozperftest/profiler.py | 4++--
Mpython/mozperftest/mozperftest/system/__init__.py | 3+++
Apython/mozperftest/mozperftest/system/geckoprofiler.py | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpython/mozperftest/mozperftest/system/simpleperf.py | 84+++++++++++++++++++++++++++++++++++--------------------------------------------
Apython/mozperftest/mozperftest/tests/test_geckoprofiler.py | 432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpython/mozperftest/mozperftest/tests/test_simpleperf.py | 28++++++++++++++++------------
Mpython/mozperftest/mozperftest/utils.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaskcluster/gecko_taskgraph/target_tasks.py | 2+-
Mtaskcluster/gecko_taskgraph/transforms/perftest.py | 4++--
Mtools/tryselect/selectors/perfselector/classification.py | 2+-
10 files changed, 817 insertions(+), 65 deletions(-)

diff --git a/python/mozperftest/mozperftest/profiler.py b/python/mozperftest/mozperftest/profiler.py @@ -2,9 +2,10 @@ # 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/. +from mozperftest.system.geckoprofiler import GeckoProfiler from mozperftest.system.simpleperf import SimpleperfProfiler -PROFILERS = {SimpleperfProfiler} +PROFILERS = [SimpleperfProfiler, GeckoProfiler] class ProfilingMediator: @@ -12,7 +13,6 @@ class ProfilingMediator: def __init__(self): self.active_profilers = [] - for profiler in PROFILERS: if profiler.is_enabled(): self.active_profilers.append(profiler.get_controller()) diff --git a/python/mozperftest/mozperftest/system/__init__.py b/python/mozperftest/mozperftest/system/__init__.py @@ -4,6 +4,7 @@ from mozperftest.layers import Layers from mozperftest.system.android import AndroidDevice from mozperftest.system.binarysetup import BinarySetup +from mozperftest.system.geckoprofiler import GeckoProfiler from mozperftest.system.macos import MacosDevice from mozperftest.system.pingserver import PingServer from mozperftest.system.profile import Profile @@ -20,6 +21,7 @@ def get_layers(): AndroidDevice, MacosDevice, SimpleperfProfiler, + GeckoProfiler, ) @@ -86,6 +88,7 @@ def pick_system(env, flavor, mach_cmd): MacosDevice, VersionProducer, SimpleperfProfiler, + GeckoProfiler, ] return Layers(env, mach_cmd, layers) if flavor == "alert": diff --git a/python/mozperftest/mozperftest/system/geckoprofiler.py b/python/mozperftest/mozperftest/system/geckoprofiler.py @@ -0,0 +1,259 @@ +# 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 gzip +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from mozdevice import ADBDevice + +from mozperftest.layers import Layer +from mozperftest.utils import archive_files, extract_tgz_and_find_files + + +class GeckoProfilerError(Exception): + """Base class for Gecko profiler-related exceptions.""" + + pass + + +class GeckoProfilerAlreadyActiveError(GeckoProfilerError): + """Raised when attempting to start profiling while it's already active.""" + + pass + + +class GeckoProfilerNotActiveError(GeckoProfilerError): + """Raised when attempting to stop profiling when it's not active.""" + + pass + + +GECKOVIEW_CONFIG_PATH_PREFIX = "/data/local/tmp" + +DEFAULT_GECKOPROFILER_OPTS = { + "interval": 5, + "features": "js,stackwalk,screenshots,ipcmessages,java,cpu,memory", + "filters": "GeckoMain,Compositor,Renderer,IPDL Background,socket", +} + + +class GeckoProfilerController: + """Controller that starts and stops profiling on device.""" + + _package_id = None + + @classmethod + def set_package_id(cls, package_id: str): + cls._package_id = package_id + + def __init__(self): + self.device = ADBDevice() + self.profiling_active = False + self.package_id = None + self.config_filename = None + + def _resolve_package_id(self): + pkg = self._package_id or os.environ.get("BROWSER_BINARY") + if not pkg: + raise GeckoProfilerError("Package id not set for GeckoProfiler") + return pkg + + def start(self, geckoprofiler_opts=None): + """Create a temporary geckoview-config.yaml on device and enable startup profiling.""" + if geckoprofiler_opts is None: + geckoprofiler_opts = DEFAULT_GECKOPROFILER_OPTS + else: + opts = DEFAULT_GECKOPROFILER_OPTS.copy() + opts.update(geckoprofiler_opts) + geckoprofiler_opts = opts + + assert GeckoProfiler.is_enabled() + if self.profiling_active: + raise GeckoProfilerAlreadyActiveError("Gecko profiler already active") + + self.package_id = self._resolve_package_id() + self.config_filename = f"{self.package_id}-geckoview-config.yaml" + + config_content = f"""env: + MOZ_PROFILER_STARTUP: 1 + MOZ_PROFILER_STARTUP_INTERVAL: {geckoprofiler_opts['interval']} + MOZ_PROFILER_STARTUP_FEATURES: {geckoprofiler_opts['features']} + MOZ_PROFILER_STARTUP_FILTERS: {geckoprofiler_opts['filters']} +""".encode() + + with tempfile.NamedTemporaryFile(delete=False) as config_file: + config_file.write(config_content) + config_path = config_file.name + + device_config_path = f"{GECKOVIEW_CONFIG_PATH_PREFIX}/{self.config_filename}" + + subprocess.run( + ["adb", "push", config_path, device_config_path], + capture_output=True, + text=True, + check=True, + ) + + subprocess.run( + [ + "adb", + "shell", + "am", + "set-debug-app", + "--persistent", + self.package_id, + ], + capture_output=True, + text=True, + check=True, + ) + + self.profiling_active = True + + def stop(self, output_path, index): + assert GeckoProfiler.is_enabled() + if not self.profiling_active: + raise GeckoProfilerNotActiveError("No active profiling session found") + + # Use content provider to stop profiling and stream raw profile data + # This command blocks until the profile data is fully streamed + output_filename = f"profile-{index}.json" + local_output_path = Path(output_path) / output_filename + + with open(local_output_path, "wb") as output_file: + result = subprocess.run( + [ + "adb", + "shell", + "content", + "read", + "--uri", + f"content://{self.package_id}.profiler/stop-and-upload", + ], + stdout=output_file, + stderr=subprocess.PIPE, + check=False, + ) + + # Always reset state, even if the command failed + self.profiling_active = False + + if result.returncode != 0: + print( + f"Warning: Failed to stop profiler via content provider (exit code {result.returncode})" + ) + if result.stderr: + print(f"Error details:\n{result.stderr.decode()}") + return None + + with open(local_output_path, "rb") as f: + file_data = f.read() + + compressed_size = len(file_data) + try: + decompressed_data = gzip.decompress(file_data) + print( + f"Geckoprofile gzipped size ({compressed_size} bytes -> {len(decompressed_data)} bytes)" + ) + with open(local_output_path, "wb") as output_file: + output_file.write(decompressed_data) + except gzip.BadGzipFile: + print(f"Profile data is not compressed ({compressed_size} bytes)") + + file_size = local_output_path.stat().st_size + print(f"Profile saved to: {local_output_path} ({file_size} bytes)") + return local_output_path + + +class GeckoProfiler(Layer): + name = "geckoprofiler" + activated = False + + def __init__(self, env, mach_cmd): + super().__init__(env, mach_cmd) + self.device = ADBDevice() + self.output_dir = None + self.test_name = None + + @staticmethod + def is_enabled(): + return os.environ.get("MOZPERFTEST_GECKOPROFILE", "0") == "1" + + @staticmethod + def get_controller(): + return GeckoProfilerController() + + def _archive_profiles(self): + """Collect Gecko profiles and add to archive.""" + + if not self.output_dir: + self.info("No output directory set, skipping profile archiving") + return + + patterns = ["profile-*.json"] + self.info(f"geckoview output_dir {self.output_dir} and test {self.test_name}") + + profiles, work_dir = extract_tgz_and_find_files( + self.output_dir, self.test_name, patterns + ) + + try: + if profiles: + # Profiles are streamed directly from device and ready to use + profiles.sort() + archive_files( + profiles, + self.output_dir, + f"profile_{self.test_name}", + prefix="gecko", + ) + self.info("Archived gecko profiles") + finally: + if work_dir: + shutil.rmtree(work_dir) + + def _cleanup(self): + """Cleanup step, called during setup and teardown + + Remove the config files, clear debug app, and unset env flag. + """ + self.device.shell( + f"rm -f {GECKOVIEW_CONFIG_PATH_PREFIX}/*-geckoview-config.yaml" + ) + self.device.shell("am clear-debug-app") + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + GeckoProfilerController._package_id = None + + def setup(self): + self._cleanup() + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + + def teardown(self): + """Cleanup on teardown and add profiles to shared archive.""" + # Collect gecko profiles and add them to a zip file + self._archive_profiles() + self._cleanup() + + def run(self, metadata): + """Run the geckoprofiler layer. + + The run step of the geckoprofiler layer is a no-op since the expectation is that + the start/stop controls are manually called through the ProfilerMediator. + """ + + if not self.env.get_layer("android"): + raise GeckoProfilerError( + "GeckoProfiler is only supported on Android. Please enable the android layer." + ) + metadata.add_extra_options(["gecko-profile"]) + pkg = getattr(metadata, "binary", None) + if pkg: + GeckoProfilerController.set_package_id(pkg) + output = self.get_arg("output", self.output_dir) + self.output_dir = Path(output) if output else None + self.test_name = metadata.script["name"] + return metadata diff --git a/python/mozperftest/mozperftest/system/simpleperf.py b/python/mozperftest/mozperftest/system/simpleperf.py @@ -6,8 +6,6 @@ import os import shutil import signal import subprocess -import tarfile -import tempfile import time import zipfile from pathlib import Path @@ -17,7 +15,7 @@ from mozbuild.nodeutil import find_node_executable from mozdevice import ADBDevice from mozperftest.layers import Layer -from mozperftest.utils import ON_TRY +from mozperftest.utils import ON_TRY, archive_files, extract_tgz_and_find_files """The default Simpleperf options will collect a 30s system-wide profile that uses DWARF based call graph so that we can collect Java stacks. This requires root access. @@ -131,10 +129,11 @@ class SimpleperfController: raise SimpleperfExecutionError("failed to run simpleperf") self.profiler_process = None - output_path = str(Path(output_path, f"perf-{index}.data")) + profile_path = Path(output_path, f"perf-{index}.data") # Pull profiler data directly to the given output path. - self.device.pull("/data/local/tmp/perf.data", output_path) + self.device.pull("/data/local/tmp/perf.data", str(profile_path)) self.device.shell("rm -f /data/local/tmp/perf.data") + return Path(output_path, f"perf-{index}.data") class SimpleperfProfiler(Layer): @@ -195,7 +194,7 @@ class SimpleperfProfiler(Layer): android.ensure_android_ndk(os_name) - self.set_arg("path", str(Path(android.NDK_PATH, "simpleperf"))) + self.set_arg("path", Path(android.NDK_PATH, "simpleperf")) # Make sure the arm64 binary exists in the NDK path. binary_path = Path( @@ -268,15 +267,14 @@ class SimpleperfProfiler(Layer): :return bool: Returns True if preparation is successful, False otherwise. """ - self.output_dir = self.get_arg("output") - self.work_dir = Path(tempfile.mkdtemp()) + output = self.get_arg("output") + self.output_dir = Path(output) if output else None if ON_TRY: moz_fetch = os.environ["MOZ_FETCHES_DIR"] self.breakpad_symbol_dir = Path(moz_fetch, "target.crashreporter-symbols") self.samply_path = Path(moz_fetch, "samply", "samply") self.node_path = Path(moz_fetch, "node", "bin", "node") - self.tgz_path = Path(self.output_dir, self.test_name) self.symbolicator_dir = Path(moz_fetch, "symbolicator-cli") # Extracting crashreporter symbols @@ -293,35 +291,12 @@ class SimpleperfProfiler(Layer): ) ) - def _get_perf_data(self): - """Retrieve all the perf.data profiles generated by simpleperf. On CI, - .tgz file containing the profiles needs to be extracted first. - - :return list[pathlib.Path]: Returns list of paths to perf.data files - """ - data_dir = self.output_dir - - if ON_TRY: - # Extract perf.data files - tgz_file = Path(f"{self.tgz_path}.tgz") - with tarfile.open(tgz_file, "r:gz") as tar: - tar.extractall(path=self.work_dir) - - data_dir = self.work_dir - - perf_data = [ - data_file - for data_file in Path(data_dir).rglob("*.data") - if data_file.is_file() - ] - - return perf_data - - def _convert_perf_to_json(self, perf_data): + def _convert_perf_to_json(self, perf_data, work_dir): """Convert perf.data files into .json files into the Firefox Profiler's processed profile format. :param perf_data list[pathlib.Path]: list of paths to perf.data files + :param work_dir pathlib.Path: working directory for output files :return list[pathlib.Path]: Returns list of paths to .json profiles """ @@ -331,7 +306,10 @@ class SimpleperfProfiler(Layer): for file_path in perf_data: filename = file_path.stem number = filename.split("-")[-1] - output_path = Path(self.work_dir, f"profile-{number}-unsymbolicated.json") + output_path = Path( + work_dir if work_dir else self.output_dir, + f"profile-{number}-unsymbolicated.json", + ) # Run samply import as a blocking command to ensure perf.data # is processed to profile.json before proceeding @@ -359,13 +337,14 @@ class SimpleperfProfiler(Layer): return unsymbolicated_profiles - def _symbolicate_profiles(self, unsymbolicated_profiles): + def _symbolicate_profiles(self, unsymbolicated_profiles, work_dir): """Symbolicate .json profiles. This involves loading the profiles with samply, capturing the symbol server url, and processing the files with symbolicator-cli. :param unsymbolicated_profiles list[pathlib.Path]: list of paths to unsymbolicated profile.json files in processed profile format. + :param work_dir pathlib.Path: working directory for output files :raises SimpleperfSymbolicationTimeoutError: Error if obtaining the symbol server URL from the samply process exceeds SYMBOL_SERVER_TIMEOUT seconds. @@ -411,7 +390,9 @@ class SimpleperfProfiler(Layer): # Symbolicate profiles with a blocking symbolicator-cli call input_profile_path = file_path filename = file_path.stem.replace("-unsymbolicated", "") - output_profile_path = Path(self.work_dir, f"{filename}.json") + output_profile_path = Path( + work_dir if work_dir else self.output_dir, f"{filename}.json" + ) with subprocess.Popen( [ str(self.node_path), @@ -441,7 +422,7 @@ class SimpleperfProfiler(Layer): return symbolicated_profiles def _archive_profiles(self, symbolicated_profiles): - """Archive all symbolicated profiles into a compressed .zip file. + """Archive all symbolicated profiles into a compressed .zip file :param symbolicated_profiles list[pathlib.Path]: List of paths to symbolicated profile.json files to be archived. @@ -449,10 +430,12 @@ class SimpleperfProfiler(Layer): # Archive and export symbolicated profiles symbolicated_profiles.sort() - output_zip_path = Path(self.output_dir, f"profile_{self.test_name}.zip") - with zipfile.ZipFile(output_zip_path, "w") as zipf: - for file_path in symbolicated_profiles: - zipf.write(file_path, arcname=file_path.name) + archive_files( + symbolicated_profiles, + self.output_dir, + f"profile_{self.test_name}", + prefix="simpleperf", + ) def _symbolicate(self): """Convert perf data to symbolicated profiles. @@ -464,18 +447,25 @@ class SimpleperfProfiler(Layer): symbolicator-cli must be provided via command-line arguments for local symbolication. """ + work_dir = None try: self.info("Preparing symbolication environment") self._prepare_symbolication_environment() self.info("Obtaining perf.data files") - perf_data = self._get_perf_data() + perf_data, work_dir = extract_tgz_and_find_files( + self.output_dir, self.test_name, ["*.data"] + ) + + # For local runs, work_dir will be None, self.info("Converting perf.data files to profile.json files") - unsymbolicated_profiles = self._convert_perf_to_json(perf_data) + unsymbolicated_profiles = self._convert_perf_to_json(perf_data, work_dir) self.info("Symbolicating profile.json files") - symbolicated_profiles = self._symbolicate_profiles(unsymbolicated_profiles) + symbolicated_profiles = self._symbolicate_profiles( + unsymbolicated_profiles, work_dir + ) self.info("Archiving symbolicated profile.json files") self._archive_profiles(symbolicated_profiles) @@ -491,8 +481,8 @@ class SimpleperfProfiler(Layer): ) finally: - if self.work_dir.exists(): - shutil.rmtree(self.work_dir) # Ensure cleanup + if work_dir: + shutil.rmtree(work_dir) # Ensure cleanup def teardown(self): self._symbolicate() diff --git a/python/mozperftest/mozperftest/tests/test_geckoprofiler.py b/python/mozperftest/mozperftest/tests/test_geckoprofiler.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python +import gzip +import os +import tarfile +from functools import partial +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +import mozunit +import pytest + +from mozperftest.system.geckoprofiler import ( + DEFAULT_GECKOPROFILER_OPTS, + GeckoProfiler, + GeckoProfilerAlreadyActiveError, + GeckoProfilerController, + GeckoProfilerError, + GeckoProfilerNotActiveError, +) +from mozperftest.tests.support import EXAMPLE_SHELL_TEST, get_running_env + + +def running_env(**kw): + return get_running_env(flavor="custom-script", **kw) + + +class FakeDevice: + def __init__(self): + self.pushed_files = {} + self.commands = [] + self.pulled_files = {} + self.files_on_device = set() + + def push(self, source, destination): + self.pushed_files[destination] = source + + def shell(self, command): + self.commands.append(command) + return "" + + def pull(self, source, destination): + self.pulled_files[destination] = source + + def exists(self, path): + return path in self.files_on_device + + +def mock_subprocess_run(data, returncode, cmd, stdout=None, **kwargs): + """Mock subprocess.run that writes data to a file. + + Args: + data: bytes to write to the file + returncode: exit code to return + cmd: Command being run passed by subprocess.run + stdout: File handle to write to passed by subprocess.run + **kwargs: Other subprocess.run arguments + + Returns: + Mock object with returncode and stderr attributes + """ + if stdout: + stdout.write(data) + result = mock.Mock() + result.returncode = returncode + result.stderr = b"" + return result + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_geckoprofiler_setup(): + mach_cmd, metadata, env = running_env( + app="fenix", tests=[str(EXAMPLE_SHELL_TEST)], output=None + ) + + profiler = GeckoProfiler(env, mach_cmd) + + profiler.setup() + + assert os.environ.get("MOZPERFTEST_GECKOPROFILE") == "1" + + # Mock get_layer to simulate Android layer being present + profiler.env.get_layer = mock.Mock(return_value=True) + result = profiler.run(metadata) + assert result == metadata + assert metadata.get_extra_options() == ["gecko-profile"] + + # Mock _archive_profiles to avoid file operations + profiler._archive_profiles = mock.Mock() + profiler.teardown() + + assert "MOZPERFTEST_GECKOPROFILE" not in os.environ + cleanup_cmd = "rm -f /data/local/tmp/*-geckoview-config.yaml" + assert cleanup_cmd in profiler.device.commands + assert "am clear-debug-app" in profiler.device.commands + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_geckoprofiler_run_requires_android_layer(): + """Test to verify that running without the Android layer will throw an error.""" + mach_cmd, metadata, env = running_env( + app="fenix", tests=[str(EXAMPLE_SHELL_TEST)], output=None + ) + + profiler = GeckoProfiler(env, mach_cmd) + + with pytest.raises(GeckoProfilerError) as excinfo: + profiler.run(metadata) + + assert "only supported on Android" in str(excinfo.value) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_geckoprofiler_is_enabled(): + """Test that verifies that profiling is enabled if MOZPERFTEST_GECKOPROFILE is set to 1 .""" + assert not GeckoProfiler.is_enabled() + + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + assert GeckoProfiler.is_enabled() + + os.environ.pop("MOZPERFTEST_GECKOPROFILE") + assert not GeckoProfiler.is_enabled() + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_geckoprofiler_cleanup_resets_state(): + """Test to make sure cleanup removes the GECKOPROFILE environment variable and resets package ID in the controller""" + mach_cmd, metadata, env = running_env( + app="fenix", tests=[str(EXAMPLE_SHELL_TEST)], output=None + ) + + profiler = GeckoProfiler(env, mach_cmd) + + GeckoProfilerController.set_package_id("org.mozilla.firefox") + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + + profiler._cleanup() + + assert "MOZPERFTEST_GECKOPROFILE" not in os.environ + assert GeckoProfilerController._package_id is None + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +@mock.patch("tempfile.NamedTemporaryFile") +def test_controller_start_with_default_options(mock_temp, mock_run): + """Test starting the controller with no options uses the default settings and pushes them as a YAML config file to the device""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.fenix") + mock_temp.return_value.__enter__.return_value.name = "/tmp/test_config" + + controller = GeckoProfilerController() + controller.start() + + assert controller.profiling_active + assert controller.package_id == "org.mozilla.fenix" + assert controller.config_filename == "org.mozilla.fenix-geckoview-config.yaml" + + push_call = None + set_debug_call = None + for call_args in mock_run.call_args_list: + args = call_args[0][0] + if args[0] == "adb" and args[1] == "push": + push_call = args + elif args[0] == "adb" and "set-debug-app" in args: + set_debug_call = args + + assert push_call is not None + assert set_debug_call is not None + assert "org.mozilla.fenix" in set_debug_call + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +@mock.patch("tempfile.NamedTemporaryFile") +def test_controller_start_with_custom_options(mock_temp, mock_run): + """Test starting the controller with custom options generates a YAML config file with those values""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + custom_opts = { + "interval": 10, + "features": "js,stackwalk", + "filters": "GeckoMain", + } + + mock_file = MagicMock() + mock_file.name = "/tmp/test_config" + mock_temp.return_value.__enter__.return_value = mock_file + + controller = GeckoProfilerController() + controller.start(custom_opts) + + written_content = b"" + for call_args in mock_file.write.call_args_list: + written_content += call_args[0][0] + + config_str = written_content.decode() + assert "MOZ_PROFILER_STARTUP_INTERVAL: 10" in config_str + assert "MOZ_PROFILER_STARTUP_FEATURES: js,stackwalk" in config_str + assert "MOZ_PROFILER_STARTUP_FILTERS: GeckoMain" in config_str + assert controller.profiling_active + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_controller_start_already_active(): + """Test attempting to start profiling when it's already active raises an error""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + controller = GeckoProfilerController() + controller.profiling_active = True + + with pytest.raises(GeckoProfilerAlreadyActiveError) as excinfo: + controller.start() + + assert "already active" in str(excinfo.value) + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_controller_stop_not_active(): + """Test attempting to stop the profiler with no active session raises an error""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + + controller = GeckoProfilerController() + controller.profiling_active = False + + output_dir = Path("/tmp/output") + + with pytest.raises(GeckoProfilerNotActiveError) as excinfo: + controller.stop(str(output_dir), 1) + + assert "No active profiling session" in str(excinfo.value) + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +def test_controller_package_id_not_set(): + """Test that trying to resolve a package ID when it is not set raises an erorr""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController._package_id = None + + controller = GeckoProfilerController() + + with pytest.raises(GeckoProfilerError) as excinfo: + controller._resolve_package_id() + + assert "Package id not set" in str(excinfo.value) + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.extract_tgz_and_find_files") +def test_geckoprofiler_archive_profiles(mock_extract, tmp_path): + """Test that archived profiles are extracted from .tgz and packaged into a .zip file.""" + mach_cmd, metadata, env = running_env( + app="fenix", tests=[str(EXAMPLE_SHELL_TEST)], output=str(tmp_path) + ) + + profiler = GeckoProfiler(env, mach_cmd) + profiler.output_dir = tmp_path + profiler.test_name = "test_gecko" + + work_dir = tmp_path / "work" + work_dir.mkdir() + + # Profiles are now streamed as .json directly from device (not compressed) + profile = work_dir / "profile-0.json" + with open(profile, "w") as f: + f.write('{"meta": {"profile_type": "gecko"}}') + + tgz_file = tmp_path / "test_gecko.tgz" + with tarfile.open(tgz_file, "w:gz") as tar: + tar.add(profile, arcname=profile.name) + + mock_extract.return_value = ([profile], work_dir) + + profiler._archive_profiles() + + archive_file = tmp_path / "profile_test_gecko.zip" + assert archive_file.exists() + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +@mock.patch("tempfile.NamedTemporaryFile") +def test_controller_config_file_naming(mock_temp, mock_run): + """Test that the YAML filename follows the naming rule "{package_id}-geckoview-config.yaml".""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("com.example.app") + mock_temp.return_value.__enter__.return_value.name = "/tmp/config" + + controller = GeckoProfilerController() + controller.start() + + expected_config_name = "com.example.app-geckoview-config.yaml" + assert controller.config_filename == expected_config_name + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +def test_controller_stop_success(mock_run, tmp_path): + """Test that the controller stop successfully stops profiling and saves profile""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + controller = GeckoProfilerController() + controller.profiling_active = True + controller.package_id = "org.mozilla.firefox" + + output_dir = tmp_path + index = 1 + profile_path = output_dir / f"profile-{index}.json" + + # Mock subprocess to write valid profile data + profile_json_data = b'{"meta": {"version": 1}, "threads": []}' + mock_run.side_effect = partial(mock_subprocess_run, profile_json_data, 0) + + result = controller.stop(str(output_dir), index) + + assert controller.profiling_active is False + assert result == profile_path + assert profile_path.exists() + assert profile_path.read_bytes() == profile_json_data + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +def test_controller_stop_error(mock_run, tmp_path): + """Test that GeckoProfilerController.stop() handles errors and resets state""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + controller = GeckoProfilerController() + controller.profiling_active = True + controller.package_id = "org.mozilla.firefox" + + output_dir = tmp_path + index = 1 + + # Mock subprocess to return error + mock_run.side_effect = partial(mock_subprocess_run, b"", 1) + + # Call stop() - should handle error gracefully + result = controller.stop(str(output_dir), index) + + # Verify profiling_active is reset even on error + assert controller.profiling_active is False + # Verify None is returned on error + assert result is None + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +def test_controller_stop_gzipped_profile(mock_run, tmp_path): + """Test that GeckoProfilerController.stop() decompresses gzipped profiles""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + controller = GeckoProfilerController() + controller.profiling_active = True + controller.package_id = "org.mozilla.firefox" + + output_dir = tmp_path + index = 1 + profile_path = output_dir / f"profile-{index}.json" + + profile_json_data = b'{"meta": {"version": 1}, "threads": []}' + compressed_data = gzip.compress(profile_json_data) + + mock_run.side_effect = partial(mock_subprocess_run, compressed_data, 0) + result = controller.stop(str(output_dir), index) + + assert controller.profiling_active is False + assert result == profile_path + # Verify file was successfully decompressed + assert profile_path.read_bytes() == profile_json_data # Should be decompressed + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +@mock.patch("mozperftest.system.geckoprofiler.ADBDevice", new=FakeDevice) +@mock.patch("mozperftest.system.geckoprofiler.subprocess.run") +@mock.patch("tempfile.NamedTemporaryFile") +def test_controller_merges_default_and_custom_options(mock_temp, mock_run): + """Test that when partial custom options are given, they are merged with the defaults one""" + os.environ["MOZPERFTEST_GECKOPROFILE"] = "1" + GeckoProfilerController.set_package_id("org.mozilla.firefox") + + partial_opts = {"interval": 15} + + mock_file = MagicMock() + mock_file.name = "/tmp/test_config" + mock_temp.return_value.__enter__.return_value = mock_file + + controller = GeckoProfilerController() + controller.start(partial_opts) + + written_content = b"" + for call_args in mock_file.write.call_args_list: + written_content += call_args[0][0] + + config_str = written_content.decode() + assert "MOZ_PROFILER_STARTUP_INTERVAL: 15" in config_str + assert ( + f"MOZ_PROFILER_STARTUP_FEATURES: {DEFAULT_GECKOPROFILER_OPTS['features']}" + in config_str + ) + assert ( + f"MOZ_PROFILER_STARTUP_FILTERS: {DEFAULT_GECKOPROFILER_OPTS['filters']}" + in config_str + ) + + os.environ.pop("MOZPERFTEST_GECKOPROFILE", None) + + +if __name__ == "__main__": + mozunit.main() diff --git a/python/mozperftest/mozperftest/tests/test_simpleperf.py b/python/mozperftest/mozperftest/tests/test_simpleperf.py @@ -178,7 +178,7 @@ def test_simpleperf_setup_without_path(mock_exists): # Verify simpleperf path was set correctly. expected_path = mock_ndk / "simpleperf" - assert profiler.get_arg("path") == str(expected_path) + assert profiler.get_arg("path") == expected_path # Verify binary was installed. mock_exists.assert_called_once_with( @@ -441,10 +441,10 @@ def test_local_simpleperf_symbolicate(tmp_path): # Mock files (mock_perf_data_path := output_dir / "mock_perf-0.data").write_text("mock-data") - (mock_work_dir_path / "profile-0-unsymbolicated.json").write_text( + (output_dir / "profile-0-unsymbolicated.json").write_text( "mock-unsymbolicated-profile" ) - (mock_work_dir_path / "profile-0.json").write_text("mock-symbolicated-profile") + (output_dir / "profile-0.json").write_text("mock-symbolicated-profile") # Mock args profiler.set_arg("symbol-path", symbol_dir) @@ -478,7 +478,7 @@ def test_local_simpleperf_symbolicate(tmp_path): profiler.teardown() # Verify the temporary work directory is deleted - mock_rmtree.assert_called_once_with(mock_work_dir_path) + mock_rmtree.assert_not_called() # Expected process calls expected_import = call( @@ -488,7 +488,7 @@ def test_local_simpleperf_symbolicate(tmp_path): str(mock_perf_data_path), "--save-only", "-o", - str(mock_work_dir_path / "profile-0-unsymbolicated.json"), + str(output_dir / "profile-0-unsymbolicated.json"), ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -500,7 +500,7 @@ def test_local_simpleperf_symbolicate(tmp_path): [ "samply", "load", - str(mock_work_dir_path / "profile-0-unsymbolicated.json"), + str(output_dir / "profile-0-unsymbolicated.json"), "--no-open", "--breakpad-symbol-dir", str(symbol_dir), @@ -517,9 +517,9 @@ def test_local_simpleperf_symbolicate(tmp_path): str(node_path), str(symbolicator_dir / "symbolicator-cli.js"), "--input", - str(mock_work_dir_path / "profile-0-unsymbolicated.json"), + str(output_dir / "profile-0-unsymbolicated.json"), "--output", - str(mock_work_dir_path / "profile-0.json"), + str(output_dir / "profile-0.json"), "--server", "http://127.0.0.1:3000", ], @@ -600,9 +600,9 @@ def test_local_simpleperf_symbolicate_timeout(tmp_path): str(node_path), str(symbolicator_dir / "symbolicator-cli.js"), "--input", - str(mock_work_dir_path / "profile-0-unsymbolicated.json"), + str(output_dir / "profile-0-unsymbolicated.json"), "--output", - str(mock_work_dir_path / "profile-0.json"), + str(output_dir / "profile-0.json"), "--server", "http://127.0.0.1:3000", ], @@ -617,7 +617,7 @@ def test_local_simpleperf_symbolicate_timeout(tmp_path): assert expected_symbolicator not in mock_popen.call_args_list # Check for clean exit - mock_rmtree.assert_called_once() + mock_rmtree.assert_not_called() mock_cleanup.assert_called_once() @@ -681,6 +681,8 @@ def test_ci_simpleperf_symbolicate(tmp_path): }, clear=False, ), mock.patch("mozperftest.system.simpleperf.ON_TRY", True), mock.patch( + "mozperftest.utils.ON_TRY", True + ), mock.patch( "tempfile.mkdtemp", return_value=str(mock_work_dir_path) ), mock.patch( "shutil.rmtree" @@ -706,7 +708,7 @@ def test_ci_simpleperf_symbolicate(tmp_path): profiler.teardown() # Verify the temporary work directory is deleted - mock_rmtree.assert_called_once_with(mock_work_dir_path) + mock_rmtree.assert_called_once() # Verify proper .zip extraction mock_symbol_path = ( @@ -828,6 +830,8 @@ def test_ci_simpleperf_symbolicate_timeout(tmp_path): # Test timeout error in CI with mock.patch("mozperftest.system.simpleperf.ON_TRY", True), mock.patch( + "mozperftest.utils.ON_TRY", True + ), mock.patch( "tempfile.mkdtemp", return_value=str(mock_work_dir_path) ), mock.patch.dict( os.environ, diff --git a/python/mozperftest/mozperftest/utils.py b/python/mozperftest/mozperftest/utils.py @@ -15,6 +15,7 @@ import subprocess import sys import tarfile import tempfile +import zipfile from collections import defaultdict from datetime import date, datetime, timedelta from io import StringIO @@ -663,3 +664,66 @@ def archive_folder(folder_to_archive, output_path, archive_name=None): tar.add(folder_to_archive, arcname=archive_name) return full_archive_path + + +def archive_files(files, output_dir, archive_name, prefix=""): + """Archives individual files into a zip file, with optional append mode. + + Args: + files: List of Path objects to archive + output_dir: Path object - directory where the archive should be created + archive_name: Name for the archive + prefix: Optional prefix for archived filenames + + Returns: + Path to the archive if created/updated, None otherwise + """ + output_dir.mkdir(parents=True, exist_ok=True) + archive_path = output_dir / f"{archive_name}.zip" + + mode = "a" if archive_path.exists() else "w" + + with zipfile.ZipFile(archive_path, mode, compression=zipfile.ZIP_DEFLATED) as zf: + for file_path in files: + base_name = file_path.name + + if prefix: + archive_name = f"{prefix}-{base_name}" + else: + archive_name = base_name + + print(f"Adding {archive_name} to archive") + zf.write(file_path, arcname=archive_name) + + return archive_path + + +def extract_tgz_and_find_files(output_dir, tgz_name, patterns): + """Extract TGZ file if on CI and find files matching patterns. + + Args: + output_dir: Path object - directory where files are located or where TGZ should be extracted + tgz_name: Name of the TGZ file (without extension) + patterns: List of patterns for file extensions (e.g., ["*.data", "*.json.gz"]) + + Returns: + Tuple of (files, work_dir) where work_dir is the temp directory to clean up (or None) + """ + work_dir = None + search_dir = output_dir + + if ON_TRY: + tgz_path = output_dir / f"{tgz_name}.tgz" + if tgz_path.exists(): + work_dir = Path(tempfile.mkdtemp()) + with tarfile.open(tgz_path, "r:gz") as tar: + tar.extractall(path=work_dir) + search_dir = work_dir + + found_files = [] + for pattern in patterns: + found_files.extend(search_dir.rglob(pattern)) + + valid_files = [f for f in found_files if f.is_file()] + + return (valid_files, work_dir) diff --git a/taskcluster/gecko_taskgraph/target_tasks.py b/taskcluster/gecko_taskgraph/target_tasks.py @@ -1574,7 +1574,7 @@ def target_tasks_perftest_fenix_startup(full_task_graph, parameters, graph_confi for name, task in full_task_graph.tasks.items(): if task.kind != "perftest": continue - if "fenix" in name and "startup" in name and "simpleperf" not in name: + if "fenix" in name and "startup" in name and "profiling" not in name: yield name diff --git a/taskcluster/gecko_taskgraph/transforms/perftest.py b/taskcluster/gecko_taskgraph/transforms/perftest.py @@ -277,10 +277,10 @@ def create_duplicate_simpleperf_jobs(config, jobs): new_job["dependencies"] = { "android-aarch64-shippable": "build-android-aarch64-shippable/opt" } - new_job["name"] += "-simpleperf" + new_job["name"] += "-profiling" new_job["run"][ "command" - ] += " --simpleperf --simpleperf-path $MOZ_FETCHES_DIR/android-simpleperf" + ] += " --simpleperf --simpleperf-path $MOZ_FETCHES_DIR/android-simpleperf --geckoprofiler" new_job["description"] = str(new_job["description"]).replace( "Run", "Profile" ) diff --git a/tools/tryselect/selectors/perfselector/classification.py b/tools/tryselect/selectors/perfselector/classification.py @@ -637,7 +637,7 @@ class ClassificationProvider: }, "Startup": { "query": { - Suites.PERFTEST.value: ["'startup !-test- !simple"], + Suites.PERFTEST.value: ["'startup !-test- !profiling"], Suites.TALOS.value: ["'sessionrestore | 'other !damp"], }, "suites": [Suites.PERFTEST.value, Suites.TALOS.value],