| #!/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()) |