"""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
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'))
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
PERF_BASE_URL = 'gs://chrome-test-builds/official-by-commit'
RELEASE_BASE_URL = 'gs://chrome-unsigned/desktop-5c0tCh'
ANDROID_RELEASE_BASE_URL = 'gs://chrome-unsigned/android-B0urB0N'
ANDROID_RELEASE_BASE_URL_SIGNED = 'gs://chrome-signed/android-B0urB0N'
ANDROID_INVALID_BUCKET = 'gs://chrome-signed/android-B0urB0N/Test'
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'
GOOGLE_APIS_URL = 'commondatastorage.googleapis.com'
SHORT_CHANGELOG_URL = 'https://crrev.com/%s..%s'
CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/src/+log/%s..%s')
CRREV_URL = ('https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/')
RELEASE_CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/'
'src/+log/%s..%s?n=10000')
STEPS_TO_SHOW_CHANGELOG_URL = 5
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_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': None,
'listing_platform_dir': 'arm/',
'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',
'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',
'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 = {
'android_webview': 'AndroidWebview.apk',
'system_webview_google': 'SystemWebViewGoogle.apk',
'system_webview': 'SystemWebView.apk',
}
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',
}
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
"""
stdout = RunGsutilCommand(['ls', '-l', *urls], ignore_fail=ignore_fail)
lines = []
for line in stdout.splitlines():
parts = line.split(maxsplit=2)
if not parts[-1].startswith('gs://'):
continue
if len(parts) > 1:
if ANDROID_INVALID_BUCKET in line:
continue
size = int(parts[0])
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'):
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[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}.')
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
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
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."""
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:
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 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)
rev_list = [x for x in rev_list_all if min_rev <= x <= max_rev]
if len(rev_list) < 2:
rev_list_min, rev_list_max = rev_list_all[0], rev_list_all[-1]
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)
if min_rev > rev_list_max:
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)
msg = 'We don\'t have enough builds to bisect. rev_list: %s' % rev_list
raise BisectException(msg)
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):
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)
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):
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]))
build_numbers = [
x for x in build_numbers
if (not min_rev or min_rev <= x) and (not max_rev or x <= max_rev)
]
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()
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):
super()._save_rev_list_cache([str(x) for x in revisions])
def _load_rev_list_cache(self):
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)
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):
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)
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]
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
revisions = []
revision_re = re.compile(r'(\d+)')
all_prefixes = document.findall(namespace + 'CommonPrefixes/' + namespace +
'Prefix')
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):
if not max_rev:
max_rev = GetChromiumRevision(self.get_last_change_url())
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()
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))
]
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
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."""
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):
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())
return self.base_url + '/?delimiter=.zip&prefix=' + prefix + marker_param
def _get_marker_for_revision(self, revision):
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
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
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):
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):
InstallOnAndroid(self.device, download)
class AndroidTrichromeReleaseBuild(AndroidTrichromeMixin, AndroidReleaseBuild):
def __init__(self, options):
super().__init__(options)
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):
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.")
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()
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
return (f'{self._get_release_bucket()}/{build_number}/*/'
f'{self.listing_platform_dir.rstrip("/")}/*/{archive_name}')
def _install_revision(self, download, tempdir):
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}')
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
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']
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}')
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':
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)
if not os.path.isdir(directory):
os.mkdir(directory)
os.chdir(directory)
if tarfile.is_tarfile(filename):
tf = tarfile.open(filename, 'r')
tf.extractall(directory)
os.chdir(cwd)
return
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)
for info in zf.infolist():
name = info.filename
if name.endswith('/'):
if not os.path.isdir(name):
os.makedirs(name)
else:
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()
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
with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
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()
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)
answer = evaluate(revision, exit_status, stdout, stderr)
if answer != 'r':
return answer
def AskIsGoodBuild(rev, exit_status, stdout, stderr):
"""Asks the user whether build |rev| is good or bad."""
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
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:
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:
progress = 'Received %d bytes' % size
else:
size = min(totalsize, size)
progress = 'Received %d of %d bytes, %.2f%%' % (size, totalsize,
100.0 * size / totalsize)
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():
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()
try:
while self.thread.is_alive():
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()
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:
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))
for key in list(prefetch.keys()):
if key not in rev_list:
prefetch.pop(key).stop()
if prefetch:
fetch = None
pivot = len(rev_list)
for revision, pfetch in prefetch.items():
prefetch_pivot = rev_list.index(revision)
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])
else:
pivot = len(rev_list) // 2
fetch = archive_build.get_download_job(rev_list[pivot], 'fetch').start()
try:
download = fetch.wait_for()
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()
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()
answer = EvaluateRevision(archive_build, download, rev_list[pivot],
try_args, evaluate)
if answer == 'g':
rev_list = rev_list[pivot:]
elif answer == 'b':
rev_list = rev_list[:pivot + 1]
elif answer == 'u':
rev_list.pop(pivot)
else:
assert False, 'Unexpected return value from evaluate(): ' + answer
finally:
fetch.stop()
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:
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"""
for i in range(3):
try:
data = urllib.request.urlopen(url).read()
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"""
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:
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:
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:]))
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
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')
if options.archive.startswith('android-'):
SetupAndroidEnvironment()
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
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'
}
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)
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',
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',
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')]
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'
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-'):
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.
"""
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
new_opts.build_type = 'official'
new_opts.verify_range = True
new_opts.good = str(good)
new_opts.bad = str(bad)
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:
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)
archive_build = create_archive_build(opts)
if opts.not_interactive:
evaluator = DidCommandSucceed
elif opts.build_type == 'asan':
evaluator = IsGoodASANBuild
else:
evaluator = AskIsGoodBuild
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
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())