diff options
Diffstat (limited to 'llvm_tools/llvm_simple_bisect.py')
-rwxr-xr-x | llvm_tools/llvm_simple_bisect.py | 351 |
1 files changed, 351 insertions, 0 deletions
diff --git a/llvm_tools/llvm_simple_bisect.py b/llvm_tools/llvm_simple_bisect.py new file mode 100755 index 00000000..433fec77 --- /dev/null +++ b/llvm_tools/llvm_simple_bisect.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +# Copyright 2024 The ChromiumOS Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Simple LLVM Bisection Script for use with the llvm-9999 ebuild. + +Example usage with `git bisect`: + + cd path/to/llvm-project + git bisect good <GOOD_HASH> + git bisect bad <BAD_HASH> + git bisect run \ + path/to/llvm_tools/llvm_simple_bisect.py --reset-llvm \ + --test "emerge-atlas package" \ + --search-error "some error that I care about" +""" + +import argparse +import dataclasses +import logging +import os +from pathlib import Path +import subprocess +import sys +from typing import Optional, Text + +import chroot + + +# Git Bisection exit codes +EXIT_GOOD = 0 +EXIT_BAD = 1 +EXIT_SKIP = 125 +EXIT_ABORT = 255 + + +class AbortingException(Exception): + """A nonrecoverable error occurred which should not depend on the LLVM Hash. + + In this case we will abort bisection unless --never-abort is set. + """ + + +@dataclasses.dataclass(frozen=True) +class CommandResult: + """Results a command""" + + return_code: int + output: Text + + def success(self) -> bool: + """Checks if command exited successfully.""" + return self.return_code == 0 + + def search(self, error_string: Text) -> bool: + """Checks if command has error_string in output.""" + return error_string in self.output + + def exit_assert( + self, + error_string: Text, + llvm_hash: Text, + log_dir: Optional[Path] = None, + ): + """Exit program with error code based on result.""" + if self.success(): + decision, decision_str = EXIT_GOOD, "GOOD" + elif self.search(error_string): + if error_string: + logging.info("Found failure and output contained error_string") + decision, decision_str = EXIT_BAD, "BAD" + else: + if error_string: + logging.info( + "Found failure but error_string was not found in results." + ) + decision, decision_str = EXIT_SKIP, "SKIP" + + logging.info("Completed bisection stage with: %s", decision_str) + if log_dir: + self.log_result(log_dir, llvm_hash, decision_str) + sys.exit(decision) + + def log_result(self, log_dir: Path, llvm_hash: Text, decision: Text): + """Log command's output to `{log_dir}/{llvm_hash}.{decision}`. + + Args: + log_dir: Path to the directory to use for log files + llvm_hash: LLVM Hash being tested + decision: GOOD, BAD, or SKIP decision returned for `git bisect` + """ + log_dir = Path(log_dir) + log_dir.mkdir(parents=True, exist_ok=True) + + log_file = log_dir / f"{llvm_hash}.{decision}" + log_file.touch() + + logging.info("Writing output logs to %s", log_file) + + log_file.write_text(self.output, encoding="utf-8") + + # Fix permissions since sometimes this script gets called with sudo + log_dir.chmod(0o666) + log_file.chmod(0o666) + + +class LLVMRepo: + """LLVM Repository git and workon information.""" + + REPO_PATH = Path("/mnt/host/source/src/third_party/llvm-project") + + def __init__(self): + self.workon: Optional[bool] = None + + def get_current_hash(self) -> Text: + try: + output = subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=self.REPO_PATH, + encoding="utf-8", + ) + output = output.strip() + except subprocess.CalledProcessError as e: + output = e.output + logging.error("Could not get current llvm hash") + raise AbortingException + return output + + def set_workon(self, workon: bool): + """Toggle llvm-9999 mode on or off.""" + if self.workon == workon: + return + subcommand = "start" if workon else "stop" + try: + subprocess.check_call( + ["cros_workon", "--host", subcommand, "sys-devel/llvm"] + ) + except subprocess.CalledProcessError: + logging.exception("cros_workon could not be toggled for LLVM.") + raise AbortingException + self.workon = workon + + def reset(self): + """Reset installed LLVM version.""" + logging.info("Reseting llvm to downloaded binary.") + self.set_workon(False) + files_to_rm = Path("/var/lib/portage/pkgs").glob("sys-*/*") + try: + subprocess.check_call( + ["sudo", "rm", "-f"] + [str(f) for f in files_to_rm] + ) + subprocess.check_call(["emerge", "-C", "llvm"]) + subprocess.check_call(["emerge", "-G", "llvm"]) + except subprocess.CalledProcessError: + logging.exception("LLVM could not be reset.") + raise AbortingException + + def build(self, use_flags: Text) -> CommandResult: + """Build selected LLVM version.""" + logging.info( + "Building llvm with candidate hash. Use flags will be %s", use_flags + ) + self.set_workon(True) + try: + output = subprocess.check_output( + ["sudo", "emerge", "llvm"], + env={"USE": use_flags, **os.environ}, + encoding="utf-8", + stderr=subprocess.STDOUT, + ) + return_code = 0 + except subprocess.CalledProcessError as e: + return_code = e.returncode + output = e.output + return CommandResult(return_code, output) + + +def run_test(command: Text) -> CommandResult: + """Run test command and get a CommandResult.""" + logging.info("Running test command: %s", command) + result = subprocess.run( + command, + check=False, + encoding="utf-8", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + logging.info("Test command returned with: %d", result.returncode) + return CommandResult(result.returncode, result.stdout) + + +def get_use_flags( + use_debug: bool, use_lto: bool, error_on_patch_failure: bool +) -> str: + """Get the USE flags for building LLVM.""" + use_flags = [] + if use_debug: + use_flags.append("debug") + if not use_lto: + use_flags.append("-thinlto") + use_flags.append("-llvm_pgo_use") + if not error_on_patch_failure: + use_flags.append("continue-on-patch-failure") + return " ".join(use_flags) + + +def abort(never_abort: bool): + """Exit with EXIT_ABORT or else EXIT_SKIP if never_abort is set.""" + if never_abort: + logging.info( + "Would have aborted but --never-abort was set. " + "Completed bisection stage with: SKIP" + ) + sys.exit(EXIT_SKIP) + else: + logging.info("Completed bisection stage with: ABORT") + sys.exit(EXIT_ABORT) + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Simple LLVM Bisection Script for use with llvm-9999." + ) + + parser.add_argument( + "--never-abort", + action="store_true", + help=( + "Return SKIP (125) for unrecoverable hash-independent errors " + "instead of ABORT (255)." + ), + ) + parser.add_argument( + "--reset-llvm", + action="store_true", + help="Reset llvm with downloaded prebuilds before rebuilding", + ) + parser.add_argument( + "--skip-build", + action="store_true", + help="Don't build or reset llvm, even if --reset-llvm is set.", + ) + parser.add_argument( + "--use-debug", + action="store_true", + help="Build llvm with assertions enabled", + ) + parser.add_argument( + "--use-lto", + action="store_true", + help="Build llvm with thinlto and PGO. This will increase build times.", + ) + parser.add_argument( + "--error-on-patch-failure", + action="store_true", + help="Don't add continue-on-patch-failure to LLVM use flags.", + ) + + test_group = parser.add_mutually_exclusive_group(required=True) + test_group.add_argument( + "--test-llvm-build", + action="store_true", + help="Bisect the llvm build instead of a test command/script.", + ) + test_group.add_argument( + "--test", help="Command to test (exp. 'emerge-atlas grpc')" + ) + + parser.add_argument( + "--search-error", + default="", + help=( + "Search for an error string from test if test has nonzero exit " + "code. If test has a non-zero exit code but search string is not " + "found, git bisect SKIP will be used." + ), + ) + parser.add_argument( + "--log-dir", + help=( + "Save a log of each output to a directory. " + "Logs will be written to `{log_dir}/{llvm_hash}.{decision}`" + ), + ) + + return parser.parse_args() + + +def run(opts: argparse.Namespace): + # Validate path to Log dir. + log_dir = opts.log_dir + if log_dir: + log_dir = Path(log_dir) + if log_dir.exists() and not log_dir.is_dir(): + logging.error("argument --log-dir: Given path is not a directory!") + raise AbortingException() + + # Get LLVM repo + llvm_repo = LLVMRepo() + llvm_hash = llvm_repo.get_current_hash() + logging.info("Testing LLVM Hash: %s", llvm_hash) + + # Build LLVM + if not opts.skip_build: + + # Get llvm USE flags. + use_flags = get_use_flags( + opts.use_debug, opts.use_lto, opts.error_on_patch_failure + ) + + # Reset LLVM if needed. + if opts.reset_llvm: + llvm_repo.reset() + + # Build new LLVM-9999. + build_result = llvm_repo.build(use_flags) + + # Check LLVM-9999 build. + if opts.test_llvm_build: + logging.info("Checking result of build....") + build_result.exit_assert(opts.search_error, llvm_hash, opts.log_dir) + elif build_result.success(): + logging.info("LLVM candidate built successfully.") + else: + logging.error("LLVM could not be built.") + logging.info("Completed bisection stage with: SKIP.") + sys.exit(EXIT_SKIP) + + # Run Test Command. + test_result = run_test(opts.test) + logging.info("Checking result of test command....") + test_result.exit_assert(opts.search_error, llvm_hash, log_dir) + + +def main(): + logging.basicConfig(level=logging.INFO) + chroot.VerifyInsideChroot() + opts = get_args() + try: + run(opts) + except AbortingException: + abort(opts.never_abort) + except Exception: + logging.exception("Uncaught Exception in main") + abort(opts.never_abort) + + +if __name__ == "__main__": + main() |