| #!/usr/bin/env python |
| # |
| # Given a previous good compile narrow down miscompiles. |
| # Expects two directories named "before" and "after" each containing a set of |
| # assembly or object files where the "after" version is assumed to be broken. |
| # You also have to provide a script called "link_test". It is called with a |
| # list of files which should be linked together and result tested. "link_test" |
| # should returns with exitcode 0 if the linking and testing succeeded. |
| # |
| # If a response file is provided, only the object files that are listed in the |
| # file are inspected. In addition, the "link_test" is called with a temporary |
| # response file representing one iteration of bisection. |
| # |
| # abtest.py operates by taking all files from the "before" directory and |
| # in each step replacing one of them with a file from the "bad" directory. |
| # |
| # Additionally you can perform the same steps with a single .s file. In this |
| # mode functions are identified by " -- Begin function FunctionName" and |
| # " -- End function" markers. The abtest.py then takes all |
| # function from the file in the "before" directory and replaces one function |
| # with the corresponding function from the "bad" file in each step. |
| # |
| # Example usage to identify miscompiled files: |
| # 1. Create a link_test script, make it executable. Simple Example: |
| # clang "$@" -o /tmp/test && /tmp/test || echo "PROBLEM" |
| # 2. Run the script to figure out which files are miscompiled: |
| # > ./abtest.py |
| # somefile.s: ok |
| # someotherfile.s: skipped: same content |
| # anotherfile.s: failed: './link_test' exitcode != 0 |
| # ... |
| # Example usage to identify miscompiled functions inside a file: |
| # 3. Run the tests on a single file (assuming before/file.s and |
| # after/file.s exist) |
| # > ./abtest.py file.s |
| # funcname1 [0/XX]: ok |
| # funcname2 [1/XX]: ok |
| # funcname3 [2/XX]: skipped: same content |
| # funcname4 [3/XX]: failed: './link_test' exitcode != 0 |
| # ... |
| from fnmatch import filter |
| from sys import stderr |
| import argparse |
| import filecmp |
| import os |
| import subprocess |
| import sys |
| import tempfile |
| |
| # Specify LINKTEST via `--test`. Default value is './link_test'. |
| LINKTEST = "" |
| ESCAPE = "\033[%sm" |
| BOLD = ESCAPE % "1" |
| RED = ESCAPE % "31" |
| NORMAL = ESCAPE % "0" |
| FAILED = RED + "failed" + NORMAL |
| |
| |
| def find(dir, file_filter=None): |
| files = [walkdir[0] + "/" + file for walkdir in os.walk(dir) for file in walkdir[2]] |
| if file_filter is not None: |
| files = filter(files, file_filter) |
| return sorted(files) |
| |
| |
| def error(message): |
| stderr.write("Error: %s\n" % (message,)) |
| |
| |
| def warn(message): |
| stderr.write("Warning: %s\n" % (message,)) |
| |
| |
| def info(message): |
| stderr.write("Info: %s\n" % (message,)) |
| |
| |
| def announce_test(name): |
| stderr.write("%s%s%s: " % (BOLD, name, NORMAL)) |
| stderr.flush() |
| |
| |
| def announce_result(result): |
| stderr.write(result) |
| stderr.write("\n") |
| stderr.flush() |
| |
| |
| def format_namelist(l): |
| result = ", ".join(l[0:3]) |
| if len(l) > 3: |
| result += "... (%d total)" % len(l) |
| return result |
| |
| |
| def check_sanity(choices, perform_test): |
| announce_test("sanity check A") |
| all_a = {name: a_b[0] for name, a_b in choices} |
| res_a = perform_test(all_a) |
| if res_a is not True: |
| error("Picking all choices from A failed to pass the test") |
| sys.exit(1) |
| |
| announce_test("sanity check B (expecting failure)") |
| all_b = {name: a_b[1] for name, a_b in choices} |
| res_b = perform_test(all_b) |
| if res_b is not False: |
| error("Picking all choices from B did unexpectedly pass the test") |
| sys.exit(1) |
| |
| |
| def check_sequentially(choices, perform_test): |
| known_good = set() |
| all_a = {name: a_b[0] for name, a_b in choices} |
| n = 1 |
| for name, a_b in sorted(choices): |
| picks = dict(all_a) |
| picks[name] = a_b[1] |
| announce_test("checking %s [%d/%d]" % (name, n, len(choices))) |
| n += 1 |
| res = perform_test(picks) |
| if res is True: |
| known_good.add(name) |
| return known_good |
| |
| |
| def check_bisect(choices, perform_test): |
| known_good = set() |
| if len(choices) == 0: |
| return known_good |
| |
| choice_map = dict(choices) |
| all_a = {name: a_b[0] for name, a_b in choices} |
| |
| def test_partition(partition, upcoming_partition): |
| # Compute the maximum number of checks we have to do in the worst case. |
| max_remaining_steps = len(partition) * 2 - 1 |
| if upcoming_partition is not None: |
| max_remaining_steps += len(upcoming_partition) * 2 - 1 |
| for x in partitions_to_split: |
| max_remaining_steps += (len(x) - 1) * 2 |
| |
| picks = dict(all_a) |
| for x in partition: |
| picks[x] = choice_map[x][1] |
| announce_test( |
| "checking %s [<=%d remaining]" |
| % (format_namelist(partition), max_remaining_steps) |
| ) |
| res = perform_test(picks) |
| if res is True: |
| known_good.update(partition) |
| elif len(partition) > 1: |
| partitions_to_split.insert(0, partition) |
| |
| # TODO: |
| # - We could optimize based on the knowledge that when splitting a failed |
| # partition into two and one side checks out okay then we can deduce that |
| # the other partition must be a failure. |
| all_choice_names = [name for name, _ in choices] |
| partitions_to_split = [all_choice_names] |
| while len(partitions_to_split) > 0: |
| partition = partitions_to_split.pop() |
| |
| middle = len(partition) // 2 |
| left = partition[0:middle] |
| right = partition[middle:] |
| |
| if len(left) > 0: |
| test_partition(left, right) |
| assert len(right) > 0 |
| test_partition(right, None) |
| |
| return known_good |
| |
| |
| def extract_functions(file): |
| functions = [] |
| in_function = None |
| for line in open(file): |
| marker = line.find(" -- Begin function ") |
| if marker != -1: |
| if in_function is not None: |
| warn("Missing end of function %s" % (in_function,)) |
| funcname = line[marker + 19 : -1] |
| in_function = funcname |
| text = line |
| continue |
| |
| marker = line.find(" -- End function") |
| if marker != -1: |
| text += line |
| functions.append((in_function, text)) |
| in_function = None |
| continue |
| |
| if in_function is not None: |
| text += line |
| return functions |
| |
| |
| def replace_functions(source, dest, replacements): |
| out = open(dest, "w") |
| skip = False |
| in_function = None |
| for line in open(source): |
| marker = line.find(" -- Begin function ") |
| if marker != -1: |
| if in_function is not None: |
| warn("Missing end of function %s" % (in_function,)) |
| funcname = line[marker + 19 : -1] |
| in_function = funcname |
| replacement = replacements.get(in_function) |
| if replacement is not None: |
| out.write(replacement) |
| skip = True |
| else: |
| marker = line.find(" -- End function") |
| if marker != -1: |
| in_function = None |
| if skip: |
| skip = False |
| continue |
| |
| if not skip: |
| out.write(line) |
| |
| |
| def testrun(files): |
| linkline = "%s %s" % ( |
| LINKTEST, |
| " ".join(files), |
| ) |
| res = subprocess.call(linkline, shell=True) |
| if res != 0: |
| announce_result(FAILED + ": '%s' exitcode != 0" % LINKTEST) |
| return False |
| else: |
| announce_result("ok") |
| return True |
| |
| |
| def prepare_files(gooddir, baddir, rspfile): |
| files_a = [] |
| files_b = [] |
| |
| if rspfile is not None: |
| |
| def get_basename(name): |
| # remove prefix |
| if name.startswith(gooddir): |
| return name[len(gooddir) :] |
| if name.startswith(baddir): |
| return name[len(baddir) :] |
| assert False, "" |
| |
| with open(rspfile, "r") as rf: |
| for line in rf.read().splitlines(): |
| for obj in line.split(): |
| assert not os.path.isabs(obj), "TODO: support abs path" |
| files_a.append(gooddir + "/" + obj) |
| files_b.append(baddir + "/" + obj) |
| else: |
| get_basename = lambda name: os.path.basename(name) |
| files_a = find(gooddir, "*") |
| files_b = find(baddir, "*") |
| |
| basenames_a = set(map(get_basename, files_a)) |
| basenames_b = set(map(get_basename, files_b)) |
| |
| for name in files_b: |
| basename = get_basename(name) |
| if basename not in basenames_a: |
| warn("There is no corresponding file to '%s' in %s" % (name, gooddir)) |
| choices = [] |
| skipped = [] |
| for name in files_a: |
| basename = get_basename(name) |
| if basename not in basenames_b: |
| warn("There is no corresponding file to '%s' in %s" % (name, baddir)) |
| |
| file_a = gooddir + "/" + basename |
| file_b = baddir + "/" + basename |
| if filecmp.cmp(file_a, file_b): |
| skipped.append(basename) |
| continue |
| |
| choice = (basename, (file_a, file_b)) |
| choices.append(choice) |
| |
| if len(skipped) > 0: |
| info("Skipped (same content): %s" % format_namelist(skipped)) |
| |
| def perform_test(picks): |
| files = [] |
| # Note that we iterate over files_a so we don't change the order |
| # (cannot use `picks` as it is a dictionary without order) |
| for x in files_a: |
| basename = get_basename(x) |
| picked = picks.get(basename) |
| if picked is None: |
| assert basename in skipped |
| files.append(x) |
| else: |
| files.append(picked) |
| |
| # If response file is used, create a temporary response file for the |
| # picked files. |
| if rspfile is not None: |
| with tempfile.NamedTemporaryFile("w", suffix=".rsp", delete=False) as tf: |
| tf.write(" ".join(files)) |
| tf.flush() |
| ret = testrun([tf.name]) |
| os.remove(tf.name) |
| return ret |
| |
| return testrun(files) |
| |
| return perform_test, choices |
| |
| |
| def prepare_functions(to_check, gooddir, goodfile, badfile): |
| files_good = find(gooddir, "*") |
| |
| functions_a = extract_functions(goodfile) |
| functions_a_map = dict(functions_a) |
| functions_b_map = dict(extract_functions(badfile)) |
| |
| for name in functions_b_map.keys(): |
| if name not in functions_a_map: |
| warn("Function '%s' missing from good file" % name) |
| choices = [] |
| skipped = [] |
| for name, candidate_a in functions_a: |
| candidate_b = functions_b_map.get(name) |
| if candidate_b is None: |
| warn("Function '%s' missing from bad file" % name) |
| continue |
| if candidate_a == candidate_b: |
| skipped.append(name) |
| continue |
| choice = name, (candidate_a, candidate_b) |
| choices.append(choice) |
| |
| if len(skipped) > 0: |
| info("Skipped (same content): %s" % format_namelist(skipped)) |
| |
| combined_file = "/tmp/combined2.s" |
| files = [] |
| found_good_file = False |
| for c in files_good: |
| if os.path.basename(c) == to_check: |
| found_good_file = True |
| files.append(combined_file) |
| continue |
| files.append(c) |
| assert found_good_file |
| |
| def perform_test(picks): |
| for name, x in picks.items(): |
| assert x == functions_a_map[name] or x == functions_b_map[name] |
| replace_functions(goodfile, combined_file, picks) |
| return testrun(files) |
| |
| return perform_test, choices |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--a", dest="dir_a", default="before") |
| parser.add_argument("--b", dest="dir_b", default="after") |
| parser.add_argument("--rsp", default=None) |
| parser.add_argument("--test", default="./link_test") |
| parser.add_argument("--insane", help="Skip sanity check", action="store_true") |
| parser.add_argument( |
| "--seq", help="Check sequentially instead of bisection", action="store_true" |
| ) |
| parser.add_argument("file", metavar="file", nargs="?") |
| config = parser.parse_args() |
| |
| gooddir = config.dir_a |
| baddir = config.dir_b |
| rspfile = config.rsp |
| global LINKTEST |
| LINKTEST = config.test |
| |
| # Preparation phase: Creates a dictionary mapping names to a list of two |
| # choices each. The bisection algorithm will pick one choice for each name |
| # and then run the perform_test function on it. |
| if config.file is not None: |
| goodfile = gooddir + "/" + config.file |
| badfile = baddir + "/" + config.file |
| perform_test, choices = prepare_functions( |
| config.file, gooddir, goodfile, badfile |
| ) |
| else: |
| perform_test, choices = prepare_files(gooddir, baddir, rspfile) |
| |
| info("%d bisection choices" % len(choices)) |
| |
| # "Checking whether build environment is sane ..." |
| if not config.insane: |
| if not os.access(LINKTEST, os.X_OK): |
| error("Expect '%s' to be present and executable" % (LINKTEST,)) |
| exit(1) |
| |
| check_sanity(choices, perform_test) |
| |
| if config.seq: |
| known_good = check_sequentially(choices, perform_test) |
| else: |
| known_good = check_bisect(choices, perform_test) |
| |
| stderr.write("") |
| if len(known_good) != len(choices): |
| stderr.write("== Failing ==\n") |
| for name, _ in choices: |
| if name not in known_good: |
| stderr.write("%s\n" % name) |
| else: |
| # This shouldn't happen when the sanity check works... |
| # Maybe link_test isn't deterministic? |
| stderr.write("Could not identify failing parts?!?") |
| |
| |
| if __name__ == "__main__": |
| main() |