xref: /aosp_15_r20/external/toolchain-utils/llvm_tools/llvm_simple_bisect.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
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