|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Breakpad crash test script |
| 3 | +
|
| 4 | +Do an end-to-end test of the Breakpad crash dump and stack trace workflow: |
| 5 | +- Generate symbols from binary debug information (or use the distributed symbol |
| 6 | + zips in the case of a release) |
| 7 | +- Run Daemon and trigger a crash using /injectFault which is dumped by Breakpad |
| 8 | + or the NaCl minidump code |
| 9 | +- Run minidump_stackwalk to print the stack trace |
| 10 | +- Verify that function and file names are present in the stack trace |
| 11 | +""" |
| 12 | + |
| 13 | +import argparse |
| 14 | +import os |
| 15 | +import shutil |
| 16 | +import subprocess |
| 17 | +import sys |
| 18 | +import traceback |
| 19 | +import zipfile |
| 20 | + |
| 21 | +if os.name == "nt": |
| 22 | + EXE = '.exe' |
| 23 | +else: |
| 24 | + EXE = "" |
| 25 | + |
| 26 | +def PathJoin(*paths): |
| 27 | + p = os.path.join(*paths) |
| 28 | + if EXE: |
| 29 | + p = p.replace("\\", "/") # for WSL |
| 30 | + return p |
| 31 | + |
| 32 | +class Test: |
| 33 | + def __init__(self, name): |
| 34 | + self.name = name |
| 35 | + |
| 36 | + def Begin(self): |
| 37 | + self.status = "PASSED" |
| 38 | + print(f"===RUNNING: {self.name}===") |
| 39 | + |
| 40 | + def End(self): |
| 41 | + print(f"==={self.status}: {self.name}===") |
| 42 | + |
| 43 | + def Verify(self, cond, reason=None): |
| 44 | + if not cond: |
| 45 | + if reason is not None: |
| 46 | + print(f"FAILURE: {reason}") |
| 47 | + self.status = "FAILED" |
| 48 | + if GIVE_UP: |
| 49 | + raise Exception("Giving up on first failure") |
| 50 | + |
| 51 | + def Go(self): |
| 52 | + self.Begin() |
| 53 | + try: |
| 54 | + self.Do() |
| 55 | + except Exception as e: |
| 56 | + traceback.print_exception(e) |
| 57 | + self.status = "FAILED" |
| 58 | + self.End() |
| 59 | + return self.status == "PASSED" |
| 60 | + |
| 61 | +class BreakpadCrashTest(Test): |
| 62 | + def __init__(self, module, engine, tprefix, fault): |
| 63 | + super().__init__(module + "." + fault) |
| 64 | + self.engine = engine |
| 65 | + self.tprefix = tprefix |
| 66 | + self.fault = fault |
| 67 | + self.dir = PathJoin(TEMP_DIR, self.name) |
| 68 | + try: |
| 69 | + shutil.rmtree(self.dir) |
| 70 | + except FileNotFoundError: |
| 71 | + pass |
| 72 | + os.makedirs(self.dir) |
| 73 | + |
| 74 | + def Do(self): |
| 75 | + vmtype = "0" if SYMBOL_ZIPS else "1" |
| 76 | + print("Running daemon...") |
| 77 | + p = subprocess.run([self.engine, |
| 78 | + "-noforward", # catch stupid Linux persistent sockets (if gamelogic test...) |
| 79 | + "-homepath", self.dir, |
| 80 | + "-set", "vm.sgame.type", vmtype, |
| 81 | + "-set", "vm.cgame.type", vmtype, |
| 82 | + "-set", "sv_fps", "1000", |
| 83 | + "-set", "common.framerate.max", "0", |
| 84 | + "-set", "client.errorPopup", "0", |
| 85 | + "-set", "server.private", "2", |
| 86 | + "-set", "net_enabled", "0", |
| 87 | + "-set", "common.framerate.max", "0", |
| 88 | + "-set", "cg_navgenOnLoad", "0", |
| 89 | + *DAEMON_USER_ARGS, |
| 90 | + *["+devmap plat23"] * (self.tprefix == "sgame."), |
| 91 | + "+delay 20f echo CRASHTEST_BEGIN", |
| 92 | + f"+delay 20f {self.tprefix}injectFault", self.fault, |
| 93 | + "+delay 20f echo CRASHTEST_END", |
| 94 | + "+delay 40f quit"], |
| 95 | + stderr=subprocess.PIPE, check=bool(self.tprefix)) |
| 96 | + dumps = os.listdir(PathJoin(self.dir, "crashdump")) |
| 97 | + assert len(dumps) == 1, dumps |
| 98 | + dump = PathJoin(self.dir, "crashdump", dumps[0]) |
| 99 | + sw_out = PathJoin(TEMP_DIR, self.name + "_stackwalk.log") |
| 100 | + with open(sw_out, "a+") as sw_f: |
| 101 | + print(f"Extracting stack trace to '{sw_out}'...") |
| 102 | + sw_f.truncate() |
| 103 | + subprocess.run(Virtualize([PathJoin(BREAKPAD_DIR, "src/processor/minidump_stackwalk"), dump, SYMBOL_DIR]), check=True, stdout=sw_f, stderr=subprocess.STDOUT) |
| 104 | + sw_f.seek(0) |
| 105 | + sw = sw_f.read() |
| 106 | + TRACE_FUNC = "InjectFaultCmd::Run" |
| 107 | + self.Verify(TRACE_FUNC in sw, "function names not found in trace (did you build with symbols?)") |
| 108 | + |
| 109 | +def Virtualize(cmdline): |
| 110 | + bin, *args = cmdline |
| 111 | + if EXE: |
| 112 | + bin2 = bin.replace("\\", "/") |
| 113 | + if bin2.startswith("//wsl.localhost/"): |
| 114 | + parts = bin2.split("/") |
| 115 | + vm = parts[3] |
| 116 | + path = "/" + "/".join(parts[4:]) |
| 117 | + return ["wsl", "-d", vm, "--", path] + args |
| 118 | + elif bin.endswith(".py"): |
| 119 | + return [sys.executable] + cmdline |
| 120 | + return cmdline |
| 121 | + |
| 122 | +def ModulePath(module): |
| 123 | + base = { |
| 124 | + "dummyapp" : "dummyapp" + EXE, |
| 125 | + "server": "daemonded" + EXE, |
| 126 | + "ttyclient": "daemon-tty" + EXE, |
| 127 | + "client": "daemon" + EXE, |
| 128 | + "sgame": f"sgame-{NACL_ARCH}.nexe", |
| 129 | + "cgame": f"cgame-{NACL_ARCH}.nexe", |
| 130 | + }[module] |
| 131 | + return PathJoin(GAME_DIR, base) |
| 132 | + |
| 133 | +class ModuleCrashTests(Test): |
| 134 | + def __init__(self, module, engine=None): |
| 135 | + super().__init__(module) |
| 136 | + self.engine = engine |
| 137 | + |
| 138 | + def Do(self): |
| 139 | + module = self.name |
| 140 | + if module == "sgame": |
| 141 | + eng = self.engine or "server" |
| 142 | + tprefix = "sgame." |
| 143 | + elif module == "cgame": |
| 144 | + tprefix = "cgame." |
| 145 | + eng = self.engine or "ttyclient" |
| 146 | + else: |
| 147 | + tprefix = "" |
| 148 | + eng = module |
| 149 | + engine = ModulePath(eng) |
| 150 | + assert os.path.isfile(engine), engine |
| 151 | + |
| 152 | + if not SYMBOL_ZIPS: |
| 153 | + target = ModulePath(module) |
| 154 | + assert os.path.isfile(target), target |
| 155 | + print(f"Symbolizing '{target}'...") |
| 156 | + subprocess.check_call(Virtualize([PathJoin(BREAKPAD_DIR, "symbolize.py"), |
| 157 | + "--symbol-directory", SYMBOL_DIR, target])) |
| 158 | + |
| 159 | + self.Verify(BreakpadCrashTest(module, engine, tprefix, "segfault").Go()) |
| 160 | + if tprefix or not EXE: |
| 161 | + # apparently abort() is caught on Linux but not Windows |
| 162 | + self.Verify(BreakpadCrashTest(module, engine, tprefix, "abort").Go()) |
| 163 | + if tprefix: |
| 164 | + self.Verify(BreakpadCrashTest(module, engine, tprefix, "exception").Go()) |
| 165 | + self.Verify(BreakpadCrashTest(module, engine, tprefix, "throw").Go()) |
| 166 | + |
| 167 | +def ArgParser(usage=None): |
| 168 | + ap = argparse.ArgumentParser( |
| 169 | + usage=usage, |
| 170 | + description="Verify that Breakpad toolchain can produce usable stack traces." |
| 171 | + " A Daemon build must be found in the current directory (or --game-dir). Also Breakpad's tools must be built in its source tree." |
| 172 | + " If a symbols zip is found in the current directory, enter release validation mode: prebuilt symbols are used and VM type defaults to 0 (NaCl from paks)." |
| 173 | + " Otherwise, enter end-to-end mode: symbols are produced from the binaries and VM type defaults to 1 (NaCl from PWD). In this mode you will likely need to provide pak paths via --daemon-args.") |
| 174 | + ap.add_argument("--game-dir", type=str, default=".", help="Path to Daemon (+ gamelogic) binaries") |
| 175 | + ap.add_argument("--breakpad-dir", type=str, default=BREAKPAD_DIR, help=r"Path to Breakpad repo containing built dump_syms and stackwalk binaries. It may be a \\wsl.localhost\ path on Windows hosts in order to symbolize NaCl.") |
| 176 | + ap.add_argument("--give-up", action="store_true", help="Stop after first test failure") |
| 177 | + ap.add_argument("--nacl-arch", type=str, choices=["amd64", "i686", "armhf"], default="amd64") # TODO auto-detect? |
| 178 | + ap.add_argument("module", nargs="*", |
| 179 | + default="server", # bogus default needed due to buggy argparse |
| 180 | + choices=["dummyapp", "server", "ttyclient", "client", |
| 181 | + "cgame", "ttyclient:cgame", "client:cgame", |
| 182 | + "sgame", "server:sgame", "ttyclient:sgame", "client:sgame"]) |
| 183 | + return ap |
| 184 | + |
| 185 | +BREAKPAD_DIR = os.path.abspath(PathJoin( |
| 186 | + os.path.dirname(os.path.realpath(__file__)), "../libs/breakpad")) |
| 187 | + |
| 188 | +ap = ArgParser( |
| 189 | + usage=ArgParser().format_usage().rstrip().removeprefix("usage: ") + " [--daemon-args ARGS...]") |
| 190 | +ap.add_argument("--daemon-args", nargs=argparse.REMAINDER, default=[], |
| 191 | + help="Extra arguments for Daemon (e.g. -pakpath)") |
| 192 | +pa = ap.parse_args(sys.argv[1:]) |
| 193 | +GAME_DIR = pa.game_dir |
| 194 | +BREAKPAD_DIR = pa.breakpad_dir |
| 195 | +GIVE_UP = pa.give_up |
| 196 | +DAEMON_USER_ARGS = pa.daemon_args |
| 197 | +NACL_ARCH = pa.nacl_arch |
| 198 | +SYMBOL_ZIPS = [p for p in os.listdir(GAME_DIR) if p.startswith("symbols") and p.endswith(".zip")] |
| 199 | +modules = pa.module |
| 200 | +if isinstance(modules, str): |
| 201 | + modules = ["server", "ttyclient", "sgame", "cgame"] |
| 202 | + |
| 203 | +TEMP_DIR = "crashtest-tmp" # WSL relies on this being relative |
| 204 | +SYMBOL_DIR = PathJoin(TEMP_DIR, "symbols") |
| 205 | +os.makedirs(TEMP_DIR, exist_ok=True) |
| 206 | +os.makedirs(SYMBOL_DIR, exist_ok=True) |
| 207 | + |
| 208 | +if SYMBOL_ZIPS: |
| 209 | + print("Symbol zip(s) detected. Using release validation mode with pre-built symbols") |
| 210 | + for z in SYMBOL_ZIPS: |
| 211 | + with zipfile.ZipFile(PathJoin(GAME_DIR, z), 'r') as z: |
| 212 | + z.extractall(SYMBOL_DIR) |
| 213 | +else: |
| 214 | + print("No symbol zip detected. Using end-to-end Breakpad tooling test mode with dump_syms") |
| 215 | + |
| 216 | +passed = True |
| 217 | +for module in modules: |
| 218 | + passed &= ModuleCrashTests(*module.split(":")[::-1]).Go() |
| 219 | + if not passed and GIVE_UP: |
| 220 | + break |
| 221 | +sys.exit(1 - passed) |
0 commit comments