commit b713574b28a308c41b9cf9607bafa25081bf95e4
parent 4dc5bc79f2ae965a9ec9b6330bcf644c3063d34d
Author: Marc Leclair <mleclair@mozilla.com>
Date: Tue, 9 Dec 2025 21:46:41 +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:
10 files changed, 871 insertions(+), 66 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):
@@ -159,7 +158,7 @@ class SimpleperfProfiler(Layer):
}
def __init__(self, env, mach_cmd):
- super(SimpleperfProfiler, self).__init__(env, mach_cmd)
+ super().__init__(env, mach_cmd)
self.device = ADBDevice()
@staticmethod
@@ -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,485 @@
+#!/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_empty_file(mock_run, tmp_path):
+ """Test that GeckoProfilerController.stop() handles empty profile data"""
+ 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 write empty file
+ mock_run.side_effect = partial(mock_subprocess_run, b"", 0)
+
+ # Call stop() - should detect empty file and return None
+ result = controller.stop(str(output_dir), index)
+
+ assert controller.profiling_active is False
+ 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_error_response(mock_run, tmp_path):
+ """Test that GeckoProfilerController.stop() handles ERROR responses from content provider"""
+ 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 write ERROR response
+ error_data = b"ERROR=Profiler not running"
+ mock_run.side_effect = partial(mock_subprocess_run, error_data, 0)
+
+ # ERROR in the output file should return None
+ result = controller.stop(str(output_dir), index)
+
+ assert controller.profiling_active is False
+ 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],