1#!/usr/bin/env python3 2# Copyright 2024 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Simple LLVM Bisection Script for use with the llvm-9999 ebuild. 7 8Example usage with `git bisect`: 9 10 cd path/to/llvm-project 11 git bisect good <GOOD_HASH> 12 git bisect bad <BAD_HASH> 13 git bisect run \ 14 path/to/llvm_tools/llvm_simple_bisect.py --reset-llvm \ 15 --test "emerge-atlas package" \ 16 --search-error "some error that I care about" 17""" 18 19import argparse 20import dataclasses 21import logging 22import os 23from pathlib import Path 24import subprocess 25import sys 26from typing import Optional, Text 27 28import chroot 29 30 31# Git Bisection exit codes 32EXIT_GOOD = 0 33EXIT_BAD = 1 34EXIT_SKIP = 125 35EXIT_ABORT = 255 36 37 38class AbortingException(Exception): 39 """A nonrecoverable error occurred which should not depend on the LLVM Hash. 40 41 In this case we will abort bisection unless --never-abort is set. 42 """ 43 44 45@dataclasses.dataclass(frozen=True) 46class CommandResult: 47 """Results a command""" 48 49 return_code: int 50 output: Text 51 52 def success(self) -> bool: 53 """Checks if command exited successfully.""" 54 return self.return_code == 0 55 56 def search(self, error_string: Text) -> bool: 57 """Checks if command has error_string in output.""" 58 return error_string in self.output 59 60 def exit_assert( 61 self, 62 error_string: Text, 63 llvm_hash: Text, 64 log_dir: Optional[Path] = None, 65 ): 66 """Exit program with error code based on result.""" 67 if self.success(): 68 decision, decision_str = EXIT_GOOD, "GOOD" 69 elif self.search(error_string): 70 if error_string: 71 logging.info("Found failure and output contained error_string") 72 decision, decision_str = EXIT_BAD, "BAD" 73 else: 74 if error_string: 75 logging.info( 76 "Found failure but error_string was not found in results." 77 ) 78 decision, decision_str = EXIT_SKIP, "SKIP" 79 80 logging.info("Completed bisection stage with: %s", decision_str) 81 if log_dir: 82 self.log_result(log_dir, llvm_hash, decision_str) 83 sys.exit(decision) 84 85 def log_result(self, log_dir: Path, llvm_hash: Text, decision: Text): 86 """Log command's output to `{log_dir}/{llvm_hash}.{decision}`. 87 88 Args: 89 log_dir: Path to the directory to use for log files 90 llvm_hash: LLVM Hash being tested 91 decision: GOOD, BAD, or SKIP decision returned for `git bisect` 92 """ 93 log_dir = Path(log_dir) 94 log_dir.mkdir(parents=True, exist_ok=True) 95 96 log_file = log_dir / f"{llvm_hash}.{decision}" 97 log_file.touch() 98 99 logging.info("Writing output logs to %s", log_file) 100 101 log_file.write_text(self.output, encoding="utf-8") 102 103 # Fix permissions since sometimes this script gets called with sudo 104 log_dir.chmod(0o666) 105 log_file.chmod(0o666) 106 107 108class LLVMRepo: 109 """LLVM Repository git and workon information.""" 110 111 REPO_PATH = Path("/mnt/host/source/src/third_party/llvm-project") 112 113 def __init__(self): 114 self.workon: Optional[bool] = None 115 116 def get_current_hash(self) -> Text: 117 try: 118 output = subprocess.check_output( 119 ["git", "rev-parse", "HEAD"], 120 cwd=self.REPO_PATH, 121 encoding="utf-8", 122 ) 123 output = output.strip() 124 except subprocess.CalledProcessError as e: 125 output = e.output 126 logging.error("Could not get current llvm hash") 127 raise AbortingException 128 return output 129 130 def set_workon(self, workon: bool): 131 """Toggle llvm-9999 mode on or off.""" 132 if self.workon == workon: 133 return 134 subcommand = "start" if workon else "stop" 135 try: 136 subprocess.check_call( 137 ["cros_workon", "--host", subcommand, "sys-devel/llvm"] 138 ) 139 except subprocess.CalledProcessError: 140 logging.exception("cros_workon could not be toggled for LLVM.") 141 raise AbortingException 142 self.workon = workon 143 144 def reset(self): 145 """Reset installed LLVM version.""" 146 logging.info("Reseting llvm to downloaded binary.") 147 self.set_workon(False) 148 files_to_rm = Path("/var/lib/portage/pkgs").glob("sys-*/*") 149 try: 150 subprocess.check_call( 151 ["sudo", "rm", "-f"] + [str(f) for f in files_to_rm] 152 ) 153 subprocess.check_call(["emerge", "-C", "llvm"]) 154 subprocess.check_call(["emerge", "-G", "llvm"]) 155 except subprocess.CalledProcessError: 156 logging.exception("LLVM could not be reset.") 157 raise AbortingException 158 159 def build(self, use_flags: Text) -> CommandResult: 160 """Build selected LLVM version.""" 161 logging.info( 162 "Building llvm with candidate hash. Use flags will be %s", use_flags 163 ) 164 self.set_workon(True) 165 try: 166 output = subprocess.check_output( 167 ["sudo", "emerge", "llvm"], 168 env={"USE": use_flags, **os.environ}, 169 encoding="utf-8", 170 stderr=subprocess.STDOUT, 171 ) 172 return_code = 0 173 except subprocess.CalledProcessError as e: 174 return_code = e.returncode 175 output = e.output 176 return CommandResult(return_code, output) 177 178 179def run_test(command: Text) -> CommandResult: 180 """Run test command and get a CommandResult.""" 181 logging.info("Running test command: %s", command) 182 result = subprocess.run( 183 command, 184 check=False, 185 encoding="utf-8", 186 shell=True, 187 stdout=subprocess.PIPE, 188 stderr=subprocess.STDOUT, 189 ) 190 logging.info("Test command returned with: %d", result.returncode) 191 return CommandResult(result.returncode, result.stdout) 192 193 194def get_use_flags( 195 use_debug: bool, use_lto: bool, error_on_patch_failure: bool 196) -> str: 197 """Get the USE flags for building LLVM.""" 198 use_flags = [] 199 if use_debug: 200 use_flags.append("debug") 201 if not use_lto: 202 use_flags.append("-thinlto") 203 use_flags.append("-llvm_pgo_use") 204 if not error_on_patch_failure: 205 use_flags.append("continue-on-patch-failure") 206 return " ".join(use_flags) 207 208 209def abort(never_abort: bool): 210 """Exit with EXIT_ABORT or else EXIT_SKIP if never_abort is set.""" 211 if never_abort: 212 logging.info( 213 "Would have aborted but --never-abort was set. " 214 "Completed bisection stage with: SKIP" 215 ) 216 sys.exit(EXIT_SKIP) 217 else: 218 logging.info("Completed bisection stage with: ABORT") 219 sys.exit(EXIT_ABORT) 220 221 222def get_args() -> argparse.Namespace: 223 parser = argparse.ArgumentParser( 224 description="Simple LLVM Bisection Script for use with llvm-9999." 225 ) 226 227 parser.add_argument( 228 "--never-abort", 229 action="store_true", 230 help=( 231 "Return SKIP (125) for unrecoverable hash-independent errors " 232 "instead of ABORT (255)." 233 ), 234 ) 235 parser.add_argument( 236 "--reset-llvm", 237 action="store_true", 238 help="Reset llvm with downloaded prebuilds before rebuilding", 239 ) 240 parser.add_argument( 241 "--skip-build", 242 action="store_true", 243 help="Don't build or reset llvm, even if --reset-llvm is set.", 244 ) 245 parser.add_argument( 246 "--use-debug", 247 action="store_true", 248 help="Build llvm with assertions enabled", 249 ) 250 parser.add_argument( 251 "--use-lto", 252 action="store_true", 253 help="Build llvm with thinlto and PGO. This will increase build times.", 254 ) 255 parser.add_argument( 256 "--error-on-patch-failure", 257 action="store_true", 258 help="Don't add continue-on-patch-failure to LLVM use flags.", 259 ) 260 261 test_group = parser.add_mutually_exclusive_group(required=True) 262 test_group.add_argument( 263 "--test-llvm-build", 264 action="store_true", 265 help="Bisect the llvm build instead of a test command/script.", 266 ) 267 test_group.add_argument( 268 "--test", help="Command to test (exp. 'emerge-atlas grpc')" 269 ) 270 271 parser.add_argument( 272 "--search-error", 273 default="", 274 help=( 275 "Search for an error string from test if test has nonzero exit " 276 "code. If test has a non-zero exit code but search string is not " 277 "found, git bisect SKIP will be used." 278 ), 279 ) 280 parser.add_argument( 281 "--log-dir", 282 help=( 283 "Save a log of each output to a directory. " 284 "Logs will be written to `{log_dir}/{llvm_hash}.{decision}`" 285 ), 286 ) 287 288 return parser.parse_args() 289 290 291def run(opts: argparse.Namespace): 292 # Validate path to Log dir. 293 log_dir = opts.log_dir 294 if log_dir: 295 log_dir = Path(log_dir) 296 if log_dir.exists() and not log_dir.is_dir(): 297 logging.error("argument --log-dir: Given path is not a directory!") 298 raise AbortingException() 299 300 # Get LLVM repo 301 llvm_repo = LLVMRepo() 302 llvm_hash = llvm_repo.get_current_hash() 303 logging.info("Testing LLVM Hash: %s", llvm_hash) 304 305 # Build LLVM 306 if not opts.skip_build: 307 308 # Get llvm USE flags. 309 use_flags = get_use_flags( 310 opts.use_debug, opts.use_lto, opts.error_on_patch_failure 311 ) 312 313 # Reset LLVM if needed. 314 if opts.reset_llvm: 315 llvm_repo.reset() 316 317 # Build new LLVM-9999. 318 build_result = llvm_repo.build(use_flags) 319 320 # Check LLVM-9999 build. 321 if opts.test_llvm_build: 322 logging.info("Checking result of build....") 323 build_result.exit_assert(opts.search_error, llvm_hash, opts.log_dir) 324 elif build_result.success(): 325 logging.info("LLVM candidate built successfully.") 326 else: 327 logging.error("LLVM could not be built.") 328 logging.info("Completed bisection stage with: SKIP.") 329 sys.exit(EXIT_SKIP) 330 331 # Run Test Command. 332 test_result = run_test(opts.test) 333 logging.info("Checking result of test command....") 334 test_result.exit_assert(opts.search_error, llvm_hash, log_dir) 335 336 337def main(): 338 logging.basicConfig(level=logging.INFO) 339 chroot.VerifyInsideChroot() 340 opts = get_args() 341 try: 342 run(opts) 343 except AbortingException: 344 abort(opts.never_abort) 345 except Exception: 346 logging.exception("Uncaught Exception in main") 347 abort(opts.never_abort) 348 349 350if __name__ == "__main__": 351 main() 352