Skip to content

Commit 824a539

Browse files
committed
Add Breakpad crash test script
tools/crash_test.py does an end-to-end test of the Breakpad crash dump and stack trace workflow: - Generate symbols from binary debug information (or use the distributed symbol zips in the case of a release) - Run Daemon and trigger a crash using /injectFault which is dumped by Breakpad or the NaCl minidump code - Run minidump_stackwalk to print the stack trace - Verify that function and file names are present in the stack trace
1 parent 7bec0ec commit 824a539

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

tools/crash_test.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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

Comments
 (0)