Files
BT411/tools/symcrash.py
T
Joe DiPrimaandClaude Fable 5 68ffe556ce make a field crash resolvable: no Release PDB existed, and /O2 had eaten the frame pointers
BTCrashFilter writes `[crash] ... addr=0x... (btl4+0xNNNN)` plus an EBP-chain
walk into the tester's day log, and its own comment claims "we hold the PDB".
We did not. Two independent gaps, both fatal to the point of the thing:

  1. No Release PDB was produced AT ALL -- only build/Debug/btl4.pdb, and the
     shipped exe embedded no PDB path. btl4+0xNNNN from a tester could not be
     turned into a function name by anyone.
  2. Release flags were /O2 /Ob2 /DNDEBUG with no /Oy- anywhere, and /O2 implies
     /Oy on x86. The walker follows EBP, so the stack it printed was unreliable
     even when the faulting address was not.

Net effect: when the Owens crash finally lands we would have received an address
nobody could resolve, and a call chain we could not trust. Cheaper to fix before
tonight's session than to wait for the crash to happen twice.

  /Zi   emit debug info -> a PDB. Does not change codegen.
  /Oy-  keep EBP as a frame pointer so the walk is trustworthy.
  /DEBUG + /OPT:REF + /OPT:ICF -- /DEBUG turns the last two OFF by default,
        which would have quietly bloated the shipped exe with unreferenced
        code. The exe grew 512 bytes, the debug directory entry, and nothing else.

Also emitting /MAP, which turned out to matter: this machine has no cdb, and a
tester's operator may not have one either. The .map is plain text, so an offset
can be resolved with a text editor. tools/symcrash.py does it properly --
nearest-preceding-symbol against the archived map, module-external addresses
(ntdll, kernel32) passed through untouched since they are not ours.

Verified end to end rather than assumed, using the BT_CRASHTEST=1 hook that
exists for exactly this:

  [crash] addr=0xb8260 (btl4+0x8260) access=1 target=0x0
  [crash] stack: btl4+0x8260 btl4+0xfe7ed 0x763ffcc9 ...

  btl4+0x8260   -> _WinMain@16 +0x7e0
  btl4+0xfe7ed  -> __scrt_common_main_seh +0xf8

which is exactly right: BT_CRASHTEST does *(volatile int *)0 = 0 inside WinMain,
called from the CRT entry. Correct stack AND correct names.

The PDB and map are archived per build and never shipped: .gitignore already
covers *.pdb and dist/, and mkdist only picks up .dll next to the exe, so
neither can reach a zip by accident. The session header stamps
build=4.11.<n> (<hash>), so an archived pair matches a tester's log exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:09:36 -05:00

88 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""symcrash.py -- turn a tester's [crash] line into function names.
BTCrashFilter (btl4main.cpp) writes module-relative offsets into the day log:
[crash] UNHANDLED EXCEPTION code=0xc0000005 addr=0xb8260 (btl4+0x8260) ...
[crash] stack: btl4+0x8260 btl4+0xfe7ed 0x763ffcc9 ...
Those offsets mean nothing without the symbols for the EXACT build that produced
them -- which is why every session header carries `build=4.11.<n> (<hash>)` and
why the linker .map is archived per build alongside the PDB.
Usage:
python tools/symcrash.py <btl4.map> "btl4+0x8260"
python tools/symcrash.py <btl4.map> <tester.log> (scans for [crash])
Resolution is nearest-preceding-symbol, so the answer is the function the
address falls INSIDE, plus the byte offset into it. Addresses outside the
module (kernel32, ntdll) are passed through untouched -- they are not ours.
"""
import re
import sys
def load_map(path):
"""Return (sorted [(rva, name)], preferred_base)."""
syms = []
base = None
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
if base is None:
m = re.search(r"Preferred load address is ([0-9A-Fa-f]+)", line)
if m:
base = int(m.group(1), 16)
continue
# " 0001:00000a10 ?Foo@Bar@@QAEXXZ 00401a10 f thing.obj"
m = re.match(r"\s+[0-9A-Fa-f]{4}:[0-9A-Fa-f]{8}\s+(\S+)\s+([0-9A-Fa-f]{8,16})\s",
line)
if m and base is not None:
syms.append((int(m.group(2), 16) - base, m.group(1)))
syms.sort(key=lambda s: s[0])
return syms, base
def resolve(syms, rva):
lo, hi, best = 0, len(syms) - 1, None
while lo <= hi: # nearest preceding symbol
mid = (lo + hi) // 2
if syms[mid][0] <= rva:
best = syms[mid]
lo = mid + 1
else:
hi = mid - 1
return best
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
syms, base = load_map(sys.argv[1])
if not syms:
sys.exit("no symbols parsed from %s -- is it a linker .map?" % sys.argv[1])
print("%d symbols, preferred base 0x%x\n" % (len(syms), base))
arg = sys.argv[2]
try: # a log file?
text = open(arg, "r", encoding="utf-8", errors="replace").read()
except OSError:
text = arg # ...no, a literal offset
for line in text.splitlines() or [text]:
if "btl4+" not in line:
continue
if line.lstrip().startswith("[crash]"):
print(line.strip())
for off in re.findall(r"btl4\+0x([0-9A-Fa-f]+)", line):
rva = int(off, 16)
hit = resolve(syms, rva)
if hit is None:
print(" btl4+0x%-8x <before first symbol>" % rva)
else:
print(" btl4+0x%-8x %s +0x%x" % (rva, hit[1], rva - hit[0]))
print()
if __name__ == "__main__":
main()