commit e2e6e5a53d80ac144f4b5e64c5c5a369a941dcce
parent b2acc8ba3cd02c89d9706eafe804d4841aa3c585
Author: Serban Stanca <sstanca@mozilla.com>
Date: Wed, 10 Dec 2025 01:56:49 +0200
Revert "Bug 1983904: Geckoprofile can now be captured in CI r=sparky,mozperftest-reviewers,mstange,perftest-reviewers" for causing python-mozperftest failures.
This reverts commit b713574b28a308c41b9cf9607bafa25081bf95e4.
Diffstat:
10 files changed, 66 insertions(+), 871 deletions(-)
diff --git a/python/mozperftest/mozperftest/profiler.py b/python/mozperftest/mozperftest/profiler.py
@@ -2,10 +2,9 @@
# 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, GeckoProfiler]
+PROFILERS = {SimpleperfProfiler}
class ProfilingMediator:
@@ -13,6 +12,7 @@ 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,7 +4,6 @@
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
@@ -21,7 +20,6 @@ def get_layers():
AndroidDevice,
MacosDevice,
SimpleperfProfiler,
- GeckoProfiler,
)
@@ -88,7 +86,6 @@ 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
@@ -1,259 +0,0 @@
-# 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,6 +6,8 @@ import os
import shutil
import signal
import subprocess
+import tarfile
+import tempfile
import time
import zipfile
from pathlib import Path
@@ -15,7 +17,7 @@ from mozbuild.nodeutil import find_node_executable
from mozdevice import ADBDevice
from mozperftest.layers import Layer
-from mozperftest.utils import ON_TRY, archive_files, extract_tgz_and_find_files
+from mozperftest.utils import ON_TRY
"""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.
@@ -129,11 +131,10 @@ class SimpleperfController:
raise SimpleperfExecutionError("failed to run simpleperf")
self.profiler_process = None
- profile_path = Path(output_path, f"perf-{index}.data")
+ output_path = str(Path(output_path, f"perf-{index}.data"))
# Pull profiler data directly to the given output path.
- self.device.pull("/data/local/tmp/perf.data", str(profile_path))
+ self.device.pull("/data/local/tmp/perf.data", output_path)
self.device.shell("rm -f /data/local/tmp/perf.data")
- return Path(output_path, f"perf-{index}.data")
class SimpleperfProfiler(Layer):
@@ -158,7 +159,7 @@ class SimpleperfProfiler(Layer):
}
def __init__(self, env, mach_cmd):
- super().__init__(env, mach_cmd)
+ super(SimpleperfProfiler, self).__init__(env, mach_cmd)
self.device = ADBDevice()
@staticmethod
@@ -194,7 +195,7 @@ class SimpleperfProfiler(Layer):
android.ensure_android_ndk(os_name)
- self.set_arg("path", Path(android.NDK_PATH, "simpleperf"))
+ self.set_arg("path", str(Path(android.NDK_PATH, "simpleperf")))
# Make sure the arm64 binary exists in the NDK path.
binary_path = Path(
@@ -267,14 +268,15 @@ class SimpleperfProfiler(Layer):
:return bool: Returns True if preparation is successful, False otherwise.
"""
- output = self.get_arg("output")
- self.output_dir = Path(output) if output else None
+ self.output_dir = self.get_arg("output")
+ self.work_dir = Path(tempfile.mkdtemp())
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
@@ -291,12 +293,35 @@ class SimpleperfProfiler(Layer):
)
)
- def _convert_perf_to_json(self, perf_data, work_dir):
+ 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):
"""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
"""
@@ -306,10 +331,7 @@ class SimpleperfProfiler(Layer):
for file_path in perf_data:
filename = file_path.stem
number = filename.split("-")[-1]
- output_path = Path(
- work_dir if work_dir else self.output_dir,
- f"profile-{number}-unsymbolicated.json",
- )
+ output_path = Path(self.work_dir, f"profile-{number}-unsymbolicated.json")
# Run samply import as a blocking command to ensure perf.data
# is processed to profile.json before proceeding
@@ -337,14 +359,13 @@ class SimpleperfProfiler(Layer):
return unsymbolicated_profiles
- def _symbolicate_profiles(self, unsymbolicated_profiles, work_dir):
+ def _symbolicate_profiles(self, unsymbolicated_profiles):
"""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.
@@ -390,9 +411,7 @@ 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(
- work_dir if work_dir else self.output_dir, f"{filename}.json"
- )
+ output_profile_path = Path(self.work_dir, f"{filename}.json")
with subprocess.Popen(
[
str(self.node_path),
@@ -422,7 +441,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.
@@ -430,12 +449,10 @@ class SimpleperfProfiler(Layer):
# Archive and export symbolicated profiles
symbolicated_profiles.sort()
- archive_files(
- symbolicated_profiles,
- self.output_dir,
- f"profile_{self.test_name}",
- prefix="simpleperf",
- )
+ 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)
def _symbolicate(self):
"""Convert perf data to symbolicated profiles.
@@ -447,25 +464,18 @@ 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, work_dir = extract_tgz_and_find_files(
- self.output_dir, self.test_name, ["*.data"]
- )
-
- # For local runs, work_dir will be None,
+ perf_data = self._get_perf_data()
self.info("Converting perf.data files to profile.json files")
- unsymbolicated_profiles = self._convert_perf_to_json(perf_data, work_dir)
+ unsymbolicated_profiles = self._convert_perf_to_json(perf_data)
self.info("Symbolicating profile.json files")
- symbolicated_profiles = self._symbolicate_profiles(
- unsymbolicated_profiles, work_dir
- )
+ symbolicated_profiles = self._symbolicate_profiles(unsymbolicated_profiles)
self.info("Archiving symbolicated profile.json files")
self._archive_profiles(symbolicated_profiles)
@@ -481,8 +491,8 @@ class SimpleperfProfiler(Layer):
)
finally:
- if work_dir:
- shutil.rmtree(work_dir) # Ensure cleanup
+ if self.work_dir.exists():
+ shutil.rmtree(self.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
@@ -1,485 +0,0 @@
-#!/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") == expected_path
+ assert profiler.get_arg("path") == str(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")
- (output_dir / "profile-0-unsymbolicated.json").write_text(
+ (mock_work_dir_path / "profile-0-unsymbolicated.json").write_text(
"mock-unsymbolicated-profile"
)
- (output_dir / "profile-0.json").write_text("mock-symbolicated-profile")
+ (mock_work_dir_path / "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_not_called()
+ mock_rmtree.assert_called_once_with(mock_work_dir_path)
# 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(output_dir / "profile-0-unsymbolicated.json"),
+ str(mock_work_dir_path / "profile-0-unsymbolicated.json"),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -500,7 +500,7 @@ def test_local_simpleperf_symbolicate(tmp_path):
[
"samply",
"load",
- str(output_dir / "profile-0-unsymbolicated.json"),
+ str(mock_work_dir_path / "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(output_dir / "profile-0-unsymbolicated.json"),
+ str(mock_work_dir_path / "profile-0-unsymbolicated.json"),
"--output",
- str(output_dir / "profile-0.json"),
+ str(mock_work_dir_path / "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(output_dir / "profile-0-unsymbolicated.json"),
+ str(mock_work_dir_path / "profile-0-unsymbolicated.json"),
"--output",
- str(output_dir / "profile-0.json"),
+ str(mock_work_dir_path / "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_not_called()
+ mock_rmtree.assert_called_once()
mock_cleanup.assert_called_once()
@@ -681,8 +681,6 @@ 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"
@@ -708,7 +706,7 @@ def test_ci_simpleperf_symbolicate(tmp_path):
profiler.teardown()
# Verify the temporary work directory is deleted
- mock_rmtree.assert_called_once()
+ mock_rmtree.assert_called_once_with(mock_work_dir_path)
# Verify proper .zip extraction
mock_symbol_path = (
@@ -830,8 +828,6 @@ 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,7 +15,6 @@ import subprocess
import sys
import tarfile
import tempfile
-import zipfile
from collections import defaultdict
from datetime import date, datetime, timedelta
from io import StringIO
@@ -664,66 +663,3 @@ 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 "profiling" not in name:
+ if "fenix" in name and "startup" in name and "simpleperf" 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"] += "-profiling"
+ new_job["name"] += "-simpleperf"
new_job["run"][
"command"
- ] += " --simpleperf --simpleperf-path $MOZ_FETCHES_DIR/android-simpleperf --geckoprofiler"
+ ] += " --simpleperf --simpleperf-path $MOZ_FETCHES_DIR/android-simpleperf"
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- !profiling"],
+ Suites.PERFTEST.value: ["'startup !-test- !simple"],
Suites.TALOS.value: ["'sessionrestore | 'other !damp"],
},
"suites": [Suites.PERFTEST.value, Suites.TALOS.value],