blob: e59746ca576165b6fa3dfd38b63ab32090f8face [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2012 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Snapshot Build Bisect Tool
This script bisects a snapshot archive using binary search. It starts at
a bad revision (it will try to guess HEAD) and asks for a last known-good
revision. It will then binary search across this revision range by downloading,
unzipping, and opening Chromium for you. After testing the specific revision,
it will ask you whether it is good or bad before continuing the search.
Docs: https://www.chromium.org/developers/bisect-builds-py/
Googlers: go/chrome-bisect
"""
import abc
import argparse
import base64
import copy
import functools
import glob
import importlib
import json
import os
import platform
import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
import threading
import traceback
import urllib.parse
import urllib.request
from xml.etree import ElementTree
import zipfile
# These constants are used for android bisect which depends on
# Catapult repo.
DEFAULT_CATAPULT_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), 'catapult_bisect_dep'))
CATAPULT_DIR = os.environ.get('CATAPULT_DIR', DEFAULT_CATAPULT_DIR)
CATAPULT_REPO = 'https://github.com/catapult-project/catapult.git'
DEVIL_PATH = os.path.abspath(os.path.join(CATAPULT_DIR, 'devil'))
# The base URL for stored build archives.
CHROMIUM_BASE_URL = ('http://commondatastorage.googleapis.com'
'/chromium-browser-snapshots')
ASAN_BASE_URL = ('http://commondatastorage.googleapis.com'
'/chromium-browser-asan')
CHROME_FOR_TESTING_BASE_URL = ('https://storage.googleapis.com/'
'chrome-for-testing-per-commit-public')
GSUTILS_PATH = None
# GS bucket name for perf builds
PERF_BASE_URL = 'gs://chrome-test-builds/official-by-commit'
# GS bucket name.
RELEASE_BASE_URL = 'gs://chrome-unsigned/desktop-5c0tCh'
# Android bucket starting at M45.
ANDROID_RELEASE_BASE_URL = 'gs://chrome-unsigned/android-B0urB0N'
ANDROID_RELEASE_BASE_URL_SIGNED = 'gs://chrome-signed/android-B0urB0N'
# A special bucket that need to be skipped.
ANDROID_INVALID_BUCKET = 'gs://chrome-signed/android-B0urB0N/Test'
# iOS bucket
IOS_RELEASE_BASE_URL = 'gs://chrome-unsigned/ios-G1N'
IOS_RELEASE_BASE_URL_SIGNED = 'gs://chrome-signed/ios-G1N'
IOS_ARCHIVE_BASE_URL = 'gs://bling-archive'
# Base URL for downloading release builds.
GOOGLE_APIS_URL = 'commondatastorage.googleapis.com'
# URL template for viewing changelogs between revisions.
SHORT_CHANGELOG_URL = 'https://crrev.com/%s..%s'
CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/src/+log/%s..%s')
# URL to convert SVN revision to git hash.
CRREV_URL = ('https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/')
# URL template for viewing changelogs between release versions.
RELEASE_CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/'
'src/+log/%s..%s?n=10000')
# show change logs during bisecting for last 5 steps
STEPS_TO_SHOW_CHANGELOG_URL = 5
# DEPS file URL.
DEPS_FILE_OLD = ('http://src.chromium.org/viewvc/chrome/trunk/src/'
'DEPS?revision=%d')
DEPS_FILE_NEW = ('https://chromium.googlesource.com/chromium/src/+/%s/DEPS')
# Source Tag
SOURCE_TAG_URL = ('https://chromium.googlesource.com/chromium/src/'
'+/refs/tags/%s?format=JSON')
DONE_MESSAGE_GOOD_MIN = ('You are probably looking for a change made after %s ('
'known good), but no later than %s (first known bad).')
DONE_MESSAGE_GOOD_MAX = ('You are probably looking for a change made after %s ('
'known bad), but no later than %s (first known good).')
VERSION_INFO_URL = ('https://chromiumdash.appspot.com/fetch_version?version=%s')
MILESTONES_URL = ('https://chromiumdash.appspot.com/fetch_milestones?mstone=%s')
CREDENTIAL_ERROR_MESSAGE = ('You are attempting to access protected data with '
'no configured credentials')
PATH_CONTEXT = {
'release': {
'android-arm': {
# Binary name is the Chrome binary filename. On Android, we don't
# use it to launch Chrome.
'binary_name': None,
'listing_platform_dir': 'arm/',
# Archive name is the zip file on gcs. For Android, we don't have
# such zip file. Instead we have a lot of apk files directly stored
# on gcs. The archive_name is used to find zip file for other
# platforms, but it will be apk filename defined by --apk for
# Android platform.
'archive_name': None,
'archive_extract_dir': 'android-arm'
},
'android-arm64': {
'binary_name': None,
'listing_platform_dir': 'arm_64/',
'archive_name': None,
'archive_extract_dir': 'android-arm64'
},
'android-arm64-high': {
'binary_name': None,
'listing_platform_dir': 'high-arm_64/',
'archive_name': None,
'archive_extract_dir': 'android-arm64'
},
'android-x86': {
'binary_name': None,
'listing_platform_dir': 'x86/',
'archive_name': None,
'archive_extract_dir': 'android-x86'
},
'android-x64': {
'binary_name': None,
'listing_platform_dir': 'x86_64/',
'archive_name': None,
'archive_extract_dir': 'android-x64'
},
'ios': {
'binary_name': None,
'listing_platform_dir': 'ios/',
'archive_name': None,
'archive_extract_dir': None,
},
'ios-simulator': {
'binary_name': 'Chromium.app',
'listing_platform_dir': '',
'archive_name': 'Chromium.tar.gz',
'archive_extract_dir': None,
},
'linux64': {
'binary_name': 'chrome',
'listing_platform_dir': 'linux64/',
'archive_name': 'chrome-linux64.zip',
'archive_extract_dir': 'chrome-linux64',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_linux64.zip',
},
'mac': {
'binary_name': 'Google Chrome.app/Contents/MacOS/Google Chrome',
'listing_platform_dir': 'mac/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
},
'mac64': {
'binary_name': 'Google Chrome.app/Contents/MacOS/Google Chrome',
'listing_platform_dir': 'mac64/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_mac64.zip',
},
'mac-arm': {
'binary_name': 'Google Chrome.app/Contents/MacOS/Google Chrome',
'listing_platform_dir': 'mac-arm64/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_mac64.zip',
},
'win': {
'binary_name': 'chrome.exe',
# Release builds switched to -clang in M64.
'listing_platform_dir': 'win-clang/',
'archive_name': 'chrome-win-clang.zip',
'archive_extract_dir': 'chrome-win-clang',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win32.zip',
},
'win64': {
'binary_name': 'chrome.exe',
# Release builds switched to -clang in M64.
'listing_platform_dir': 'win64-clang/',
'archive_name': 'chrome-win64-clang.zip',
'archive_extract_dir': 'chrome-win64-clang',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win64.zip',
},
'win-arm64': {
'binary_name': 'chrome.exe',
'listing_platform_dir': 'win-arm64-clang/',
'archive_name': 'chrome-win-arm64-clang.zip',
'archive_extract_dir': 'chrome-win-arm64-clang',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win64.zip',
},
},
'official': {
'android-arm': {
'binary_name': None,
'listing_platform_dir': 'android-builder-perf/',
'archive_name': 'full-build-linux.zip',
'archive_extract_dir': 'full-build-linux'
},
'android-arm64': {
'binary_name': None,
'listing_platform_dir': 'android_arm64-builder-perf/',
'archive_name': 'full-build-linux.zip',
'archive_extract_dir': 'full-build-linux'
},
'android-arm64-high': {
'binary_name': None,
'listing_platform_dir': 'android_arm64_high_end-builder-perf/',
'archive_name': 'full-build-linux.zip',
'archive_extract_dir': 'full-build-linux'
},
'linux64': {
'binary_name': 'chrome',
'listing_platform_dir': 'linux-builder-perf/',
'archive_name': 'chrome-perf-linux.zip',
'archive_extract_dir': 'full-build-linux',
'chromedriver_binary_name': 'chromedriver',
},
'mac': {
'binary_name': 'Google Chrome.app/Contents/MacOS/Google Chrome',
'listing_platform_dir': 'mac-builder-perf/',
'archive_name': 'chrome-perf-mac.zip',
'archive_extract_dir': 'full-build-mac',
'chromedriver_binary_name': 'chromedriver',
},
'mac-arm': {
'binary_name': 'Google Chrome.app/Contents/MacOS/Google Chrome',
'listing_platform_dir': 'mac-arm-builder-perf/',
'archive_name': 'chrome-perf-mac.zip',
'archive_extract_dir': 'full-build-mac',
'chromedriver_binary_name': 'chromedriver',
},
'win64': {
'binary_name': 'chrome.exe',
'listing_platform_dir': 'win64-builder-perf/',
'archive_name': 'chrome-perf-win.zip',
'archive_extract_dir': 'full-build-win32',
'chromedriver_binary_name': 'chromedriver.exe',
},
},
'snapshot': {
'android-arm': {
'binary_name': None,
'listing_platform_dir': 'Android/',
'archive_name': 'chrome-android.zip',
'archive_extract_dir': 'chrome-android'
},
'android-arm64': {
'binary_name': None,
'listing_platform_dir': 'Android_Arm64/',
'archive_name': 'chrome-android.zip',
'archive_extract_dir': 'chrome-android'
},
'linux64': {
'binary_name': 'chrome',
'listing_platform_dir': 'Linux_x64/',
'archive_name': 'chrome-linux.zip',
'archive_extract_dir': 'chrome-linux',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_linux64.zip',
},
'linux-arm': {
'binary_name': 'chrome',
'listing_platform_dir': 'Linux_ARM_Cross-Compile/',
'archive_name': 'chrome-linux.zip',
'archive_extract_dir': 'chrome-linux'
},
'chromeos': {
'binary_name': 'chrome',
'listing_platform_dir': 'Linux_ChromiumOS_Full/',
'archive_name': 'chrome-chromeos.zip',
'archive_extract_dir': 'chrome-chromeos'
},
'mac': {
'binary_name': 'Chromium.app/Contents/MacOS/Chromium',
'listing_platform_dir': 'Mac/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_mac64.zip',
},
'mac64': {
'binary_name': 'Chromium.app/Contents/MacOS/Chromium',
'listing_platform_dir': 'Mac/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_mac64.zip',
},
'mac-arm': {
'binary_name': 'Chromium.app/Contents/MacOS/Chromium',
'listing_platform_dir': 'Mac_Arm/',
'archive_name': 'chrome-mac.zip',
'archive_extract_dir': 'chrome-mac',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver_mac64.zip',
},
'win': {
'binary_name': 'chrome.exe',
'listing_platform_dir': 'Win/',
'archive_name': 'chrome-win.zip',
'archive_extract_dir': 'chrome-win',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win32.zip',
},
'win64': {
'binary_name': 'chrome.exe',
'listing_platform_dir': 'Win_x64/',
'archive_name': 'chrome-win.zip',
'archive_extract_dir': 'chrome-win',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win32.zip',
},
'win-arm64': {
'binary_name': 'chrome.exe',
'listing_platform_dir': 'Win_Arm64/',
'archive_name': 'chrome-win.zip',
'archive_extract_dir': 'chrome-win',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver_win64.zip',
},
},
'asan': {
'linux': {},
'mac': {},
'win': {},
},
'cft': {
'linux64': {
'listing_platform_dir': 'linux64/',
'binary_name': 'chrome',
'archive_name': 'chrome-linux64.zip',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver-linux64.zip',
},
'mac-arm': {
'listing_platform_dir': 'mac-arm64/',
'binary_name': 'Google Chrome for Testing.app/Contents/MacOS'
'/Google Chrome for Testing',
'archive_name': 'chrome-mac-arm64.zip',
'chromedriver_binary_name': 'chromedriver',
'chromedriver_archive_name': 'chromedriver-mac-arm64.zip',
},
'win64': {
'listing_platform_dir': 'win64/',
'binary_name': 'chrome.exe',
'archive_name': 'chrome-win64.zip',
'chromedriver_binary_name': 'chromedriver.exe',
'chromedriver_archive_name': 'chromedriver-win64.zip',
},
},
}
CHROME_APK_FILENAMES = {
'chrome': 'Chrome.apk',
'chrome_beta': 'ChromeBeta.apk',
'chrome_canary': 'ChromeCanary.apk',
'chrome_dev': 'ChromeDev.apk',
'chrome_stable': 'ChromeStable.apk',
'chromium': 'ChromePublic.apk',
}
CHROME_MODERN_APK_FILENAMES = {
'chrome': 'ChromeModern.apk',
'chrome_beta': 'ChromeModernBeta.apk',
'chrome_canary': 'ChromeModernCanary.apk',
'chrome_dev': 'ChromeModernDev.apk',
'chrome_stable': 'ChromeModernStable.apk',
'chromium': 'ChromePublic.apk',
}
MONOCHROME_APK_FILENAMES = {
'chrome': 'Monochrome.apk',
'chrome_beta': 'MonochromeBeta.apk',
'chrome_canary': 'MonochromeCanary.apk',
'chrome_dev': 'MonochromeDev.apk',
'chrome_stable': 'MonochromeStable.apk',
'chromium': 'ChromePublic.apk',
}
TRICHROME_APK_FILENAMES = {
'chrome': 'TrichromeChromeGoogle.apks',
'chrome_beta': 'TrichromeChromeGoogleBeta.apks',
'chrome_canary': 'TrichromeChromeGoogleCanary.apks',
'chrome_dev': 'TrichromeChromeGoogleDev.apks',
'chrome_stable': 'TrichromeChromeGoogleStable.apks',
}
TRICHROME64_APK_FILENAMES = {
'chrome': 'TrichromeChromeGoogle6432.apks',
'chrome_beta': 'TrichromeChromeGoogle6432Beta.apks',
'chrome_canary': 'TrichromeChromeGoogle6432Canary.apks',
'chrome_dev': 'TrichromeChromeGoogle6432Dev.apks',
'chrome_stable': 'TrichromeChromeGoogle6432Stable.apks',
}
TRICHROME_LIBRARY_FILENAMES = {
'chrome': 'TrichromeLibraryGoogle.apk',
'chrome_beta': 'TrichromeLibraryGoogleBeta.apk',
'chrome_canary': 'TrichromeLibraryGoogleCanary.apk',
'chrome_dev': 'TrichromeLibraryGoogleDev.apk',
'chrome_stable': 'TrichromeLibraryGoogleStable.apk',
}
TRICHROME64_LIBRARY_FILENAMES = {
'chrome': 'TrichromeLibraryGoogle6432.apk',
'chrome_beta': 'TrichromeLibraryGoogle6432Beta.apk',
'chrome_canary': 'TrichromeLibraryGoogle6432Canary.apk',
'chrome_dev': 'TrichromeLibraryGoogle6432Dev.apk',
'chrome_stable': 'TrichromeLibraryGoogle6432Stable.apk',
}
WEBVIEW_APK_FILENAMES = {
# clank release
'android_webview': 'AndroidWebview.apk',
# clank official
'system_webview_google': 'SystemWebViewGoogle.apk',
# upstream
'system_webview': 'SystemWebView.apk',
}
# Old storage locations for per CL builds
OFFICIAL_BACKUP_BUILDS = {
'android-arm': {
'listing_platform_dir': ['Android Builder/'],
},
'linux64': {
'listing_platform_dir': ['Linux Builder Perf/'],
},
'mac': {
'listing_platform_dir': ['Mac Builder Perf/'],
},
'win64': {
'listing_platform_dir': ['Win x64 Builder Perf/'],
}
}
PLATFORM_ARCH_TO_ARCHIVE_MAPPING = {
('linux', 'x64'): 'linux64',
('mac', 'x64'): 'mac64',
('mac', 'x86'): 'mac',
('mac', 'arm'): 'mac-arm',
('win', 'x64'): 'win64',
('win', 'x86'): 'win',
('win', 'arm'): 'win-arm64',
}
# Set only during initialization.
is_verbose = False
class BisectException(Exception):
def __str__(self):
return '[Bisect Exception]: %s\n' % self.args[0]
def RunGsutilCommand(args, can_fail=False, ignore_fail=False):
if not GSUTILS_PATH:
raise BisectException('gsutils is not found in path.')
if is_verbose:
print('Running gsutil command: ' +
str([sys.executable, GSUTILS_PATH] + args))
gsutil = subprocess.Popen([sys.executable, GSUTILS_PATH] + args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=None)
stdout_b, stderr_b = gsutil.communicate()
stdout = stdout_b.decode("utf-8")
stderr = stderr_b.decode("utf-8")
if gsutil.returncode:
if (re.findall(r'(status|ServiceException:)[ |=]40[1|3]', stderr)
or stderr.startswith(CREDENTIAL_ERROR_MESSAGE)):
print(('Follow these steps to configure your credentials and try'
' running the bisect-builds.py again.:\n'
' 1. Run "python3 %s config" and follow its instructions.\n'
' 2. If you have a @google.com account, use that account.\n'
' 3. For the project-id, just enter 0.' % GSUTILS_PATH))
print('Warning: You might have an outdated .boto file. If this issue '
'persists after running `gsutil.py config`, try removing your '
'.boto, usually located in your home directory.')
raise BisectException('gsutil credential error')
elif can_fail:
return stderr
elif ignore_fail:
return stdout
else:
raise Exception('Error running the gsutil command:\n%s\n%s' %
(args, stderr))
return stdout
def GsutilList(*urls, ignore_fail=False):
"""List GCloud Storage with URLs and return a list of paths.
This method lists all archive builds in a GCS bucket; it filters out invalid
archive builds or files.
Arguments:
* urls - one or more gs:// URLs
* ignore_fail - ignore gsutil command errors, e.g., 'matched no objects'
Return:
* list of paths that match the given URLs
"""
# Get a directory listing with file sizes. Typical output looks like:
# 7 2023-11-27T21:08:36Z gs://.../LAST_CHANGE
# 144486938 2023-03-07T14:41:25Z gs://.../full-build-win32_1113893.zip
# TOTAL: 114167 objects, 15913845813421 bytes (14.47 TiB)
# This lets us ignore empty .zip files that will otherwise cause errors.
stdout = RunGsutilCommand(['ls', '-l', *urls], ignore_fail=ignore_fail)
# Trim off the summary line that only happens with -l
lines = []
for line in stdout.splitlines():
parts = line.split(maxsplit=2)
if not parts[-1].startswith('gs://'):
continue
# Check whether there is a size field. For release builds the listing
# will be directories so there will be no size field.
if len(parts) > 1:
if ANDROID_INVALID_BUCKET in line:
continue
size = int(parts[0])
# Empty .zip files are 22 bytes. Ignore anything less than 1,000 bytes,
# but keep the LAST_CHANGE file since the code seems to expect that.
if parts[-1].endswith('LAST_CHANGE') or size > 1000:
lines.append(parts[-1])
else:
lines.append(parts[-1])
return lines
def join_args(args: list) -> str:
"""Join the args into a single command line."""
if sys.platform.startswith('win'):
# subprocess.list2cmdline is an API for subprocess internal use. However to
# reduce the external dependency, we use it for Windows to quote the args.
return subprocess.list2cmdline(args)
else:
return shlex.join(args)
def quote_arg(arg: str) -> str:
"""Quote the arg for the shell."""
return join_args([arg])
class ArchiveBuild(abc.ABC):
"""Base class for a archived build."""
def __init__(self, options):
self.platform = options.archive
self.good_revision = options.good
self.bad_revision = options.bad
self.use_local_cache = options.use_local_cache
self.chromedriver = options.chromedriver
# PATH_CONTEXT
path_context = PATH_CONTEXT[self.build_type].get(self.platform, {})
self.binary_name = path_context.get('binary_name')
self.listing_platform_dir = path_context.get('listing_platform_dir')
self.archive_name = path_context.get('archive_name')
self.archive_extract_dir = path_context.get('archive_extract_dir')
self.chromedriver_binary_name = path_context.get('chromedriver_binary_name')
self.chromedriver_archive_name = path_context.get(
'chromedriver_archive_name')
if self.chromedriver and not self.chromedriver_binary_name:
raise BisectException(
'Could not find chromedriver_binary_name, '
f'--chromedriver might not supported on {self.platform}.')
# run_revision options
self.profile = options.profile
self.command = options.command
self.num_runs = options.times
@property
@abc.abstractmethod
def build_type(self):
raise NotImplemented()
@abc.abstractmethod
def _get_rev_list(self, min_rev=None, max_rev=None):
"""The actual method to get revision list without cache.
min_rev and max_rev could be None, indicating that the method should return
all revisions.
The method should return at least the revision list that exists for the
given (min_rev, max_rev) range. However, it could return a revision list
with revisions beyond min_rev and max_rev based on the implementation for
better caching. The rev_list should contain all available revisions between
the returned minimum and maximum values.
The return value of revisions in the list should match the type of
good_revision and bad_revision, and should be comparable.
"""
raise NotImplemented()
@property
def _rev_list_cache_filename(self):
return os.path.join(os.path.abspath(os.path.dirname(__file__)),
'.bisect-builds-cache.json')
@property
@abc.abstractmethod
def _rev_list_cache_key(self):
"""Returns the cache key for archive build. The cache key should be able to
distinguish like build_type, platform."""
raise NotImplemented()
def _load_rev_list_cache(self):
if not self.use_local_cache:
return []
cache_filename = self._rev_list_cache_filename
try:
with open(cache_filename) as cache_file:
cache = json.load(cache_file)
revisions = cache.get(self._rev_list_cache_key, [])
if revisions:
print('Loaded revisions %s-%s from %s' %
(revisions[0], revisions[-1], cache_filename))
return revisions
except FileNotFoundError:
return []
except (EnvironmentError, ValueError) as e:
print('Load revisions cache error:', e)
return []
def _save_rev_list_cache(self, revisions):
if not self.use_local_cache:
return
if not revisions:
return
cache = {}
cache_filename = self._rev_list_cache_filename
# Load cache for all of the builds.
try:
with open(cache_filename) as cache_file:
cache = json.load(cache_file)
except FileNotFoundError:
pass
except (EnvironmentError, ValueError) as e:
print('Load existing revisions cache error:', e)
return
# Update and save cache for current build.
cache[self._rev_list_cache_key] = revisions
try:
with open(cache_filename, 'w') as cache_file:
json.dump(cache, cache_file)
print('Saved revisions %s-%s to %s' %
(revisions[0], revisions[-1], cache_filename))
except EnvironmentError as e:
print('Save revisions cache error:', e)
return
def get_rev_list(self):
"""Gets the list of revision numbers between self.good_revision and
self.bad_revision. The result might be cached when use_local_cache."""
# Download the rev_list_all
min_rev, max_rev = sorted((self.good_revision, self.bad_revision))
rev_list_all = self._load_rev_list_cache()
if not rev_list_all:
rev_list_all = sorted(self._get_rev_list(min_rev, max_rev))
self._save_rev_list_cache(rev_list_all)
else:
rev_list_min, rev_list_max = rev_list_all[0], rev_list_all[-1]
if min_rev < rev_list_min or max_rev > rev_list_max:
# We only need to request and merge the rev_list beyond the cache.
rev_list_requested = self._get_rev_list(
min_rev if min_rev < rev_list_min else rev_list_max,
max_rev if max_rev > rev_list_max else rev_list_min)
rev_list_all = sorted(set().union(rev_list_all, rev_list_requested))
self._save_rev_list_cache(rev_list_all)
# If we still don't get a rev_list_all for the given range, adjust the
# range to get the full revision list for better messaging.
if not rev_list_all:
rev_list_all = sorted(self._get_rev_list())
self._save_rev_list_cache(rev_list_all)
if not rev_list_all:
raise BisectException('Could not retrieve the revisions for %s.' %
self.platform)
# Filter for just the range between good and bad.
rev_list = [x for x in rev_list_all if min_rev <= x <= max_rev]
# Don't have enough builds to bisect.
if len(rev_list) < 2:
rev_list_min, rev_list_max = rev_list_all[0], rev_list_all[-1]
# Check for specifying a number before the available range.
if max_rev < rev_list_min:
msg = (
'First available bisect revision for %s is %d. Be sure to specify '
'revision numbers, not branch numbers.' %
(self.platform, rev_list_min))
raise BisectException(msg)
# Check for specifying a number beyond the available range.
if min_rev > rev_list_max:
# Check for the special case of linux where bisect builds stopped at
# revision 382086, around March 2016.
if self.platform == 'linux':
msg = ('Last available bisect revision for %s is %d. Try linux64 '
'instead.' % (self.platform, rev_list_max))
else:
msg = ('Last available bisect revision for %s is %d. Try a different '
'good/bad range.' % (self.platform, rev_list_max))
raise BisectException(msg)
# Otherwise give a generic message.
msg = 'We don\'t have enough builds to bisect. rev_list: %s' % rev_list
raise BisectException(msg)
# Set good and bad revisions to be legit revisions.
if rev_list:
if self.good_revision < self.bad_revision:
self.good_revision = rev_list[0]
self.bad_revision = rev_list[-1]
else:
self.bad_revision = rev_list[0]
self.good_revision = rev_list[-1]
return rev_list
@abc.abstractmethod
def get_download_url(self, revision):
"""Gets the download URL for the specific revision."""
raise NotImplemented()
def get_download_job(self, revision, name=None):
"""Gets as a DownloadJob that download the specific revision in threads."""
return DownloadJob(self.get_download_url(revision), revision, name)
def _get_extra_args(self):
"""Get extra chrome args"""
return ['--user-data-dir=%s' % self.profile]
def _get_extract_binary_glob(self, tempdir):
"""Get the pathname for extracted chrome binary"""
return '%s/*/%s' % (tempdir, self.binary_name)
def _get_chromedriver_binary_glob(self, tempdir):
"""Get the pathname for extracted chromedriver binary"""
if not self.chromedriver_binary_name:
raise BisectException(f"chromedriver is not supported on {self.platform}")
return '%s/*/%s' % (tempdir, self.chromedriver_binary_name)
def _run(self, runcommand, cwd=None, shell=False, print_when_error=True):
# is_verbos is a global variable.
if is_verbose:
print(('Running ' + str(runcommand)))
subproc = subprocess.Popen(runcommand,
cwd=cwd,
shell=shell,
bufsize=-1,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(stdout, stderr) = subproc.communicate()
if print_when_error and subproc.returncode:
print('command: ' + str(runcommand))
if is_verbose or (print_when_error and subproc.returncode):
print(f'retcode: {subproc.returncode}\nstdout:\n')
sys.stdout.buffer.write(stdout)
sys.stdout.flush()
print('stderr:\n')
sys.stderr.buffer.write(stderr)
sys.stderr.flush()
return subproc.returncode, stdout, stderr
@staticmethod
def _glob_with_unique_match(executable_name, tempdir, pathname):
executables = glob.glob(pathname)
if len(executables) == 0:
raise BisectException(
f'Can not find the {executable_name} binary from {tempdir}')
elif len(executables) > 1:
raise BisectException(
f'Multiple {executable_name} executables found: {executables}')
return os.path.abspath(executables[0])
def _install_revision(self, download, tempdir):
"""Unzip and/or install the given download to tempdir. Return executable
binaries in a dict."""
if isinstance(download, dict):
for each in download.values():
UnzipFilenameToDir(each, tempdir)
else:
UnzipFilenameToDir(download, tempdir)
# Searching for the executable, it's unlikely the zip file contains multiple
# folders with the binary_name.
result = {}
result['chrome'] = self._glob_with_unique_match(
'chrome', tempdir, self._get_extract_binary_glob(tempdir))
if self.chromedriver:
result['chromedriver'] = self._glob_with_unique_match(
'chromedriver', tempdir, self._get_chromedriver_binary_glob(tempdir))
return result
def _launch_revision(self, tempdir, executables, args=()):
args = [*self._get_extra_args(), *args]
args_str = join_args(args)
command = (self.command.replace(r'%p', quote_arg(
executables['chrome'])).replace(r'%s', args_str).replace(
r'%a', args_str).replace(r'%t', tempdir))
if self.chromedriver:
command = command.replace(r'%d', quote_arg(executables['chromedriver']))
return self._run(command, shell=True)
def run_revision(self, download, tempdir, args=()):
"""Run downloaded archive"""
executables = self._install_revision(download, tempdir)
result = None
for _ in range(self.num_runs):
returncode, _, _ = result = self._launch_revision(tempdir, executables,
args)
if returncode:
break
return result
@functools.total_ordering
class ChromiumVersion:
"""Chromium version numbers consist of 4 parts: MAJOR.MINOR.BUILD.PATCH.
This class is used to compare the version numbers.
"""
def __init__(self, vstring: str):
self.vstring = vstring
self.version = tuple(int(x) for x in vstring.split('.'))
def __str__ (self):
return self.vstring
def __repr__ (self):
return "ChromiumVersion ('%s')" % str(self)
def __lt__(self, other):
if isinstance(other, str):
other = ChromiumVersion(other)
return self.version < other.version
def __eq__(self, other):
if isinstance(other, str):
other = ChromiumVersion(other)
return self.version == other.version
def __hash__(self):
return hash(str(self))
class ReleaseBuild(ArchiveBuild):
def __init__(self, options):
super().__init__(options)
self.good_revision = ChromiumVersion(self.good_revision)
self.bad_revision = ChromiumVersion(self.bad_revision)
@property
def build_type(self):
return 'release'
def _get_release_bucket(self):
return RELEASE_BASE_URL
def _get_rev_list(self, min_rev=None, max_rev=None):
# Get all build numbers in the build bucket.
build_numbers = []
revision_re = re.compile(r'(\d+\.\d\.\d{4}\.\d+)')
for path in GsutilList(self._get_release_bucket()):
match = revision_re.search(path)
if match:
build_numbers.append(ChromiumVersion(match[1]))
# Filter the versions between min_rev and max_rev.
build_numbers = [
x for x in build_numbers
if (not min_rev or min_rev <= x) and (not max_rev or x <= max_rev)
]
# Check if target archive build exists in batches.
# batch size is limited by maximum length for the command line. Which is
# 32,768 characters on Windows, which should be enough up to 400 files.
batch_size = 100
final_list = []
for batch in (build_numbers[i:i + batch_size]
for i in range(0, len(build_numbers), batch_size)):
sys.stdout.write('\rFetching revisions at marker %s' % batch[0])
sys.stdout.flush()
# List the files that exists with listing_platform_dir and archive_name.
# Gsutil could fail because some of the path not exists. It's safe to
# ignore them.
for path in GsutilList(*[self._get_archive_path(x) for x in batch],
ignore_fail=True):
match = revision_re.search(path)
if match:
final_list.append(ChromiumVersion(match[1]))
sys.stdout.write('\r')
sys.stdout.flush()
return final_list
def _get_listing_url(self):
return self._get_release_bucket()
def _get_archive_path(self, build_number, archive_name=None):
if archive_name is None:
archive_name = self.archive_name
return '/'.join((self._get_release_bucket(), str(build_number),
self.listing_platform_dir.rstrip('/'), archive_name))
@property
def _rev_list_cache_key(self):
return self._get_archive_path('**')
def _save_rev_list_cache(self, revisions):
# ChromiumVersion is not json-able, convert it back to string format.
super()._save_rev_list_cache([str(x) for x in revisions])
def _load_rev_list_cache(self):
# Convert to ChromiumVersion that revisions can be correctly compared.
revisions = super()._load_rev_list_cache()
return [ChromiumVersion(x) for x in revisions]
def get_download_url(self, revision):
if self.chromedriver:
return {
'chrome':
self._get_archive_path(revision),
'chromedriver':
self._get_archive_path(revision, self.chromedriver_archive_name),
}
return self._get_archive_path(revision)
class ArchiveBuildWithCommitPosition(ArchiveBuild):
"""Class for ArchiveBuilds that organized based on commit position."""
def get_last_change_url(self):
return None
def __init__(self, options):
super().__init__(options)
# convert good and bad to commit position as int.
self.good_revision = GetRevision(self.good_revision)
if not options.bad:
self.bad_revision = GetChromiumRevision(self.get_last_change_url())
self.bad_revision = GetRevision(self.bad_revision)
class OfficialBuild(ArchiveBuildWithCommitPosition):
@property
def build_type(self):
return 'official'
def _get_listing_url(self):
return '/'.join((PERF_BASE_URL, self.listing_platform_dir))
def _get_rev_list(self, min_rev=None, max_rev=None):
# For official builds, it's getting the list from perf build bucket.
# Since it's cheap to get full list, we are returning the full list for
# caching.
revision_re = re.compile(r'%s_(\d+)\.zip' % (self.archive_extract_dir))
revision_files = GsutilList(self._get_listing_url())
revision_numbers = []
for revision_file in revision_files:
revision_num = revision_re.search(revision_file)
if revision_num:
revision_numbers.append(int(revision_num[1]))
return revision_numbers
@property
def _rev_list_cache_key(self):
return self._get_listing_url()
def get_download_url(self, revision):
return '%s/%s%s_%s.zip' % (PERF_BASE_URL, self.listing_platform_dir,
self.archive_extract_dir, revision)
class SnapshotBuild(ArchiveBuildWithCommitPosition):
@property
def base_url(self):
return CHROMIUM_BASE_URL
@property
def build_type(self):
return 'snapshot'
def _get_marker_for_revision(self, revision):
return '%s%d' % (self.listing_platform_dir, revision)
def _fetch_and_parse(self, url):
"""Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
next-marker is not None, then the listing is a partial listing and another
fetch should be performed with next-marker being the marker= GET
parameter."""
handle = urllib.request.urlopen(url)
document = ElementTree.parse(handle)
# All nodes in the tree are namespaced. Get the root's tag name to extract
# the namespace. Etree does namespaces as |{namespace}tag|.
root_tag = document.getroot().tag
end_ns_pos = root_tag.find('}')
if end_ns_pos == -1:
raise Exception('Could not locate end namespace for directory index')
namespace = root_tag[:end_ns_pos + 1]
# Find the prefix (_listing_platform_dir) and whether or not the list is
# truncated.
prefix_len = len(document.find(namespace + 'Prefix').text)
next_marker = None
is_truncated = document.find(namespace + 'IsTruncated')
if is_truncated is not None and is_truncated.text.lower() == 'true':
next_marker = document.find(namespace + 'NextMarker').text
# Get a list of all the revisions.
revisions = []
revision_re = re.compile(r'(\d+)')
all_prefixes = document.findall(namespace + 'CommonPrefixes/' + namespace +
'Prefix')
# The <Prefix> nodes have content of the form of
# |_listing_platform_dir/revision/|. Strip off the platform dir and the
# trailing slash to just have a number.go
for prefix in all_prefixes:
match = revision_re.search(prefix.text[prefix_len:])
if match:
revisions.append(int(match[1]))
return revisions, next_marker
def get_last_change_url(self):
"""Returns a URL to the LAST_CHANGE file."""
return self.base_url + '/' + self.listing_platform_dir + 'LAST_CHANGE'
def _get_rev_list(self, min_rev=None, max_rev=None):
# This method works by parsing the Google Storage directory listing into a
# list of revision numbers. This method can return a full revision list for
# a full scan.
if not max_rev:
max_rev = GetChromiumRevision(self.get_last_change_url())
# The commondatastorage API listing the files by alphabetical order instead
# of numerical order (e.g. 1, 10, 2, 3, 4). That starting or breaking the
# pagination from a known position is only valid when the number of digits
# of min_rev == max_rev.
start_marker = None
next_marker = None
if min_rev is not None and max_rev is not None and len(str(min_rev)) == len(
str(max_rev)):
start_marker = next_marker = self._get_marker_for_revision(min_rev)
else:
max_rev = None
revisions = []
while True:
sys.stdout.write('\rFetching revisions at marker %s' % next_marker)
sys.stdout.flush()
new_revisions, next_marker = self._fetch_and_parse(
self._get_listing_url(next_marker))
revisions.extend(new_revisions)
if max_rev and new_revisions and max_rev <= max(new_revisions):
break
if not next_marker:
break
sys.stdout.write('\r')
sys.stdout.flush()
# We can only ensure the revisions have no gap (due to the alphabetical
# order) between min_rev and max_rev.
if start_marker or next_marker:
return [
x for x in revisions if ((min_rev is None or min_rev <= x) and (
max_rev is None or x <= max_rev))
]
# Unless we did a full scan. `not start_marker and not next_marker`
else:
return revisions
def _get_listing_url(self, marker=None):
"""Returns the URL for a directory listing, with an optional marker."""
marker_param = ''
if marker:
marker_param = '&marker=' + str(marker)
return (self.base_url + '/?delimiter=/&prefix=' +
self.listing_platform_dir + marker_param)
@property
def _rev_list_cache_key(self):
return self._get_listing_url()
def get_download_url(self, revision):
archive_name = self.archive_name
# `archive_name` was changed for chromeos, win and win64 at revision 591483
# This is patched for backward compatibility.
if revision < 591483:
if self.platform == 'chromeos':
archive_name = 'chrome-linux.zip'
elif self.platform in ('win', 'win64'):
archive_name = 'chrome-win32.zip'
url_prefix = '%s/%s%s/' % (self.base_url, self.listing_platform_dir,
revision)
chrome_url = url_prefix + archive_name
if self.chromedriver:
return {
'chrome': chrome_url,
'chromedriver': url_prefix + self.chromedriver_archive_name,
}
return chrome_url
class ChromeForTestingBuild(SnapshotBuild):
"""Chrome for Testing pre-revision build is public through
storage.googleapis.com.
The archive URL format for CfT builds is:
https://storage.googleapis.com/chrome-for-testing-per-commit-public/{platform}/r{revision}/chrome-{platform}.zip
"""
@property
def base_url(self):
return CHROME_FOR_TESTING_BASE_URL
@property
def build_type(self):
return 'cft'
def _get_marker_for_revision(self, revision):
return '%sr%d' % (self.listing_platform_dir, revision)
def get_download_url(self, revision):
url_prefix = '%s/%sr%d/' % (self.base_url, self.listing_platform_dir,
revision)
chrome_url = url_prefix + self.archive_name
if self.chromedriver:
return {
'chrome': chrome_url,
'chromedriver': url_prefix + self.chromedriver_archive_name,
}
return chrome_url
class ASANBuild(SnapshotBuild):
"""ASANBuilds works like SnapshotBuild which fetch from commondatastorage, but
with a different listing url."""
def __init__(self, options):
super().__init__(options)
self.asan_build_type = 'release'
@property
def base_url(self):
return ASAN_BASE_URL
@property
def build_type(self):
return 'asan'
def GetASANPlatformDir(self):
"""ASAN builds are in directories like "linux-release", or have filenames
like "asan-win32-release-277079.zip". This aligns to our platform names
except in the case of Windows where they use "win32" instead of "win"."""
if self.platform == 'win':
return 'win32'
else:
return self.platform
def GetASANBaseName(self):
"""Returns the base name of the ASAN zip file."""
# TODO: These files were not update since 2016 for linux, 2021 for win.
# Need to confirm if it's moved.
if 'linux' in self.platform:
return 'asan-symbolized-%s-%s' % (self.GetASANPlatformDir(),
self.asan_build_type)
else:
return 'asan-%s-%s' % (self.GetASANPlatformDir(), self.asan_build_type)
def get_last_change_url(self):
# LAST_CHANGE is not supported in asan build.
return None
def _get_listing_url(self, marker=None):
"""Returns the URL for a directory listing, with an optional marker."""
marker_param = ''
if marker:
marker_param = '&marker=' + str(marker)
prefix = '%s-%s/%s' % (self.GetASANPlatformDir(), self.asan_build_type,
self.GetASANBaseName())
# This is a hack for delimiter to make commondata API return file path as
# prefix that can reuse the code of SnapshotBuild._fetch_and_parse.
return self.base_url + '/?delimiter=.zip&prefix=' + prefix + marker_param
def _get_marker_for_revision(self, revision):
# The build type is hardcoded as release in the original code.
return '%s-%s/%s-%d.zip' % (self.GetASANPlatformDir(), self.asan_build_type,
self.GetASANBaseName(), revision)
def get_download_url(self, revision):
return '%s/%s' % (self.base_url, self._get_marker_for_revision(revision))
class AndroidBuildMixin:
def __init__(self, options):
super().__init__(options)
self.apk = options.apk
self.device = InitializeAndroidDevice(options.device_id, self.apk, None)
self.flag_changer = None
if not self.device:
raise BisectException('Failed to initialize device.')
self.binary_name = self._get_apk_filename()
def _get_apk_mapping(self):
sdk = self.device.build_version_sdk
if 'webview' in self.apk.lower():
return WEBVIEW_APK_FILENAMES
# Need these logic to bisect very old build. Release binaries are stored
# forever and occasionally there are requests to bisect issues introduced
# in very old versions.
elif sdk < version_codes.LOLLIPOP:
return CHROME_APK_FILENAMES
elif sdk < version_codes.NOUGAT:
return CHROME_MODERN_APK_FILENAMES
else:
return MONOCHROME_APK_FILENAMES
def _get_apk_filename(self):
apk_mapping = self._get_apk_mapping()
if self.apk not in apk_mapping:
raise BisectException(
'Bisecting on Android only supported for these apks: [%s].' %
'|'.join(apk_mapping))
return apk_mapping[self.apk]
def _show_available_apks(self, tempdir):
"""glob and show available apks for the path."""
available_apks = []
all_apks = []
reversed_apk_mapping = {v: k for k, v in self._get_apk_mapping().items()}
for apk_path in glob.glob(self._get_extract_binary_glob(tempdir, "*")):
apk_name = os.path.basename(apk_path)
if not re.search("\.apks?$", apk_name):
continue
all_apks.append(apk_name)
if apk_name in reversed_apk_mapping:
available_apks.append(reversed_apk_mapping[apk_name])
if available_apks:
print(f"The list of available --apk: {{{','.join(available_apks)}}}")
elif all_apks:
print("No supported apk found. But found following APK(s): "
f"{{{','.join(all_apks)}}}")
else:
print("No APK(s) found.")
def _install_revision(self, download, tempdir):
UnzipFilenameToDir(download, tempdir)
apk_path = glob.glob(self._get_extract_binary_glob(tempdir))
if len(apk_path) == 0:
self._show_available_apks(tempdir)
raise BisectException(f'Can not find {self.binary_name} from {tempdir}')
InstallOnAndroid(self.device, apk_path[0])
def _launch_revision(self, tempdir, executables, args=()):
if args:
if self.apk not in chrome.PACKAGE_INFO:
raise BisectException(
f'Launching args are not supported for {self.apk}')
if not self.flag_changer:
self.flag_changer = flag_changer.FlagChanger(
self.device, chrome.PACKAGE_INFO[self.apk].cmdline_file)
self.flag_changer.ReplaceFlags(args)
LaunchOnAndroid(self.device, self.apk)
return (0, sys.stdout, sys.stderr)
def _get_extract_binary_glob(self, tempdir, binary_name=None):
if binary_name is None:
binary_name = self.binary_name
return '%s/*/apks/%s' % (tempdir, binary_name)
class AndroidTrichromeMixin(AndroidBuildMixin):
def __init__(self, options):
self._64bit_platforms = ('android-arm64', 'android-x64',
'android-arm64-high')
super().__init__(options)
if self.device.build_version_sdk < version_codes.Q:
raise BisectException("Trichrome is only supported after Android Q.")
self.library_binary_name = self._get_library_filename()
def _get_apk_mapping(self, prefer_64bit=True):
if self.platform in self._64bit_platforms and prefer_64bit:
return TRICHROME64_APK_FILENAMES
else:
return TRICHROME_APK_FILENAMES
def _get_apk_filename(self, prefer_64bit=True):
apk_mapping = self._get_apk_mapping(prefer_64bit)
if self.apk not in apk_mapping:
raise BisectException(
'Bisecting on Android only supported for these apks: [%s].' %
'|'.join(apk_mapping))
return apk_mapping[self.apk]
def _get_library_filename(self, prefer_64bit=True):
apk_mapping = None
if self.platform in self._64bit_platforms and prefer_64bit:
apk_mapping = TRICHROME64_LIBRARY_FILENAMES
else:
apk_mapping = TRICHROME_LIBRARY_FILENAMES
if self.apk not in apk_mapping:
raise BisectException(
'Bisecting for Android Trichrome only supported for these apks: [%s].'
% '|'.join(apk_mapping))
return apk_mapping[self.apk]
def _install_revision(self, download, tempdir):
UnzipFilenameToDir(download, tempdir)
trichrome_library_filename = self._get_library_filename()
trichrome_library_path = glob.glob(
f'{tempdir}/*/apks/{trichrome_library_filename}')
if len(trichrome_library_path) == 0:
self._show_available_apks(tempdir)
raise BisectException(
f'Can not find {trichrome_library_filename} from {tempdir}')
trichrome_filename = self._get_apk_filename()
trichrome_path = glob.glob(f'{tempdir}/*/apks/{trichrome_filename}')
if len(trichrome_path) == 0:
self._show_available_apks(tempdir)
raise BisectException(f'Can not find {trichrome_filename} from {tempdir}')
InstallOnAndroid(self.device, trichrome_library_path[0])
InstallOnAndroid(self.device, trichrome_path[0])
class AndroidReleaseBuild(AndroidBuildMixin, ReleaseBuild):
def __init__(self, options):
super().__init__(options)
self.signed = options.signed
# We could download the apk directly from build bucket
self.archive_name = self.binary_name
def _get_release_bucket(self):
if self.signed:
return ANDROID_RELEASE_BASE_URL_SIGNED
else:
return ANDROID_RELEASE_BASE_URL
def _get_rev_list(self, min_rev=None, max_rev=None):
# Android release builds store archives directly in a GCS bucket that
# contains a large number of objects. Listing the full revision list takes
# too much time, so we should disallow it and fail fast.
if not min_rev or not max_rev:
raise BisectException(
"Could not found enough revisions for Android %s release channel." %
self.apk)
return super()._get_rev_list(min_rev, max_rev)
def _install_revision(self, download, tempdir):
# AndroidRelease build downloads the apks directly from GCS bucket.
InstallOnAndroid(self.device, download)
class AndroidTrichromeReleaseBuild(AndroidTrichromeMixin, AndroidReleaseBuild):
def __init__(self, options):
super().__init__(options)
# Release build will download the binary directly from GCS bucket.
self.archive_name = self.binary_name
self.library_archive_name = self.library_binary_name
def _get_library_filename(self, prefer_64bit=True):
if self.apk == 'chrome' and self.platform == 'android-arm64-high':
raise BisectException('chrome debug build is not supported for %s' %
self.platform)
return super()._get_library_filename(prefer_64bit)
def get_download_url(self, revision):
# M112 is when we started serving 6432 to 4GB+ devices. Before this it was
# only to 6GB+ devices.
if revision >= ChromiumVersion('112'):
trichrome = self.binary_name
trichrome_library = self.library_binary_name
else:
trichrome = self._get_apk_filename(prefer_64bit=False)
trichrome_library = self._get_library_filename(prefer_64bit=False)
return {
'trichrome': self._get_archive_path(revision, trichrome),
'trichrome_library': self._get_archive_path(revision,
trichrome_library),
}
def _install_revision(self, download, tempdir):
if not isinstance(download, dict):
raise Exception("Trichrome should download multiple files from GCS.")
# AndroidRelease build downloads the apks directly from GCS bucket.
# Trichrome need to install the trichrome_library first.
InstallOnAndroid(self.device, download['trichrome_library'])
InstallOnAndroid(self.device, download['trichrome'])
class AndroidTrichromeOfficialBuild(AndroidTrichromeMixin, OfficialBuild):
def _get_apk_mapping(self, prefer_64bit=True):
return {
k: v.replace(".apks", ".minimal.apks")
for k, v in super()._get_apk_mapping(prefer_64bit).items()
}
class LinuxReleaseBuild(ReleaseBuild):
def _get_extra_args(self):
args = super()._get_extra_args()
# The sandbox must be run as root on release Chrome, so bypass it.
if self.platform.startswith('linux'):
args.append('--no-sandbox')
return args
class AndroidOfficialBuild(AndroidBuildMixin, OfficialBuild):
pass
class AndroidSnapshotBuild(AndroidBuildMixin, SnapshotBuild):
pass
class IOSReleaseBuild(ReleaseBuild):
def __init__(self, options):
super().__init__(options)
self.signed = options.signed
if not self.signed:
print('WARNING: --signed is recommended for iOS release builds.')
self.device_id = options.device_id
if not self.device_id:
raise BisectException('--device-id is required for iOS builds.')
self.ipa = options.ipa
if not self.ipa:
raise BisectException('--ipa is required for iOS builds.')
if self.ipa.endswith('.ipa'):
self.ipa = self.ipa[:-4]
self.binary_name = self.archive_name = f'{self.ipa}.ipa'
def _get_release_bucket(self):
if self.signed:
return IOS_RELEASE_BASE_URL_SIGNED
return IOS_RELEASE_BASE_URL
def _get_archive_path(self, build_number, archive_name=None):
if archive_name is None:
archive_name = self.archive_name
# The format for iOS build is
# {IOS_RELEASE_BASE_URL}/{build_number}/{sdk_version}
# /{builder_name}/{build_number}/{archive_name}
# that it's not possible to generate the actual archive_path for a build.
# That we are returning a path with wildcards and expecting only one match.
return (f'{self._get_release_bucket()}/{build_number}/*/'
f'{self.listing_platform_dir.rstrip("/")}/*/{archive_name}')
def _install_revision(self, download, tempdir):
# install ipa
retcode, stdout, stderr = self._run([
'xcrun', 'devicectl', 'device', 'install', 'app', '--device',
self.device_id, download
])
if retcode:
raise BisectException(f'Install app error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
# extract and return CFBundleIdentifier from ipa.
UnzipFilenameToDir(download, tempdir)
plist = glob.glob(f'{tempdir}/Payload/*/Info.plist')
if not plist:
raise BisectException(f'Could not find Info.plist from {tempdir}.')
retcode, stdout, stderr = self._run(
['plutil', '-extract', 'CFBundleIdentifier', 'raw', plist[0]])
if retcode:
raise BisectException(f'Extract bundle identifier error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
bundle_identifier = stdout.strip()
return bundle_identifier
def _launch_revision(self, tempdir, bundle_identifier, args=()):
retcode, stdout, stderr = self._run([
'xcrun', 'devicectl', 'device', 'process', 'launch', '--device',
self.device_id, bundle_identifier, *args
])
if retcode:
print(f'Warning: App launching error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
return retcode, stdout, stderr
class IOSSimulatorReleaseBuild(ReleaseBuild):
"""
chrome/ci/ios-simulator is generating this build and archiving it in
gs://bling-archive with Chrome versions. It's not actually a release build,
but it's similar to one.
"""
def __init__(self, options):
super().__init__(options)
self.device_id = options.device_id
if not self.device_id:
raise BisectException('--device-id is required for iOS Simulator.')
retcode, stdout, stderr = self._run(
['xcrun', 'simctl', 'boot', self.device_id])
if retcode:
print(f'Warning: Boot Simulator error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
def _get_release_bucket(self):
return IOS_ARCHIVE_BASE_URL
def _get_archive_path(self, build_number, archive_name=None):
if archive_name is None:
archive_name = self.archive_name
# The path format for ios-simulator build is
# {%chromium_version%}/{%timestamp%}/Chromium.tar.gz
# that it's not possible to generate the actual archive_path for a build.
# We are returning a path with wildcards and expecting only one match.
return f'{self._get_release_bucket()}/{build_number}/*/{archive_name}'
def _get_extract_binary_glob(self, tempdir):
return f'{tempdir}/{self.binary_name}'
def _install_revision(self, download, tempdir):
executables = super()._install_revision(download, tempdir)
executable = executables['chrome']
# install app
retcode, stdout, stderr = self._run(
['xcrun', 'simctl', 'install', self.device_id, executable])
if retcode:
raise BisectException(f'Install app error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
# extract and return CFBundleIdentifier from app.
plist = glob.glob(f'{executable}/Info.plist')
if not plist:
raise BisectException(f'Could not find Info.plist from {executable}.')
retcode, stdout, stderr = self._run(
['plutil', '-extract', 'CFBundleIdentifier', 'raw', plist[0]])
if retcode:
raise BisectException(f'Extract bundle identifier error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
bundle_identifier = stdout.strip()
return bundle_identifier
def _launch_revision(self, tempdir, bundle_identifier, args=()):
retcode, stdout, stderr = self._run(
['xcrun', 'simctl', 'launch', self.device_id, bundle_identifier, *args])
if retcode:
print(f'Warning: App launching error, code:{retcode}\n'
f'stdout:\n{stdout}\n'
f'stderr:\n{stderr}')
return retcode, stdout, stderr
def create_archive_build(options):
if options.build_type == 'release':
if options.archive == 'android-arm64-high':
return AndroidTrichromeReleaseBuild(options)
elif options.archive.startswith('android'):
return AndroidReleaseBuild(options)
elif options.archive.startswith('linux'):
return LinuxReleaseBuild(options)
elif options.archive == 'ios-simulator':
return IOSSimulatorReleaseBuild(options)
elif options.archive == 'ios':
return IOSReleaseBuild(options)
return ReleaseBuild(options)
elif options.build_type == 'official':
if options.archive == 'android-arm64-high':
return AndroidTrichromeOfficialBuild(options)
elif options.archive.startswith('android'):
return AndroidOfficialBuild(options)
return OfficialBuild(options)
elif options.build_type == 'asan':
# ASANBuild is only supported on win/linux/mac.
return ASANBuild(options)
elif options.build_type == 'cft':
return ChromeForTestingBuild(options)
else:
if options.archive.startswith('android'):
return AndroidSnapshotBuild(options)
return SnapshotBuild(options)
def IsMac():
return sys.platform.startswith('darwin')
def UnzipFilenameToDir(filename, directory):
"""Unzip |filename| to |directory|."""
cwd = os.getcwd()
if not os.path.isabs(filename):
filename = os.path.join(cwd, filename)
# Make base.
if not os.path.isdir(directory):
os.mkdir(directory)
os.chdir(directory)
# Support for tar archives.
if tarfile.is_tarfile(filename):
tf = tarfile.open(filename, 'r')
tf.extractall(directory)
os.chdir(cwd)
return
# The Python ZipFile does not support symbolic links, which makes it
# unsuitable for Mac builds. so use ditto instead.
if IsMac():
unzip_cmd = ['ditto', '-x', '-k', filename, '.']
proc = subprocess.Popen(unzip_cmd, bufsize=0, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
proc.communicate()
os.chdir(cwd)
return
zf = zipfile.ZipFile(filename)
# Extract files.
for info in zf.infolist():
name = info.filename
if name.endswith('/'): # dir
if not os.path.isdir(name):
os.makedirs(name)
else: # file
directory = os.path.dirname(name)
if directory and not os.path.isdir(directory):
os.makedirs(directory)
out = open(name, 'wb')
out.write(zf.read(name))
out.close()
# Set permissions. Permission info in external_attr is shifted 16 bits.
os.chmod(name, info.external_attr >> 16)
os.chdir(cwd)
def gsutil_download(download_url, filename):
command = ['cp', download_url, filename]
RunGsutilCommand(command)
def EvaluateRevision(archive_build, download, revision, args, evaluate):
"""fetch.wait_for(), archive_build.run_revision() and evaluate the result."""
while True:
exit_status = stdout = stderr = None
# Create a temp directory and unzip the revision into it.
with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
# On Windows 10, file system needs to be readable from App Container.
if sys.platform == 'win32' and platform.release() == '10':
icacls_cmd = ['icacls', tempdir, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)']
proc = subprocess.Popen(icacls_cmd,
bufsize=0,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
proc.communicate()
# run_revision
print(f'Trying revision {revision!s}: {download!s} in {tempdir!s}')
try:
exit_status, stdout, stderr = archive_build.run_revision(
download, tempdir, args)
except SystemExit:
raise
except Exception:
traceback.print_exc(file=sys.stderr)
# evaluate
answer = evaluate(revision, exit_status, stdout, stderr)
if answer != 'r':
return answer
# The arguments release_builds, status, stdout and stderr are unused.
# They are present here because this function is passed to Bisect which then
# calls it with 5 arguments.
# pylint: disable=W0613
def AskIsGoodBuild(rev, exit_status, stdout, stderr):
"""Asks the user whether build |rev| is good or bad."""
# Loop until we get a response that we can parse.
while True:
response = input('Revision %s is '
'[(g)ood/(b)ad/(r)etry/(u)nknown/(s)tdout/(q)uit]: ' %
str(rev))
if response in ('g', 'b', 'r', 'u'):
return response
if response == 'q':
raise SystemExit()
if response == 's':
print(stdout)
print(stderr)
def IsGoodASANBuild(rev, exit_status, stdout, stderr):
"""Determine if an ASAN build |rev| is good or bad
Will examine stderr looking for the error message emitted by ASAN. If not
found then will fallback to asking the user."""
if stderr:
bad_count = 0
for line in stderr.splitlines():
print(line)
if line.find('ERROR: AddressSanitizer:') != -1:
bad_count += 1
if bad_count > 0:
print('Revision %d determined to be bad.' % rev)
return 'b'
return AskIsGoodBuild(rev, exit_status, stdout, stderr)
def DidCommandSucceed(rev, exit_status, stdout, stderr):
if exit_status:
print('Bad revision: %s' % rev)
return 'b'
else:
print('Good revision: %s' % rev)
return 'g'
class DownloadJob:
"""
DownloadJob represents a task to download a given url.
"""
def __init__(self, url, rev, name=None):
"""
Args:
url: The url to download or a dict of {key: url} to download multiple
targets.
rev: The revision of the target.
name: The name of the thread.
"""
if isinstance(url, dict):
self.is_multiple = True
self.urls = url
else:
self.is_multiple = False
self.urls = {None: url}
self.rev = rev
self.name = name
self.results = {}
self.exc_info = None # capture exception from worker thread
self.quit_event = threading.Event()
self.progress_event = threading.Event()
self.thread = None
def _clear_up_tmp_files(self):
if not self.results:
return
for tmp_file in self.results.values():
try:
os.unlink(tmp_file)
except FileNotFoundError:
# Handle missing archives.
pass
self.results = None
def __del__(self):
self._clear_up_tmp_files()
def _report_hook(self, blocknum, blocksize, totalsize):
if self.quit_event and self.quit_event.is_set():
raise RuntimeError('Aborting download of revision %s' % str(self.rev))
if not self.progress_event or not self.progress_event.is_set():
return
size = blocknum * blocksize
if totalsize == -1: # Total size not known.
progress = 'Received %d bytes' % size
else:
size = min(totalsize, size)
progress = 'Received %d of %d bytes, %.2f%%' % (size, totalsize,
100.0 * size / totalsize)
# Send a \r to let all progress messages use just one line of output.
print(progress, end='\r', flush=True)
def _fetch(self, url, tmp_file):
if url.startswith('gs'):
gsutil_download(url, tmp_file)
else:
urllib.request.urlretrieve(url, tmp_file, self._report_hook)
if self.progress_event and self.progress_event.is_set():
print()
def fetch(self):
try:
for key, url in self.urls.items():
# Keep the basename as part of tempfile name that make it easier to
# identify what's been downloaded.
basename = os.path.basename(urllib.parse.urlparse(url).path)
fd, tmp_file = tempfile.mkstemp(suffix=basename)
self.results[key] = tmp_file
os.close(fd)
self._fetch(url, tmp_file)
except RuntimeError:
pass
except BaseException:
self.exc_info = sys.exc_info()
def start(self):
"""Start the download in a thread."""
assert self.thread is None, "DownloadJob is already started."
self.thread = threading.Thread(target=self.fetch, name=self.name)
self.thread.start()
return self
def stop(self):
"""Stops the download which must have been started previously."""
assert self.thread, 'DownloadJob must be started before Stop is called.'
self.quit_event.set()
self.thread.join()
self._clear_up_tmp_files()
def wait_for(self):
"""Prints a message and waits for the download to complete.
The method will return the path of downloaded files.
"""
if not self.thread:
self.start()
print('Downloading revision %s...' % str(self.rev))
self.progress_event.set() # Display progress of download.
try:
while self.thread.is_alive():
# The parameter to join is needed to keep the main thread responsive to
# signals. Without it, the program will not respond to interruptions.
self.thread.join(1)
if self.exc_info:
raise self.exc_info[1].with_traceback(self.exc_info[2])
if self.quit_event.is_set():
raise Exception('The DownloadJob was stopped.')
if self.is_multiple:
return self.results
else:
return self.results[None]
except (KeyboardInterrupt, SystemExit):
self.stop()
raise
def Bisect(archive_build,
try_args=(),
evaluate=AskIsGoodBuild,
verify_range=False):
"""Runs a binary search on to determine the last known good revision.
Args:
archive_build: ArchiveBuild object initialized with user provided
parameters.
try_args: A tuple of arguments to pass to the test application.
evaluate: A function which returns 'g' if the argument build is good,
'b' if it's bad or 'u' if unknown.
verify_range: If true, tests the first and last revisions in the range
before proceeding with the bisect.
Threading is used to fetch Chromium revisions in the background, speeding up
the user's experience. For example, suppose the bounds of the search are
good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
whether revision 50 is good or bad, the next revision to check will be either
25 or 75. So, while revision 50 is being checked, the script will download
revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
known:
- If rev 50 is good, the download of rev 25 is cancelled, and the next test
is run on rev 75.
- If rev 50 is bad, the download of rev 75 is cancelled, and the next test
is run on rev 25.
"""
print('Downloading list of known revisions.', end=' ')
print('If the range is large, this can take several minutes...')
if not archive_build.use_local_cache:
print('(use --use-local-cache to cache and re-use the list of revisions)')
else:
print()
rev_list = archive_build.get_rev_list()
# Ensure rev_list[0] is good and rev_list[-1] is bad for easier process.
if archive_build.good_revision > archive_build.bad_revision:
rev_list = rev_list[::-1]
if IsVersionNumber(rev_list[0]):
change_log_url_fn = GetReleaseChangeLogURL
else:
change_log_url_fn = GetShortChangeLogURL
if verify_range:
good_rev_fetch = archive_build.get_download_job(rev_list[0],
'good_rev_fetch').start()
bad_rev_fetch = archive_build.get_download_job(rev_list[-1],
'bad_rev_fetch').start()
try:
good_download = good_rev_fetch.wait_for()
answer = EvaluateRevision(archive_build, good_download, rev_list[0],
try_args, evaluate)
if answer != 'g':
print(f'Expecting revision {rev_list[0]} to be good but got {answer}. '
'Please make sure the --good is a good revision.')
raise SystemExit
bad_download = bad_rev_fetch.wait_for()
answer = EvaluateRevision(archive_build, bad_download, rev_list[-1],
try_args, evaluate)
if answer != 'b':
print(f'Expecting revision {rev_list[-1]} to be bad but got {answer}. '
'Please make sure that the issue can be reproduced for --bad.')
raise SystemExit
except (KeyboardInterrupt, SystemExit):
print('Cleaning up...')
return None, None
finally:
good_rev_fetch.stop()
bad_rev_fetch.stop()
prefetch = {}
try:
while len(rev_list) > 2:
# We are retaining the boundary elements in the rev_list, that should not
# count towards the steps when calculating the number the steps.
print('You have %d revisions with about %d steps left.' %
(len(rev_list), ((len(rev_list) - 2).bit_length())))
change_log_url = ""
if (len(rev_list) - 2).bit_length() <= STEPS_TO_SHOW_CHANGELOG_URL:
change_log_url = f"({change_log_url_fn(rev_list[-1], rev_list[0])})"
print('Bisecting range [%s (bad), %s (good)]%s.' % (
rev_list[-1], rev_list[0], change_log_url))
# clean prefetch to keep only the valid fetches
for key in list(prefetch.keys()):
if key not in rev_list:
prefetch.pop(key).stop()
# get next revision to evaluate from prefetch
if prefetch:
fetch = None
# For any possible index in rev_list, abs(mid - index) < abs(mid -
# pivot). This will ensure that we can always get a fetch from prefetch.
pivot = len(rev_list)
for revision, pfetch in prefetch.items():
prefetch_pivot = rev_list.index(revision)
# Prefer the revision closer to the mid point.
mid_point = len(rev_list) // 2
if abs(mid_point - pivot) > abs(mid_point - prefetch_pivot):
fetch = pfetch
pivot = prefetch_pivot
prefetch.pop(rev_list[pivot])
# or just the mid point
else:
pivot = len(rev_list) // 2
fetch = archive_build.get_download_job(rev_list[pivot], 'fetch').start()
try:
download = fetch.wait_for()
# prefetch left_pivot = len(rev_list[:pivot+1]) // 2
left_revision = rev_list[(pivot + 1) // 2]
if left_revision != rev_list[0] and left_revision not in prefetch:
prefetch[left_revision] = archive_build.get_download_job(
left_revision, 'prefetch').start()
# prefetch right_pivot = len(rev_list[pivot:]) // 2
right_revision = rev_list[(len(rev_list) + pivot) // 2]
if right_revision != rev_list[-1] and right_revision not in prefetch:
prefetch[right_revision] = archive_build.get_download_job(
right_revision, 'prefetch').start()
# evaluate the revision
answer = EvaluateRevision(archive_build, download, rev_list[pivot],
try_args, evaluate)
# Ensure rev_list[0] is good and rev_list[-1] is bad after adjust.
if answer == 'g': # good
rev_list = rev_list[pivot:]
elif answer == 'b': # bad
# Retain the pivot element within the list to act as a confirmed
# boundary for identifying bad revisions.
rev_list = rev_list[:pivot + 1]
elif answer == 'u': # unknown
# Nuke the revision from the rev_list.
rev_list.pop(pivot)
else:
assert False, 'Unexpected return value from evaluate(): ' + answer
finally:
fetch.stop()
# end of `while len(rev_list) > 2`
finally:
for each in prefetch.values():
each.stop()
prefetch.clear()
return sorted((rev_list[0], rev_list[-1]))
def GetChromiumRevision(url, default=999999999):
"""Returns the chromium revision read from given URL."""
if not url:
return default
try:
# Location of the latest build revision number
latest_revision = urllib.request.urlopen(url).read()
if latest_revision.isdigit():
return int(latest_revision)
return default
except Exception:
print('Could not determine latest revision. This could be bad...')
return default
def FetchJsonFromURL(url):
"""Returns JSON data from the given URL"""
# Allow retry for 3 times for unexpected network error
for i in range(3):
try:
data = urllib.request.urlopen(url).read()
# Remove the potential XSSI prefix from JSON output
data = data.lstrip(b")]}',\n")
return json.loads(data)
except urllib.request.HTTPError as e:
print(f'urlopen {url} HTTPError: {e}')
except json.JSONDecodeError as e:
print(f'urlopen {url} JSON decode error: {e}')
return None
def GetGitHashFromSVNRevision(svn_revision):
"""Returns GitHash from SVN Revision"""
crrev_url = CRREV_URL + str(svn_revision)
data = FetchJsonFromURL(crrev_url)
if data and 'git_sha' in data:
return data['git_sha']
return None
def GetShortChangeLogURL(rev1, rev2):
min_rev, max_rev = sorted([rev1, rev2])
return SHORT_CHANGELOG_URL % (min_rev, max_rev)
def GetChangeLogURL(rev1, rev2):
"""Prints the changelog URL."""
min_rev, max_rev = sorted([rev1, rev2])
return CHANGELOG_URL % (GetGitHashFromSVNRevision(min_rev),
GetGitHashFromSVNRevision(max_rev))
def GetReleaseChangeLogURL(version1, version2):
"""Prints the changelog URL."""
min_ver, max_ver= sorted([version1, version2])
return RELEASE_CHANGELOG_URL % (min_ver, max_ver)
def IsVersionNumber(revision):
"""Checks if provided revision is version_number"""
if isinstance(revision, ChromiumVersion):
return True
if not isinstance(revision, str):
return False
return re.match(r'^\d+\.\d+\.\d+\.\d+$', revision) is not None
def GetRevisionFromSourceTag(tag):
"""Return Base Commit Position based on the commit message of a version tag"""
# Searching from commit message for
# Cr-Branched-From: (?P<githash>\w+)-refs/heads/master@{#857950}
# Cr-Commit-Position: refs/heads/main@{#992738}
revision_regex = re.compile(r'refs/heads/\w+@{#(\d+)}$')
source_url = SOURCE_TAG_URL % str(tag)
data = FetchJsonFromURL(source_url)
match = revision_regex.search(data.get('message', ''))
if not match:
# The commit message for version tag before M116 doesn't contains
# Cr-Branched-From and Cr-Commit-Position message lines. However they might
# exists in the parent commit.
source_url = SOURCE_TAG_URL % (str(tag) + '^')
data = FetchJsonFromURL(source_url)
match = revision_regex.search(data.get('message', ''))
if match:
return int(match.group(1))
def GetRevisionFromVersion(version):
"""Returns Base Commit Position from a version number"""
chromiumdash_url = VERSION_INFO_URL % str(version)
data = FetchJsonFromURL(chromiumdash_url)
if not data:
# Not every tag is released as a version for users. Some "versions" from the
# release builder might not exist in the VERSION_INFO_URL API. With the
# `MaybeSwitchBuildType` functionality, users might get such unreleased
# versions and try to use them with the -o flag, resulting in a 404 error.
# Meanwhile, this method retrieves the `chromium_main_branch_position`,
# which should be the same for all 127.0.6533.* versions, so we can get the
# branch position from 127.0.6533.0 instead.
chromiumdash_url = VERSION_INFO_URL % re.sub(r'\d+$', '0', str(version))
data = FetchJsonFromURL(chromiumdash_url)
if data and data.get('chromium_main_branch_position'):
return data['chromium_main_branch_position']
revision_from_source_tag = GetRevisionFromSourceTag(version)
if revision_from_source_tag:
return revision_from_source_tag
raise BisectException(
f'Can not find revision for {version} from chromiumdash and source')
def GetRevisionFromMilestone(milestone):
"""Get revision (e.g. 782793) from milestone such as 85."""
response = urllib.request.urlopen(MILESTONES_URL % milestone)
milestones = json.loads(response.read())
for m in milestones:
if m['milestone'] == milestone and m.get('chromium_main_branch_position'):
return m['chromium_main_branch_position']
raise BisectException(f'Can not find revision for milestone {milestone}')
def GetRevision(revision):
"""Get revision from either milestone M85, full version 85.0.4183.0,
or a commit position.
"""
if type(revision) == type(0):
return revision
if IsVersionNumber(revision):
return GetRevisionFromVersion(revision)
elif revision[:1].upper() == 'M' and revision[1:].isdigit():
return GetRevisionFromMilestone(int(revision[1:]))
# By default, we assume it's a commit position.
return int(revision)
def CheckDepotToolsInPath():
delimiter = ';' if sys.platform.startswith('win') else ':'
path_list = os.environ['PATH'].split(delimiter)
for path in path_list:
if path.rstrip(os.path.sep).endswith('depot_tools'):
return path
return None
def SetupEnvironment(options):
global is_verbose
global GSUTILS_PATH
# Release and Official builds bisect requires "gsutil" inorder to
# List and Download binaries.
# Check if depot_tools is installed and path is set.
gsutil_path = CheckDepotToolsInPath()
if (options.build_type in ('release', 'official') and not gsutil_path):
raise BisectException(
'Looks like depot_tools is not installed.\n'
'Follow the instructions in this document '
'http://dev.chromium.org/developers/how-tos/install-depot-tools '
'to install depot_tools and then try again.')
elif gsutil_path:
GSUTILS_PATH = os.path.join(gsutil_path, 'gsutil.py')
# Catapult repo is required for Android bisect,
# Update Catapult repo if it exists otherwise checkout repo.
if options.archive.startswith('android-'):
SetupAndroidEnvironment()
# Set up verbose logging if requested.
if options.verbose:
is_verbose = True
def SetupAndroidEnvironment():
def SetupCatapult():
print('Setting up Catapult in %s.' % CATAPULT_DIR)
print('Set the environment var CATAPULT_DIR to override '
'Catapult directory.')
if (os.path.exists(CATAPULT_DIR)):
print('Updating Catapult...\n')
process = subprocess.Popen(args=['git', 'pull', '--rebase'],
cwd=CATAPULT_DIR)
exit_code = process.wait()
if exit_code != 0:
raise BisectException('Android bisect requires Catapult repo checkout. '
'Attempt to update Catapult failed.')
else:
print('Downloading Catapult...\n')
process = subprocess.Popen(
args=['git', 'clone', CATAPULT_REPO, CATAPULT_DIR])
exit_code = process.wait()
if exit_code != 0:
raise BisectException('Android bisect requires Catapult repo checkout. '
'Attempt to download Catapult failed.')
SetupCatapult()
sys.path.append(DEVIL_PATH)
from devil.android.sdk import version_codes
# Modules required from devil
devil_imports = {
'devil_env': 'devil.devil_env',
'device_errors': 'devil.android.device_errors',
'device_utils': 'devil.android.device_utils',
'flag_changer': 'devil.android.flag_changer',
'chrome': 'devil.android.constants.chrome',
'adb_wrapper': 'devil.android.sdk.adb_wrapper',
'intent': 'devil.android.sdk.intent',
'version_codes': 'devil.android.sdk.version_codes',
'run_tests_helper': 'devil.utils.run_tests_helper'
}
# Dynamically import devil modules required for android bisect.
for i, j in devil_imports.items():
globals()[i] = importlib.import_module(j)
print('Done setting up Catapult.\n')
def InitializeAndroidDevice(device_id, apk, chrome_flags):
"""Initializes device and sets chrome flags."""
devil_env.config.Initialize()
run_tests_helper.SetLogLevel(0)
device = device_utils.DeviceUtils.HealthyDevices(device_arg=device_id)[0]
if chrome_flags:
flags = flag_changer.FlagChanger(device,
chrome.PACKAGE_INFO[apk].cmdline_file)
flags.AddFlags(chrome_flags)
return device
def InstallOnAndroid(device, apk_path):
"""Installs the chromium build on a given device."""
print('Installing %s on android device...' % apk_path)
device.Install(apk_path)
def LaunchOnAndroid(device, apk):
"""Launches the chromium build on a given device."""
if 'webview' in apk:
return
print('Launching chrome on android device...')
device.StartActivity(intent.Intent(action='android.intent.action.MAIN',
activity=chrome.PACKAGE_INFO[apk].activity,
package=chrome.PACKAGE_INFO[apk].package),
blocking=True,
force_stop=True)
def _CreateCommandLineParser():
"""Creates a parser with bisect options.
Returns:
An instance of argparse.ArgumentParser.
"""
description = """
Performs binary search on the chrome binaries to find a minimal range of \
revisions where a behavior change happened.
The behaviors are described as "good" and "bad". It is NOT assumed that the \
behavior of the later revision is the bad one.
Revision numbers should use:
a) Release versions: (e.g. 1.0.1000.0) for release builds. (-r)
b) Commit Positions: (e.g. 123456) for chromium builds, from trunk.
Use chromium_main_branch_position from \
https://chromiumdash.appspot.com/fetch_version?version=<chrome_version>
Please Note: Chrome's about: build number and chromiumdash branch \
revision are incorrect, they are from branches.
Tip: add "-- --no-first-run" to bypass the first run prompts.
"""
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter, description=description)
# Strangely, the default help output doesn't include the choice list.
choices = sorted(
set(arch for build in PATH_CONTEXT for arch in PATH_CONTEXT[build]))
parser.add_argument(
'-a',
'--archive',
choices=choices,
metavar='ARCHIVE',
help='The buildbot platform to bisect {%s}.' % ','.join(choices),
)
build_type_group = parser.add_mutually_exclusive_group()
build_type_group.add_argument(
'-s',
dest='build_type',
action='store_const',
const='snapshot',
default='snapshot',
help='Bisect across Chromium snapshot archives (default).',
)
build_type_group.add_argument(
'-r',
dest='build_type',
action='store_const',
const='release',
help='Bisect across release Chrome builds (internal only) instead of '
'Chromium archives.',
)
build_type_group.add_argument(
'-o',
dest='build_type',
action='store_const',
const='official',
help='Bisect across continuous perf official Chrome builds (internal '
'only) instead of Chromium archives.',
)
build_type_group.add_argument(
'-cft',
'--cft',
dest='build_type',
action='store_const',
const='cft',
help='Bisect across Chrome for Testing (publicly accessible) archives.',
)
build_type_group.add_argument(
'--asan',
dest='build_type',
action='store_const',
const='asan',
help='Allow the script to bisect ASAN builds',
)
parser.add_argument(
'-g',
'--good',
type=str,
metavar='GOOD_REVISION',
required=True,
help='A good revision to start bisection. May be earlier or later than '
'the bad revision.',
)
parser.add_argument(
'-b',
'--bad',
type=str,
metavar='BAD_REVISION',
help='A bad revision to start bisection. May be earlier or later than '
'the good revision. Default is HEAD.',
)
parser.add_argument(
'-p',
'--profile',
'--user-data-dir',
type=str,
default='%t/profile',
help='Profile to use; this will not reset every run. Defaults to a new, '
'clean profile for every run.',
)
parser.add_argument(
'-t',
'--times',
type=int,
default=1,
help='Number of times to run each build before asking if it\'s good or '
'bad. Temporary profiles are reused.',
)
parser.add_argument(
'--chromedriver',
action='store_true',
help='Also download ChromeDriver. Use %%d in --command to reference the '
'ChromeDriver path in the command line.',
)
parser.add_argument(
'-c',
'--command',
type=str,
default=r'%p %a',
help='Command to execute. %%p and %%a refer to Chrome executable and '
'specified extra arguments respectively. Use %%t for tempdir where '
'Chrome extracted. Use %%d for chromedriver path when --chromedriver '
'enabled. Defaults to "%%p %%a". Note that any extra paths specified '
'should be absolute.',
)
parser.add_argument(
'-v',
'--verbose',
action='store_true',
help='Log more verbose information.',
)
parser.add_argument(
'--not-interactive',
action='store_true',
default=False,
help='Use command exit code to tell good/bad revision.',
)
local_cache_group = parser.add_mutually_exclusive_group()
local_cache_group.add_argument(
'--use-local-cache',
dest='use_local_cache',
action='store_true',
default=True,
help='Use a local file in the current directory to cache a list of known '
'revisions to speed up the initialization of this script.',
)
local_cache_group.add_argument(
'--no-local-cache',
dest='use_local_cache',
action='store_false',
help='Do not use local file for known revisions.',
)
parser.add_argument(
'--verify-range',
dest='verify_range',
action='store_true',
default=False,
help='Test the first and last revisions in the range before proceeding '
'with the bisect.',
)
apk_choices = sorted(
set().union(CHROME_APK_FILENAMES, CHROME_MODERN_APK_FILENAMES,
MONOCHROME_APK_FILENAMES, WEBVIEW_APK_FILENAMES,
TRICHROME_APK_FILENAMES, TRICHROME64_APK_FILENAMES))
parser.add_argument(
'--apk',
choices=apk_choices,
dest='apk',
# default='chromium', when using android archives
metavar='{chromium,chrome_dev,android_webview...}',
help=(f'Apk you want to bisect {{{",".join(apk_choices)}}}. '
'(Default: chromium/chrome)'),
)
parser.add_argument(
'--ipa',
dest='ipa',
# default='canary.ipa', when using ios archives
metavar='{canary,beta,stable...}',
help='ipa you want to bisect. (Default: canary)',
)
parser.add_argument(
'--signed',
dest='signed',
action='store_true',
default=False,
help='Using signed binary for release build. Only support iOS and '
'Android platforms.',
)
parser.add_argument(
'-d',
'--device-id',
dest='device_id',
type=str,
help='Device to run the bisect on.',
)
parser.add_argument(
'--update-script',
action=UpdateScriptAction,
nargs=0,
help='Update this script to the latest.',
)
parser.add_argument(
'args',
nargs='*',
metavar='chromium-option',
help='Additional chromium options passed to chromium process.',
)
return parser
def _DetectArchive(opts=None):
"""Detect the buildbot archive to use based on local environment."""
if opts:
if opts.apk:
return 'android-arm64'
elif opts.ipa:
return 'ios-simulator'
os_name = None
plat = sys.platform
if plat.startswith('linux'):
os_name = 'linux'
elif plat in ('win32', 'cygwin'):
os_name = 'win'
elif plat == 'darwin':
os_name = 'mac'
arch = None
machine = platform.machine().lower()
if machine.startswith(('arm', 'aarch')):
arch = 'arm'
elif machine in ('amd64', 'x86_64'):
arch = 'x64'
elif machine in ('i386', 'i686', 'i86pc', 'x86'):
arch = 'x86'
return PLATFORM_ARCH_TO_ARCHIVE_MAPPING.get((os_name, arch), None)
def ParseCommandLine(args=None):
"""Parses the command line for bisect options."""
parser = _CreateCommandLineParser()
opts = parser.parse_args(args)
if opts.archive is None:
archive = _DetectArchive(opts)
if archive:
print('The buildbot archive (-a/--archive) detected as:', archive)
opts.archive = archive
else:
parser.error('Error: Missing required parameter: --archive')
if opts.archive not in PATH_CONTEXT[opts.build_type]:
supported_build_types = [
"%s(%s)" % (b, BuildTypeToCommandLineArgument(b, omit_default=False))
for b, context in PATH_CONTEXT.items() if opts.archive in context
]
parser.error(f'Bisecting on {opts.build_type} is only supported on these '
'platforms (-a/--archive): '
f'{{{",".join(PATH_CONTEXT[opts.build_type].keys())}}}\n'
f'To bisect for {opts.archive}, please choose from '
f'{", ".join(supported_build_types)}')
all_archives = sorted(
set(arch for build in PATH_CONTEXT for arch in PATH_CONTEXT[build]))
android_archives = [x for x in all_archives if x.startswith('android-')]
ios_archives = [x for x in all_archives if x.startswith('ios')]
# Set default apk/ipa for mobile platforms.
if opts.archive in android_archives and not opts.apk:
opts.apk = 'chromium' if opts.build_type == 'snapshot' else 'chrome'
elif opts.archive in ios_archives and not opts.ipa:
opts.ipa = 'canary'
# Or raise error if apk/ipa is set for non-mobile platforms.
if opts.apk and opts.archive not in android_archives:
parser.error('--apk is only supported for Android platform (-a/--archive): '
f'{{{",".join(android_archives)}}}')
elif opts.ipa and opts.archive not in ios_archives:
parser.error('--ipa is only supported for iOS platform (-a/--archive): '
f'{{{",".join(ios_archives)}}}')
if opts.signed and opts.archive not in (android_archives + ios_archives):
parser.error('--signed is only supported for Android and iOS platform '
'(-a/--archive): '
f'{{{",".join(android_archives+ios_archives)}}}')
elif opts.signed and not opts.build_type == 'release':
parser.error('--signed is only supported for release bisection.')
if opts.build_type == 'official':
print('Bisecting on continuous Chrome builds. If you would like '
'to bisect on release builds, try running with -r option '
'instead. Previous -o options is currently changed to -r option '
'as continous official builds were added for bisect')
if not opts.good:
parser.error('Please specify a good version.')
if opts.build_type == 'release':
if not opts.bad:
parser.error('Please specify a bad version.')
if not IsVersionNumber(opts.good) or not IsVersionNumber(opts.bad):
parser.error('For release, you can only use chrome version to bisect.')
if opts.archive.startswith('android-'):
# Channel builds have _ in their names, e.g. chrome_canary or chrome_beta.
# Non-channel builds don't, e.g. chrome or chromium. Make this a warning
# instead of an error since older archives might have non-channel builds.
if '_' not in opts.apk:
print('WARNING: Android release typically only uploads channel builds, '
f'so you will often see "Found 0 builds" with --apk={opts.apk}'
'. Switch to using --apk=chrome_stable or one of the other '
'channels if you see `[Bisect Exception]: Could not found enough'
'revisions for Android chrome release channel.\n')
if opts.times < 1:
parser.error(f'Number of times to run ({opts.times}) must be greater than '
'or equal to 1.')
return opts
def BuildTypeToCommandLineArgument(build_type, omit_default=True):
"""Convert the build_type back to command line argument."""
if build_type == 'release':
return '-r'
elif build_type == 'official':
return '-o'
elif build_type == 'snapshot':
if not omit_default:
return '-s'
else:
return ''
elif build_type == 'asan':
return '--asan'
else:
raise ValueError(f'Unknown build type: {build_type}')
def GenerateCommandLine(opts):
"""Generate a command line for bisect options.
Args:
opts: The new bisect options to generate the command line for.
This generates prompts for the suggestion to use another build type
(MaybeSwitchBuildType). Not all options are supported when generating the new
command, however the remaining unsupported args would be copied from sys.argv.
"""
# Using a parser to remove the arguments (key and value) that we are going to
# generate based on the opts and appending the remaining args as is in command
# line.
parser_to_remove_known_options = argparse.ArgumentParser()
parser_to_remove_known_options.add_argument('-a', '--archive', '-g', '--good',
'-b', '--bad')
parser_to_remove_known_options.add_argument('-r',
'-o',
'--signed',
action='store_true')
_, remaining_args = parser_to_remove_known_options.parse_known_args()
args = []
args.append(BuildTypeToCommandLineArgument(opts.build_type))
if opts.archive:
args.extend(['-a', opts.archive])
if opts.signed:
args.append('--signed')
if opts.good:
args.extend(['-g', opts.good])
if opts.bad:
args.extend(['-b', opts.bad])
if opts.verify_range:
args.append('--verify-range')
return ['./%s' % os.path.relpath(__file__)] + args + remaining_args
def MaybeSwitchBuildType(opts, good, bad):
"""Generate and print suggestions to use official build to bisect for a more
precise range when possible."""
if opts.build_type != 'release':
return
if opts.archive not in PATH_CONTEXT['official']:
return
new_opts = copy.deepcopy(opts)
new_opts.signed = False # --signed is only supported by release builds
new_opts.build_type = 'official'
new_opts.verify_range = True # always verify_range when switching the build
new_opts.good = str(good) # good could be ChromiumVersion
new_opts.bad = str(bad) # bad could be ChromiumVersion
rev_list = None
if opts.use_local_cache:
print('Checking available official builds (-o) for %s.' % new_opts.archive)
archive_build = create_archive_build(new_opts)
try:
rev_list = archive_build.get_rev_list()
except BisectException as e:
# We don't have enough builds from official builder, skip suggesting.
print("But we don't have more builds from official builder.")
return
if len(rev_list) <= 2:
print("But we don't have more builds from official builder.")
return
if rev_list:
print(
"There are %d revisions between %s and %s from the continuous official "
"build (-o). You could try to get a more precise culprit range using "
"the following command:" % (len(rev_list), *sorted([good, bad])))
else:
print(
"You could try to get a more precise culprit range with the continuous "
"official build (-o) using the following command:")
command_line = GenerateCommandLine(new_opts)
print(join_args(command_line))
return command_line
class UpdateScriptAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
script_path = sys.argv[0]
script_content = str(
base64.b64decode(
urllib.request.urlopen(
"https://chromium.googlesource.com/chromium/src/+/HEAD/"
"tools/bisect-builds.py?format=TEXT").read()), 'utf-8')
with open(script_path, "w") as f:
f.write(script_content)
print("Update successful!")
exit(0)
def main():
opts = ParseCommandLine()
try:
SetupEnvironment(opts)
except BisectException as e:
print(e)
sys.exit(1)
# Create the AbstractBuild object.
archive_build = create_archive_build(opts)
if opts.not_interactive:
evaluator = DidCommandSucceed
elif opts.build_type == 'asan':
evaluator = IsGoodASANBuild
else:
evaluator = AskIsGoodBuild
# Save these revision numbers to compare when showing the changelog URL
# after the bisect.
good_rev = archive_build.good_revision
bad_rev = archive_build.bad_revision
min_chromium_rev, max_chromium_rev = Bisect(archive_build, opts.args,
evaluator, opts.verify_range)
if min_chromium_rev is None or max_chromium_rev is None:
return
# We're done. Let the user know the results in an official manner.
if good_rev > bad_rev:
print(DONE_MESSAGE_GOOD_MAX %
(str(min_chromium_rev), str(max_chromium_rev)))
good_rev, bad_rev = max_chromium_rev, min_chromium_rev
else:
print(DONE_MESSAGE_GOOD_MIN %
(str(min_chromium_rev), str(max_chromium_rev)))
good_rev, bad_rev = min_chromium_rev, max_chromium_rev
print('CHANGELOG URL:')
if opts.build_type == 'release':
print(GetReleaseChangeLogURL(min_chromium_rev, max_chromium_rev))
MaybeSwitchBuildType(opts, good=good_rev, bad=bad_rev)
else:
print(GetChangeLogURL(min_chromium_rev, max_chromium_rev))
if opts.build_type == 'official':
print('The script might not always return single CL as suspect '
'as some perf builds might get missing due to failure.')
if __name__ == '__main__':
sys.exit(main())