aboutsummaryrefslogtreecommitdiff
path: root/llvm_tools/llvm_simple_bisect.py
blob: 433fec77c7e9f92bcc56bf0842a26dc5f2590f82 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
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()