From b38b36082413a46b730319f1ff708b46778b99fb Mon Sep 17 00:00:00 2001 From: TianlongLiang Date: Thu, 25 Jun 2026 10:15:22 +0800 Subject: [PATCH 1/2] addr2line: detect clang version and dispatch to modern or legacy resolver Refactor the wasm symbolicator to make its toolchain workarounds explicit. addr2line.py now detects the wasi-sdk's clang major version at startup and routes per-frame address resolution to one of: - resolve_address_modern (clang >= 22, wasi-sdk 33+): single llvm-symbolizer call per address. The symbolizer handles address-to-name correctly on this clang. - resolve_address_legacy (clang < 22 or unknown): llvm-symbolizer call + llvm-dwarfdump --lookup overlay to fix the outermost function name. Older clang versions emit wasm DWARF that confuses llvm-symbolizer's address-to-name resolver for some addresses (e.g., reports 'recurse' as 'free'). --lookup goes through a different DWARF traversal path that handles them. The dispatch decision is logged once to stderr so users can see which path was taken without polluting stdout. Stdout is byte- identical between paths for non-buggy inputs. Also includes accumulated improvements: - --mode flag {interp,aot,fast-interp} for different runtime offset conventions (interp post-advance, aot at-instruction-start, fast-interp's transformed in-memory bytecode) - Inline frame annotation "(inlined into )" for clarity - llvm-symbolizer preferred over llvm-addr2line for column info - Fallback for offset=0 (trap at function entry; frame_ip not captured) - Last-resort function-index name fallback when DWARF lacks PC ranges --- test-tools/addr2line/README.md | 132 ++++++ test-tools/addr2line/addr2line.py | 727 ++++++++++++++++++++++-------- 2 files changed, 675 insertions(+), 184 deletions(-) create mode 100644 test-tools/addr2line/README.md diff --git a/test-tools/addr2line/README.md b/test-tools/addr2line/README.md new file mode 100644 index 0000000000..f283f7752e --- /dev/null +++ b/test-tools/addr2line/README.md @@ -0,0 +1,132 @@ +# `addr2line.py` — symbolicating WAMR call stacks + +`addr2line.py` turns a WAMR call-stack dump (`#00: 0x0040 - $f0` lines) back +into source-level information (function names, files, line numbers, inline +chains). It is a thin orchestrator over four LLVM/wabt tools, with workarounds +for a real LLVM symbolization bug on wasm targets. + +The two `samples/debug-tools*/` samples are end-to-end demos of the workflow. +This README focuses on the tool itself: how it works, why each subprocess is +necessary, and what edge cases force a "legacy" code path. + +## Quick reference + +```bash +python3 addr2line.py \ + --wasi-sdk /opt/wasi-sdk \ + --wabt /opt/wabt \ + --wasm-file /path/to/app.debug.wasm \ + call_stack.txt +``` + +| Flag | Default | Purpose | +|------|---------|---------| +| `--mode={interp,aot,fast-interp}` | `interp` | How to interpret runtime offsets — see *Three offset spaces* below. | +| `--no-addr` | off | Resolve by function name only (for very old iwasm dumps that lack offsets, or for fast-interp). | +| `-v` / `--verbose` | off | Print the resolver-dispatch decision (modern vs legacy) to stderr. | + +## What the script does, step by step + +1. **Parse each captured frame** (`#NN: 0xADDR - SYMBOL`). +2. **Convert the runtime offset to a DWARF address.** WAMR reports + file-absolute byte offsets; DWARF addresses are relative to the start of + the WASM Code section, so the script subtracts `code_section_start` (read + from `wasm-objdump -h`). +3. **Apply a mode-dependent adjustment.** See *Three offset spaces* below. +4. **Resolve the address.** Either the modern resolver (one subprocess) or + the legacy resolver (two subprocesses with a name overlay) — the script + detects the wasi-sdk's clang version and picks one automatically. +5. **Demangle and render.** C++ symbols pass through `llvm-cxxfilt`; inline + frames are annotated `(inlined into )` so the chain reads top-down. + +## Three offset spaces (`--mode`) + +WAMR captures `frame_ip` differently in each execution mode, so the offset in +a captured frame means different things: + +| Mode | Offset space | Adjustment | What addr2line does | +|------|--------------|-----------|----------------------| +| `interp` (default) | File-absolute, *post*-advance — interpreter has already incremented `frame_ip` past the trapping opcode (and past LEB reads inside the handler) before the trap fires. | `offset - code_start - 1` | Full symbolication: file, line, column, inline chain. | +| `aot` | File-absolute, *instruction-start* — `wamrc` emits a `commit_ip` before every WASM operation. | `offset - code_start` | Full symbolication. | +| `fast-interp` | Function-relative, into the *transformed* in-memory bytecode (opcodes replaced with handler indices, LEBs expanded to fixed widths). The mapping back to source is destroyed by the transform. | (none) | Function-name lookup only — equivalent to `--no-addr`. One frame per input; no inline expansion (the `DW_TAG_inlined_subroutine` overlay is address-keyed and unavailable here). | + +Picking the wrong `--mode` lands the resolver inside an adjacent function and +silently produces wrong file/line. + +## The two resolvers, and why both exist + +`llvm-symbolizer -e -f -i ` is the natural way to map an address +to a function + source location, with `-i` expanding inline frames. + +**This subprocess is buggy on wasm targets emitted by clang < 22.** For some +addresses it returns the wrong outermost function name — frequently a +declaration-only DW_TAG_subprogram from wasi-libc that overlaps the real +function's PC range. Concretely, you can ask for the address of `recurse` and +get back `free`; ask for `a()` in a small-trap demo and get back `_start`. +The line table is correct in both cases — only the function-name lookup is +wrong. The bug lives in the symbolizer/addr2line shared backend; switching +between the two binaries doesn't help. + +The bug is fixed in clang 22 (wasi-sdk 33). For older toolchains we +build a `(low_pc, high_pc, name)` interval table from a single +`llvm-dwarfdump --debug-info` pass at startup, and overlay the +**outermost** frame's name from that table — `[low_pc, high_pc)` ranges +parsed straight from DWARF are canonical, regardless of any symbolizer +backend bug. Inner inline frames stay as-reported by the symbolizer: +they come from `DW_TAG_inlined_subroutine` entries that the symbolizer +renders correctly even on the buggy backend. When several intervals +cover the same address (wasi-libc declarations often have +`low_pc=0` and span much of the binary), the **innermost** (smallest +range) wins. + +`addr2line.py` runs `/bin/clang --version` once at startup and: +- clang ≥ 22 → **modern resolver** (one `llvm-symbolizer` subprocess per address). +- clang < 22 → **legacy resolver** (`llvm-symbolizer` + interval-table overlay). +- clang version undetectable → **legacy resolver** (safest fallback). + +Pass `-v` to see which path is taken. The legacy resolver — and the +interval-table helpers `build_subprogram_intervals` / +`lookup_subprogram_name` — can be removed once the project's minimum +supported wasi-sdk is 33+ everywhere. + +## Tools used by addr2line.py + +| Tool | From | Why this tool, and not another | +|------|------|--------------------------------| +| `llvm-symbolizer` | wasi-sdk | The actual address → `(function, file:line:column)` resolver, with inline expansion via `-i`. Ships in wasi-sdk 29+ (the project's minimum supported version). | +| `llvm-dwarfdump` | wasi-sdk | Legacy path only: a single `--debug-info` pass at startup builds a `(low_pc, high_pc, name)` interval table for every `DW_TAG_subprogram`, used to overlay the outermost frame's name (the buggy lookup on clang < 22). No equivalent CLI exposes this as structured output, so we parse the text. | +| `wasm-objdump` | wabt | Reports the Code-section start offset (needed to convert file-absolute runtime offsets to DWARF addresses) and the function-index → name table (a last-resort fallback for declaration-only DW_TAG_subprograms like `__main_void`). | +| `llvm-cxxfilt` | wasi-sdk | C++ symbol demangling, applied as a final pass on resolved names. | + +The script also detects an emscripten-style sourceMappingURL section and +falls back to `emsymbolizer` for that case. + +## Behavior matrix + +| Input | Resolver path | Output | +|-------|---------------|--------| +| `--mode=interp` or `--mode=aot`, clang ≥ 22, address in DWARF range | Modern | Full file:line:column with inline chain | +| `--mode=interp` or `--mode=aot`, clang < 22, address in DWARF range | Legacy | Same, but the outermost name is overlaid from a startup-built `DW_TAG_subprogram` interval table (innermost match wins) | +| `--mode=fast-interp` | Function-name | Single frame per input, no inline expansion, no source mapping | +| `--no-addr` | Function-name | Resolves the *function* — file:line refers to its declaration, not the trap site | +| Offset = 0 | Function-name fallback (with `(offset=0 — function entry)` note) | Used when WAMR couldn't capture `frame_ip` (e.g., trap at function entry, or top frame of an `iwasm -f` invocation) | +| Address outside any DW_TAG_subprogram | wasm-objdump function-index table | Single name; useful for declaration-only entries | +| Source has emscripten `sourceMappingURL` custom section | `emsymbolizer` source-map path | Single frame per address; no inline expansion (source maps don't carry it) | + +## Tests + +Run the pytest suite under `test-tools/addr2line/tests/`. It builds tiny +purpose-built C/C++ apps, captures their trap sites, and asserts the resolver +behaves correctly under a number of scenarios (single trap, always_inline, +deep inline chain, cross-TU LTO inlining, mid-function trap, multi-frame +call stack, C++ demangling, AOT mode, fast-interp mode, `--no-addr`, +`offset=0`, empty input). Multi-SDK runs (`pytest --multi-sdk`) cover the +modern-vs-legacy equivalence and require both an old and a new wasi-sdk to be +installed. + +## See also + +- `test-tools/addr2line/addr2line.py` — the script +- `test-tools/addr2line/tests/` — pytest harness +- `samples/debug-tools/` — minimal end-to-end sample +- `samples/debug-tools-optimized/` — production-realistic workflow (LTO, wasm-opt, strip, AOT, debug companion) diff --git a/test-tools/addr2line/addr2line.py b/test-tools/addr2line/addr2line.py index 800ba37504..52cbf11f24 100644 --- a/test-tools/addr2line/addr2line.py +++ b/test-tools/addr2line/addr2line.py @@ -14,7 +14,7 @@ """ This is a tool to convert addresses, which are from a call-stack dump generated by iwasm, into line info for a wasm file. -When a wasm file is compiled with debug info, it is possible to transfer the address to line info. +When a wasm file is compiled with debug info (-g), it is possible to transfer the address to line info. For example, there is a call-stack dump: @@ -26,27 +26,35 @@ ``` - store the call-stack dump into a file, e.g. call_stack.txt -- run the following command to convert the address into line info: +- run the following command: ``` - $ cd test-tools/addr2line - $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt + $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt ``` - The script will use *wasm-objdump* in wabt to transform address, then use *llvm-dwarfdump* to lookup the line info for each address - in the call-stack dump. -- if addresses are not available in the stack trace (i.e. iwasm <= 1.3.2) or iwasm is used in fast interpreter mode, - run the following command to convert the function index into line info (passing the `--no-addr` option): - ``` - $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt --no-addr - ``` - The script will use *wasm-objdump* in wabt to get the function names corresponding to function indexes, then use *llvm-dwarfdump* to lookup the line info for each - function index in the call-stack dump. + +The script handles two workflows: +- Same-binary: pass the wasm file that ran (must have DWARF debug info) +- Companion: pass a sibling debug companion (.debug.wasm produced by `wasm-opt -Oz -g`). + The production binary that ran can be stripped — the companion has identical code + layout but retains DWARF, so the runtime offsets map correctly. + +Inline expansion (DW_TAG_inlined_subroutine) is automatic — no flag needed. + +By default, offsets are interpreted as coming from classic interpreter mode +(frame_ip has advanced past the trapping opcode). For AOT call stacks, pass +`--mode=aot` so offsets are used verbatim — wamrc commits ip at the start of +each WASM operation, not after. + +For fast-interp call stacks, pass `--mode=fast-interp`. Fast-interp transforms +the bytecode in-memory at load time, so its offsets are meaningless for source +mapping; the script falls back to function-name lookup (same as `--no-addr`). + +If addresses are not available (iwasm <= 1.3.2), pass `--no-addr` to look up +by function index/name only. """ def locate_sourceMappingURL_section(wasm_objdump: Path, wasm_file: Path) -> bool: - """ - Figure out if the wasm file has a sourceMappingURL section. - """ + """Figure out if the wasm file has a sourceMappingURL section.""" cmd = f"{wasm_objdump} -h {wasm_file}" p = subprocess.run( shlex.split(cmd), @@ -55,24 +63,18 @@ def locate_sourceMappingURL_section(wasm_objdump: Path, wasm_file: Path) -> bool text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: - line = line.strip() - if "sourceMappingURL" in line: + for line in p.stdout.splitlines(): + if "sourceMappingURL" in line.strip(): return True - return False def get_code_section_start(wasm_objdump: Path, wasm_file: Path) -> int: """ - Find the start offset of Code section in a wasm file. + Find the start file offset of the Code section in a wasm file. - if the code section header likes: + Code section header looks like: Code start=0x0000017c end=0x00004382 (size=0x00004206) count: 47 - - the start offset is 0x0000017c """ cmd = f"{wasm_objdump} -h {wasm_file}" p = subprocess.run( @@ -82,155 +84,219 @@ def get_code_section_start(wasm_objdump: Path, wasm_file: Path) -> int: text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: + for line in p.stdout.splitlines(): line = line.strip() - if "Code" in line: - return int(line.split()[1].split("=")[1], 16) - + if "Code" in line and "start=" in line: + m = re.search(r"start=(0x[0-9a-fA-F]+)", line) + if m: + return int(m.group(1), 16) return -1 -def get_line_info_from_function_addr_dwarf( - dwarf_dump: Path, wasm_file: Path, offset: int -) -> tuple[str, str, str, str]: - """ - Find the location info of a given offset in a wasm file. +def detect_clang_major_version(wasi_sdk: Path): """ - cmd = f"{dwarf_dump} --lookup={offset} {wasm_file}" - p = subprocess.run( - shlex.split(cmd), - check=False, - capture_output=True, - text=True, - universal_newlines=True, - ) - outputs = p.stdout.split(os.linesep) - - function_name, function_file = "", "unknown" - function_line, function_column = "?", "?" - - for line in outputs: - line = line.strip() - - if "DW_AT_name" in line: - function_name = get_dwarf_tag_value("DW_AT_name", line) - - if "DW_AT_decl_file" in line: - function_file = get_dwarf_tag_value("DW_AT_decl_file", line) - - if "Line info" in line: - _, function_line, function_column = parse_line_info(line) - - return (function_name, function_file, function_line, function_column) + Run /bin/clang --version and parse the major version. + Returns the major version as an int (e.g. 22), or None if the binary + can't be run or the version line can't be parsed. Caller should treat + None as "use legacy path" (safest fallback). -def get_dwarf_tag_value(tag: str, line: str) -> str: - # Try extracting value as string - STR_PATTERN = rf"{tag}\s+\(\"(.*)\"\)" - m = re.match(STR_PATTERN, line) - if m: - return m.groups()[0] - - # Try extracting value as integer - INT_PATTERN = rf"{tag}\s+\((\d+)\)" - m = re.match(INT_PATTERN, line) - return m.groups()[0] + Used to decide whether `addr2line.py` runs the modern resolver + (clang >= 22, clean DWARF) or the legacy resolver (clang < 22, + needs llvm-dwarfdump --lookup overlay to work around an LLVM + symbolization bug on wasm targets). + """ + clang = wasi_sdk.joinpath("bin/clang") + if not clang.exists(): + return None + try: + p = subprocess.run( + [str(clang), "--version"], + check=False, + capture_output=True, + text=True, + universal_newlines=True, + ) + except (OSError, subprocess.SubprocessError): + return None + + m = re.search(r"clang version (\d+)\.\d+", p.stdout) + if not m: + return None + return int(m.group(1)) + + +def build_subprogram_intervals(dwarf_dump: Path, wasm_file: Path) -> list: + """ + Return a list of (low_pc, high_pc, name) for every DW_TAG_subprogram + in the wasm's DWARF that has all three attributes. + Used by the legacy resolver to overlay the outermost frame's name — + `llvm-symbolizer` returns wrong names on wasm under clang < 22, but + [low_pc, high_pc) ranges parsed straight from `--debug-info` are + canonical. Computed once per run; lookups against the result are O(N) + in the subprogram count, which is small. -def get_line_info_from_function_name_dwarf( - dwarf_dump: Path, wasm_file: Path, function_name: str -) -> tuple[str, str, str]: - """ - Find the location info of a given function in a wasm file. + See test-tools/addr2line/README.md for why this exists. """ - cmd = f"{dwarf_dump} --name={function_name} {wasm_file}" - p = subprocess.run( + cmd = f"{dwarf_dump} --debug-info {wasm_file}" + raw = subprocess.run( shlex.split(cmd), check=False, capture_output=True, text=True, universal_newlines=True, - ) - outputs = p.stdout.split(os.linesep) + ).stdout + + intervals = [] + sp_indent = -1 + low = high = name = None + + def _flush(): + if low is not None and high is not None and name is not None: + intervals.append((low, high, name)) + + for raw_line in raw.splitlines(): + # llvm-dwarfdump prefixes DIE-tag lines with their offset + # ("0xNNN:") whose width varies; replace with a fixed sentinel so + # the tag-line indent matches attribute-line indent. + body = re.sub(r"^0x[0-9a-fA-F]+:", " " * 11, raw_line) + stripped = body.lstrip() + if not stripped: + continue + indent = len(body) - len(stripped) - function_name, function_file = "", "unknown" - function_line = "?" + if stripped.startswith("DW_TAG_subprogram"): + _flush() + sp_indent = indent + low = high = name = None + continue + if sp_indent < 0: + continue + # A DIE at sp_indent or shallower closes the current subprogram. + if stripped.startswith("DW_TAG_") and indent <= sp_indent: + _flush() + sp_indent = -1 + low = high = name = None + continue + # Only attributes one level deeper than the tag belong to the + # subprogram itself; deeper indents are nested DIEs. + if indent != sp_indent + 2: + continue - for line in outputs: - line = line.strip() + m = re.match(r"DW_AT_low_pc\s+\(0x([0-9a-fA-F]+)\)", stripped) + if m: + low = int(m.group(1), 16) + continue + m = re.match(r"DW_AT_high_pc\s+\(0x([0-9a-fA-F]+)\)", stripped) + if m: + v = int(m.group(1), 16) + # high_pc is rendered as either an absolute address or — for + # DW_FORM_data* — as size-from-low_pc; pick the larger of the + # two interpretations. + high = v if low is None or v >= low else low + v + continue + m = re.match(r'DW_AT_name\s+\("(.+)"\)', stripped) + if m and name is None: + name = m.group(1) - if "DW_AT_name" in line: - function_name = get_dwarf_tag_value("DW_AT_name", line) + _flush() + return intervals - if "DW_AT_decl_file" in line: - function_file = get_dwarf_tag_value("DW_AT_decl_file", line) - if "DW_AT_decl_line" in line: - function_line = get_dwarf_tag_value("DW_AT_decl_line", line) +def lookup_subprogram_name(intervals: list, dwarf_addr: int) -> str: + """ + Return the name of the innermost (smallest-range) DW_TAG_subprogram + whose [low_pc, high_pc) covers `dwarf_addr`, or None. - return (function_name, function_file, function_line) + "Innermost" matters because wasi-libc declarations (low_pc=0, + high_pc spanning much of the binary) overlap real functions; we want + the tightly-bounded one. + """ + best = None + for low, high, name in intervals: + if low <= dwarf_addr < high: + if best is None or (high - low) < (best[1] - best[0]): + best = (low, high, name) + return best[2] if best else None -def get_line_info_from_function_addr_sourcemapping( - emsymbolizer: Path, wasm_file: Path, offset: int -) -> tuple[str, str, str, str]: +def resolve_address_modern( + symbolizer: Path, wasm_file: Path, dwarf_addr: int +) -> list: """ - Find the location info of a given offset in a wasm file which is compiled with emcc. + Modern address resolver for clang >= 22 (wasi-sdk 33+). + + Runs: -e -f -i 0x - {emsymbolizer} {wasm_file} {offset of file} + The symbolizer's address-to-function resolver is correct on wasm + targets emitted by clang 22+, so we don't need the llvm-dwarfdump + --lookup overlay that resolve_address_legacy uses. Single subprocess + per address. - there usually are two lines: - ?? - relative path to source file:line:column + Returns a list of (function_name, file, line, column) tuples. + Each field is a string; line and column may be "?" if unknown. """ - debug_info_source = wasm_file.with_name(f"{wasm_file.name}.map") - cmd = f"{emsymbolizer} -t code -f {debug_info_source} {wasm_file} {offset}" + addr_str = f"0x{dwarf_addr:x}" + cmd = f"{symbolizer} -e {wasm_file} -f -i {addr_str}" p = subprocess.run( shlex.split(cmd), check=False, capture_output=True, text=True, universal_newlines=True, - cwd=Path.cwd(), ) - outputs = p.stdout.split(os.linesep) - - function_name, function_file = "", "unknown" - function_line, function_column = "?", "?" - - for line in outputs: - line = line.strip() + output_lines = [line for line in p.stdout.splitlines() if line.strip()] - if not line: - continue + frames = [] + for i in range(0, len(output_lines) - 1, 2): + func_name = output_lines[i].strip() + location = output_lines[i + 1].strip() - m = re.match(r"(.*):(\d+):(\d+)", line) + # Parse "file:line:column" or "file:line" or "??:0" + m = re.match(r"^(.*):(\d+):(\d+)$", location) if m: - function_file, function_line, function_column = m.groups() - continue + file, line, column = m.group(1), m.group(2), m.group(3) else: - # it's always ??, not sure about that - if "??" != line: - function_name = line + m = re.match(r"^(.*):(\d+)$", location) + if m: + file, line, column = m.group(1), m.group(2), "?" + else: + file, line, column = location, "?", "?" - return (function_name, function_file, function_line, function_column) + frames.append((func_name, file, line, column)) + + return frames -def parse_line_info(line_info: str) -> tuple[str, str, str]: +def resolve_address_legacy( + symbolizer: Path, wasm_file: Path, dwarf_addr: int, + subprogram_intervals: list, +) -> list: """ - line_info -> [file, line, column] + Legacy address resolver for clang < 22 (wasi-sdk < 33). + + Same llvm-symbolizer call as the modern resolver, but unconditionally + overlays the OUTERMOST frame's name from `subprogram_intervals` (built + by `build_subprogram_intervals`). The symbolizer's outermost name is + unreliable on wasm targets under clang < 22; the interval table is + canonical [low_pc, high_pc) data straight from DWARF. Inline frames + keep the symbolizer-reported name — they come from + DW_TAG_inlined_subroutine entries that the symbolizer renders + correctly. + + See test-tools/addr2line/README.md for the underlying LLVM bug. """ - PATTERN = r"Line info: file \'(.+)\', line ([0-9]+), column ([0-9]+)" - m = re.search(PATTERN, line_info) - assert m is not None - - file, line, column = m.groups() - return (file, int(line), int(column)) + frames = resolve_address_modern(symbolizer, wasm_file, dwarf_addr) + if frames: + name = lookup_subprogram_name(subprogram_intervals, dwarf_addr) + if name: + _, file, line, column = frames[-1] + frames[-1] = (name, file, line, column) + return frames -def parse_call_stack_line(line: str) -> tuple[str, str, str]: +def parse_call_stack_line(line: str) -> tuple: """ New format (WAMR > 1.3.2): #00: 0x0a04 - $f18 => (00, 0x0a04, $f18) @@ -241,7 +307,6 @@ def parse_call_stack_line(line: str) -> tuple[str, str, str]: _start (always): #05: 0x011f - _start => (05, 0x011f, _start) """ - # New format and Text format and _start PATTERN = r"#([0-9]+): 0x([0-9a-f]+) - (\S+)" m = re.match(PATTERN, line) @@ -257,7 +322,8 @@ def parse_call_stack_line(line: str) -> tuple[str, str, str]: return None -def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str]: +def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict: + """Map function index to name from wasm-objdump output.""" function_index_to_name = {} cmd = f"{wasm_objdump} -x {wasm_file} --section=function" @@ -268,16 +334,13 @@ def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: - if not f"func[" in line: + for line in p.stdout.splitlines(): + if "func[" not in line: continue - PATTERN = r".*func\[([0-9]+)\].*<(.*)>" m = re.match(PATTERN, line) - assert m is not None - + if m is None: + continue index = m.groups()[0] name = m.groups()[1] function_index_to_name[index] = name @@ -285,6 +348,121 @@ def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str return function_index_to_name +def get_line_info_from_function_name_dwarf( + dwarf_dump: Path, wasm_file: Path, function_name: str +) -> tuple: + """ + Used by --no-addr mode (fast-interp call stacks, or WAMR <= 1.3.2 + output where addresses are absent). + + Resolves a function name to its declaration's (file, decl_line) by + parsing `llvm-dwarfdump --name=` output: collect every + matching DW_TAG_subprogram block, then prefer a non-sysroot match + over a wasi-libc declaration of the same name (short names like + `b`, `c` frequently collide with wasi-libc helpers). + + TODO: limitation — `llvm-symbolizer` is address-driven and has no + name-to-source mode, so this helper manually parses llvm-dwarfdump + text output. The resolved location is the function's declaration + line, not the call site. A robust replacement would need a + programmatic DWARF reader (e.g. pyelftools) or a future LLVM CLI + exposing name-keyed lookup. + + Returns (function_name, file, line). + """ + cmd = f"{dwarf_dump} --name={function_name} {wasm_file}" + p = subprocess.run( + shlex.split(cmd), + check=False, + capture_output=True, + text=True, + universal_newlines=True, + ) + + # Collect every (name, file, line) triple from the output. Each + # DW_TAG_subprogram block is separated by a blank line. + candidates = [] + cur = {} + for line in p.stdout.splitlines() + [""]: + line = line.strip() + if not line: + if cur: + candidates.append(( + cur.get("name", ""), + cur.get("file", "unknown"), + cur.get("line", "?"), + )) + cur = {} + continue + m = re.match(r'DW_AT_name\s+\("(.+)"\)', line) + if m: + cur["name"] = m.group(1) + continue + m = re.match(r'DW_AT_decl_file\s+\("(.+)"\)', line) + if m: + cur["file"] = m.group(1) + continue + m = re.match(r"DW_AT_decl_line\s+\((\d+)\)", line) + if m: + cur["line"] = m.group(1) + + # Prefer a candidate whose source file isn't from wasi-sysroot / + # wasi-libc / wasi-sdk — short names like `b`, `c` collide with + # internal libc helpers and llvm-dwarfdump's name lookup returns + # every match across the binary. + def _is_sysroot(p): + return "wasi-sysroot" in p or "wasi-libc" in p or p.startswith("wasisdk://") + + for name, file, line in candidates: + if name == function_name and not _is_sysroot(file): + return (name, file, line) + # Fall back to the last candidate (preserves prior behavior when + # all matches are sysroot-ish or the name doesn't match exactly). + if candidates: + return candidates[-1] + return ("", "unknown", "?") + + +def get_line_info_from_function_addr_sourcemapping( + emsymbolizer: Path, wasm_file: Path, offset: int +) -> tuple: + """ + Find the location info of a given offset in a wasm file compiled with emcc. + + {emsymbolizer} {wasm_file} {offset} + + Output is two lines: + ?? + relative path to source:line:column + """ + debug_info_source = wasm_file.with_name(f"{wasm_file.name}.map") + cmd = f"{emsymbolizer} -t code -f {debug_info_source} {wasm_file} {offset}" + p = subprocess.run( + shlex.split(cmd), + check=False, + capture_output=True, + text=True, + universal_newlines=True, + cwd=Path.cwd(), + ) + + function_name, function_file = "", "unknown" + function_line, function_column = "?", "?" + + for line in p.stdout.splitlines(): + line = line.strip() + if not line: + continue + m = re.match(r"(.*):(\d+):(\d+)", line) + if m: + function_file, function_line, function_column = m.groups() + continue + if "??" != line: + function_name = line + + return (function_name, function_file, function_line, function_column) + + def demangle(cxxfilt: Path, function_name: str) -> str: cmd = f"{cxxfilt} -n {function_name}" p = subprocess.run( @@ -297,45 +475,163 @@ def demangle(cxxfilt: Path, function_name: str) -> str: return p.stdout.strip() +def print_frames(index_str: str, frames: list, cxxfilt: Path) -> None: + """ + Print resolved frames with consistent indentation. Inlined functions + (frames before the outermost) get an "(inlined into )" suffix + so the chain relationship is unambiguous. + + Single frame (no inlines): + 0: c + at trap.c:4:5 + + Multiple frames (inline chain — innermost first): + 0: do_bad_access (inlined into trigger_oob) + at oob_access.c:11:5 + trigger_oob (inlined into app_main) + at oob_main.c:17:5 + app_main + at oob_main.c:23:5 + + The first frame uses ":" prefix; subsequent frames use the same + width of leading spaces for vertical alignment. + """ + prefix = f"{index_str}:" + indent = " " * len(prefix) + + demangled_names = [ + demangle(cxxfilt, fn) if fn != "??" else "" + for fn, _, _, _ in frames + ] + + for i, (func_name, file, line, column) in enumerate(frames): + leading = prefix if i == 0 else indent + # All frames except the outermost are inlined into the next one in the chain. + if i < len(frames) - 1: + suffix = f" (inlined into {demangled_names[i + 1]})" + else: + suffix = "" + print(f"{leading} {demangled_names[i]}{suffix}") + # Don't print "??:0:0" — collapse to "??:0". + if file == "??" and line in ("0", "?"): + print("\tat ??:0") + elif column != "?": + print(f"\tat {file}:{line}:{column}") + else: + print(f"\tat {file}:{line}") + + def main(): parser = argparse.ArgumentParser(description="addr2line for wasm") - parser.add_argument("--wasi-sdk", type=Path, help="path to wasi-sdk") - parser.add_argument("--wabt", type=Path, help="path to wabt") - parser.add_argument("--wasm-file", type=Path, help="path to wasm file") + parser.add_argument("--wasi-sdk", type=Path, required=True, help="path to wasi-sdk") + parser.add_argument("--wabt", type=Path, required=True, help="path to wabt") + parser.add_argument("--wasm-file", type=Path, required=True, help="path to wasm file (must have DWARF)") parser.add_argument("call_stack_file", type=Path, help="path to a call stack file") parser.add_argument( "--no-addr", action="store_true", help="use call stack without addresses or from fast interpreter mode", ) + parser.add_argument( + "--mode", + choices=["interp", "aot", "fast-interp"], + default="interp", + help=( + "WAMR execution mode that produced the call stack. " + "interp: classic interpreter, frame_ip is post-advance, subtract 1 " + "from offsets to land on the trapping opcode. " + "aot: wamrc commits ip at the start of each WASM operation, use " + "offsets verbatim. " + "fast-interp: offsets are relative to a transformed in-memory " + "bytecode and cannot be mapped to source line — falls back to " + "function-name lookup (same as --no-addr). " + "Default: interp." + ), + ) parser.add_argument("--emsdk", type=Path, help="path to emsdk") + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="print the dispatch decision (modern vs legacy resolver) to stderr", + ) args = parser.parse_args() + # fast-interp offsets aren't mappable; treat as --no-addr + if args.mode == "fast-interp": + args.no_addr = True + wasm_objdump = args.wabt.joinpath("bin/wasm-objdump") - assert wasm_objdump.exists() + assert wasm_objdump.exists(), f"wasm-objdump not found at {wasm_objdump}" + + # llvm-symbolizer handles address->source resolution and inline-frame + # expansion via `-f -i`. Ships in wasi-sdk 29+, which is the project's + # minimum supported wasi-sdk for the debug-tools samples. + llvm_symbolizer = args.wasi_sdk.joinpath("bin/llvm-symbolizer") + assert llvm_symbolizer.exists(), ( + f"llvm-symbolizer not found at {llvm_symbolizer}; " + f"need wasi-sdk 29 or newer" + ) llvm_dwarf_dump = args.wasi_sdk.joinpath("bin/llvm-dwarfdump") - assert llvm_dwarf_dump.exists() + assert llvm_dwarf_dump.exists(), f"llvm-dwarfdump not found at {llvm_dwarf_dump}" llvm_cxxfilt = args.wasi_sdk.joinpath("bin/llvm-cxxfilt") - assert llvm_cxxfilt.exists() + assert llvm_cxxfilt.exists(), f"llvm-cxxfilt not found at {llvm_cxxfilt}" emcc_production = locate_sourceMappingURL_section(wasm_objdump, args.wasm_file) if emcc_production: if args.emsdk is None: print("Please provide the path to emsdk via --emsdk") return -1 - emsymbolizer = args.emsdk.joinpath("upstream/emscripten/emsymbolizer") assert emsymbolizer.exists() + # Decide which resolver to use based on the detected clang version. + # clang >= 22 (wasi-sdk 33+) has clean DWARF and llvm-symbolizer alone + # is correct. Older clang has a known wasm DWARF symbolization bug + # that the legacy resolver works around via llvm-dwarfdump --lookup. + clang_major = detect_clang_major_version(args.wasi_sdk) + if clang_major is None: + # Always warn on the unknown-version case — the user should know + # the safest fallback was used and may want to investigate. + print( + f"warning: could not detect clang version under {args.wasi_sdk}; " + f"using legacy resolver (with llvm-dwarfdump subprogram-name overlay)", + file=sys.stderr, + ) + use_legacy = True + elif clang_major < 22: + if args.verbose: + print( + f"detected clang {clang_major}.x; using legacy resolver " + f"(llvm-symbolizer has known wasm DWARF bugs in this version)", + file=sys.stderr, + ) + use_legacy = True + else: + if args.verbose: + print( + f"detected clang {clang_major}.x; using modern resolver", + file=sys.stderr, + ) + use_legacy = False + code_section_start = get_code_section_start(wasm_objdump, args.wasm_file) if code_section_start == -1: + print("Could not find Code section in wasm file", file=sys.stderr) return -1 function_index_to_name = parse_module_functions(wasm_objdump, args.wasm_file) - assert args.call_stack_file.exists() + # Build the DW_TAG_subprogram interval table once if the legacy + # resolver will need it; the parse is O(MB of dwarfdump output) and + # we don't want to repeat it per frame. + subprogram_intervals = ( + build_subprogram_intervals(llvm_dwarf_dump, args.wasm_file) + if use_legacy and not emcc_production else [] + ) + + assert args.call_stack_file.exists(), f"call stack file not found: {args.call_stack_file}" with open(args.call_stack_file, "rt", encoding="ascii") as f: for i, line in enumerate(f): line = line.strip() @@ -348,63 +644,126 @@ def main(): continue _, offset, index = splitted + index_str = str(i) + if args.no_addr: # FIXME: w/ emcc production if not index.startswith("$f"): # E.g. _start or Text format - print(f"{i}: {index}") + print(f"{index_str}: {index}") continue - index = index[2:] + func_idx = index[2:] - if index not in function_index_to_name: - print(f"{i}: {line}") + if func_idx not in function_index_to_name: + print(f"{index_str}: {line}") continue if not emcc_production: _, function_file, function_line = ( get_line_info_from_function_name_dwarf( - llvm_dwarf_dump, - args.wasm_file, - function_index_to_name[index], + llvm_dwarf_dump, args.wasm_file, + function_index_to_name[func_idx], ) ) else: - _, function_file, function_line = _, "unknown", "?" + function_file, function_line = "unknown", "?" - function_name = demangle(llvm_cxxfilt, function_index_to_name[index]) - print(f"{i}: {function_name}") + function_name = demangle(llvm_cxxfilt, function_index_to_name[func_idx]) + print(f"{index_str}: {function_name}") print(f"\tat {function_file}:{function_line}") - else: - offset = int(offset, 16) - # match the algorithm in wasm_interp_create_call_stack() - # either a *offset* to *code* section start - # or a *offset* in a file - assert offset > code_section_start - offset = offset - code_section_start - - if emcc_production: - function_name, function_file, function_line, function_column = ( - get_line_info_from_function_addr_sourcemapping( - emsymbolizer, args.wasm_file, offset - ) - ) - else: - function_name, function_file, function_line, function_column = ( - get_line_info_from_function_addr_dwarf( - llvm_dwarf_dump, args.wasm_file, offset - ) - ) + continue + + # Address-based lookup + offset_int = int(offset, 16) - # if can't parse function_name, use name section or - if function_name == "": + # WAMR reports offset 0 when frame_ip wasn't captured (e.g., trap + # at function entry, or top frame of an iwasm -f invocation where + # ip wasn't sync'd before the trap). Fall back to function-name + # lookup for the function header instead of asserting. + if offset_int == 0 or offset_int <= code_section_start: + if not emcc_production: + # Resolve function name from index ($fN) or use the name directly if index.startswith("$f"): - function_name = function_index_to_name.get(index[2:], index) + func_idx = index[2:] + func_name = function_index_to_name.get(func_idx, index) else: - function_name = index + func_name = index + _, function_file, function_line = ( + get_line_info_from_function_name_dwarf( + llvm_dwarf_dump, args.wasm_file, func_name + ) + ) + print(f"{index_str}: {demangle(llvm_cxxfilt, func_name)}") + print(f"\tat {function_file}:{function_line} " + f"(offset=0 — function entry, no inline info)") + continue + # emcc path: just print raw + print(f"{index_str}: {index}") + print(f"\tat unknown:?:? (offset=0 — frame_ip not captured)") + continue + + # Mode-dependent offset adjustment: + # - interp: subtract 1 because frame_ip has advanced past the trapping + # opcode before the trap handler runs (FETCH_OPCODE_AND_DISPATCH + # increments ip before dispatching, plus LEB reads inside handlers). + # - aot: use offset verbatim. wamrc commits ip at the start of each + # WASM operation, before any LEB reads — the offset is already at + # the opcode boundary. + # (fast-interp is handled above by forcing --no-addr; we never reach + # this branch for it.) + adjustment = 1 if args.mode == "interp" else 0 + dwarf_addr = offset_int - code_section_start - adjustment + + if emcc_production: + # Source-map path doesn't support inline expansion; emit single frame. + func_name, file, line, column = ( + get_line_info_from_function_addr_sourcemapping( + emsymbolizer, args.wasm_file, dwarf_addr + ) + ) + # Fall back to function index name if unresolved + if func_name == "" and index.startswith("$f"): + func_name = function_index_to_name.get(index[2:], index) + func_name = demangle(llvm_cxxfilt, func_name) + print(f"{index_str}: {func_name}") + print(f"\tat {file}:{line}:{column}") + continue - function_name = demangle(llvm_cxxfilt, function_name) + if use_legacy: + frames = resolve_address_legacy( + llvm_symbolizer, args.wasm_file, dwarf_addr, + subprogram_intervals, + ) + else: + frames = resolve_address_modern( + llvm_symbolizer, args.wasm_file, dwarf_addr + ) + + if not frames: + # Fall back to function index name + if index.startswith("$f"): + func_name = function_index_to_name.get(index[2:], index) + else: + func_name = index + print(f"{index_str}: {demangle(llvm_cxxfilt, func_name)}") + print(f"\tat unknown:?:?") + continue - print(f"{i}: {function_name}") - print(f"\tat {function_file}:{function_line}:{function_column}") + # Last-resort fallback for the outermost frame: when neither + # llvm-symbolizer nor the dwarfdump subprogram-name overlay + # produced a clean name (e.g. the address is in a function + # whose DWARF has no PC range — declaration-only + # DW_TAG_subprogram), substitute the function index -> name + # from wasm-objdump. + if index.startswith("$f"): + expected = function_index_to_name.get(index[2:]) + actual = frames[-1][0] + if expected and ( + actual == "??" + or (actual != expected and frames[-1][1] in ("??", "unknown")) + ): + frames[-1] = (expected,) + frames[-1][1:] + + print_frames(index_str, frames, llvm_cxxfilt) return 0 From 9a85a2dee147ccffdb7df5bcbc3668c565e77840 Mon Sep 17 00:00:00 2001 From: TianlongLiang Date: Thu, 25 Jun 2026 10:16:26 +0800 Subject: [PATCH 2/2] addr2line: add pytest test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New test suite at test-tools/addr2line/tests/ exercising addr2line.py against purpose-built C/C++ sources covering: - Baseline single-function resolution - Inline expansion (always_inline, 4-level deep chain) - Cross-TU LTO inlining (multi-file recursion + wasm-opt -Oz -g) - Trap inside loop body (DWARF line-table edge case) - Multi-frame call stack - C++ symbol demangling - AOT mode offset math - fast-interp / --no-addr fallbacks - offset=0 fallback (trap at function entry) - Empty input - Version-dispatch stderr message - Multi-SDK legacy/modern equivalence (opt-in via --multi-sdk) Layout: test-tools/addr2line/tests/ ├── README.md -- documentation ├── conftest.py -- pytest fixtures (sdk discovery, build, │ run_addr2line invocation, multi-sdk │ parametrization) ├── test_addr2line.py -- 14 test cases ├── pytest.ini -- marker definitions (slow, multi_sdk) ├── run_tests.sh -- thin pytest wrapper ├── apps/ -- 8 purpose-built C/C++ sources └── fixtures/ -- 3 plaintext call-stack inputs Sources under apps/ are NOT copied from samples/; they target specific edge cases independent of sample evolution. Depends on the addr2line.py refactor (test_dispatch_message_in_stderr checks the modern/legacy stderr message; test_modern_legacy_equivalence verifies both paths agree on output). --- test-tools/addr2line/tests/README.md | 107 +++++ .../addr2line/tests/apps/always_inline.c | 27 ++ .../addr2line/tests/apps/cxx_mangled.cpp | 34 ++ .../addr2line/tests/apps/deep_inline_chain.c | 35 ++ .../addr2line/tests/apps/multi_file_recur.c | 24 ++ .../tests/apps/multi_file_recur_main.c | 18 + test-tools/addr2line/tests/apps/multi_frame.c | 32 ++ test-tools/addr2line/tests/apps/simple.c | 19 + .../addr2line/tests/apps/trap_in_loop.c | 27 ++ test-tools/addr2line/tests/conftest.py | 276 +++++++++++++ .../addr2line/tests/fixtures/aot_stack.txt | 2 + .../addr2line/tests/fixtures/empty_stack.txt | 0 .../tests/fixtures/fast_interp_stack.txt | 2 + test-tools/addr2line/tests/pytest.ini | 5 + test-tools/addr2line/tests/run_tests.sh | 19 + test-tools/addr2line/tests/test_addr2line.py | 369 ++++++++++++++++++ 16 files changed, 996 insertions(+) create mode 100644 test-tools/addr2line/tests/README.md create mode 100644 test-tools/addr2line/tests/apps/always_inline.c create mode 100644 test-tools/addr2line/tests/apps/cxx_mangled.cpp create mode 100644 test-tools/addr2line/tests/apps/deep_inline_chain.c create mode 100644 test-tools/addr2line/tests/apps/multi_file_recur.c create mode 100644 test-tools/addr2line/tests/apps/multi_file_recur_main.c create mode 100644 test-tools/addr2line/tests/apps/multi_frame.c create mode 100644 test-tools/addr2line/tests/apps/simple.c create mode 100644 test-tools/addr2line/tests/apps/trap_in_loop.c create mode 100644 test-tools/addr2line/tests/conftest.py create mode 100644 test-tools/addr2line/tests/fixtures/aot_stack.txt create mode 100644 test-tools/addr2line/tests/fixtures/empty_stack.txt create mode 100644 test-tools/addr2line/tests/fixtures/fast_interp_stack.txt create mode 100644 test-tools/addr2line/tests/pytest.ini create mode 100755 test-tools/addr2line/tests/run_tests.sh create mode 100644 test-tools/addr2line/tests/test_addr2line.py diff --git a/test-tools/addr2line/tests/README.md b/test-tools/addr2line/tests/README.md new file mode 100644 index 0000000000..16a0fd5b65 --- /dev/null +++ b/test-tools/addr2line/tests/README.md @@ -0,0 +1,107 @@ +# addr2line.py tests + +Pytest suite for `test-tools/addr2line/addr2line.py`. Replaces the older +diagnostic harness that lived under `samples/debug-tools/llvm-bug-experiment/`. + +## What's covered + +Each test case exercises one specific aspect of `addr2line.py`: + +| Test | Source | Purpose | +|------|--------|---------| +| `test_simple_resolution` | `apps/simple.c` | Baseline. One function, one trap. Asserts the resolved frame names the function and points at its source file — proves the basic address → `(func, file:line)` plumbing works end to end. | +| `test_inline_chain_basic` | `apps/always_inline.c` | A trap inside an `__attribute__((always_inline))` helper produces a 2-frame inline chain (the helper plus its caller). Asserts addr2line emits BOTH names and the `(inlined into )` annotation that ties them together — guards against future regressions in `print_frames`. | +| `test_inline_chain_deep` | `apps/deep_inline_chain.c` | Four nested always-inline functions all collapse into one WASM function under `-O0 -g`. Asserts all four names render in the inline chain AND that the outermost frame name is `app_main` (not a wasi-libc symbol like `__multi3` that overlaps app_main's PC range). This is the test that catches the "(inlined into free)" / wasi-libc-shadow class of legacy-resolver regressions. | +| `test_cross_tu_inline` | `apps/multi_file_recur_*.c` | The canonical clang-bug trigger: two TUs, `-Oz -flto`, then `wasm-opt -Oz -g` mangles the DWARF further. Both legacy AND modern symbolizers get confused on the outermost function name here, so the test only pins the limited contract that the *line table* stays inside our source files (or returns `??:0`) — it must NEVER reach into wasi-libc / wasi-sysroot. | +| `test_trap_mid_function` | `apps/trap_in_loop.c` | Trap is inside a loop body, several instructions past function entry. Asserts the resolved line is > 10 (i.e. NOT the function declaration line). Catches future regressions where addr2line might collapse mid-function addresses back to function-entry. | +| `test_multi_frame_callstack` | `apps/multi_frame.c` | Four distinct WASM functions in a call chain. Asserts every frame renders with the correct function name and a distinct frame index — catches frame-numbering or per-frame-resolution bugs that a single-frame test misses. | +| `test_cxx_demangling` | `apps/cxx_mangled.cpp` | A templated, namespaced C++ function exercises the cxxfilt pass. Asserts mangled `_Z...` never leaks to the user and that the line table resolves to our `.cpp` (not wasi-libc). Doesn't pin the symbolizer's function-name choice — both clang < 22 and clang 22+ pick the wrong subprogram on some C++ template addresses on wasm. | +| `test_aot_mode` | `apps/simple.c` (`-Oz -g`) | `--mode=aot` uses no `-1` adjustment (wamrc commits ip at instruction-start, not post-advance). The test picks an address at `low_pc+delta` and asserts the function still resolves under `--mode=aot`. Skips when `crash()` gets inlined away under `-Oz`. | +| `test_fast_interp_mode` | `fixtures/fast_interp_stack.txt` + `apps/simple.c` | The fixture has `$f0`/`$f1` indices; `--mode=fast-interp` forces function-name lookup (offsets aren't mappable). Asserts both indices resolve to the real wasm name-section entries (`crash`/`app_main`). | +| `test_no_addr_mode` | `fixtures/fast_interp_stack.txt` + `apps/simple.c` | Same fixture; `--no-addr` is the explicit form of function-name lookup. Additionally asserts the DWARF decl-file (`simple.c`) is surfaced (function-name lookup falls back to declaration-line, not call-site). | +| `test_offset_zero_fallback` | `apps/simple.c` (inline cs) | WAMR reports `offset=0` when `frame_ip` wasn't captured (trap at function entry, top frame of `iwasm -f`, etc.). The test asserts addr2line falls back to function-name resolution gracefully and prints the `app_main` name — does NOT crash or assert. | +| `test_empty_input` | `fixtures/empty_stack.txt` + `apps/simple.c` | A zero-line call stack should exit cleanly (rc=0) and produce no output. Defends against an `IndexError`-style crash when the loop body never runs. | +| `test_dispatch_message_in_stderr` | `apps/simple.c` (inline cs) | The version-dispatch decision (modern vs legacy resolver) goes to stderr ONLY when `-v` is passed; default is silent. Test runs addr2line twice with the same fixture address, once with and once without `-v`, and asserts the stderr-message visibility flips. | +| `test_modern_legacy_equivalence` | `apps/simple.c`, `--multi-sdk` only | Build `simple.c` once under a clang < 22 SDK and once under clang >= 22, run addr2line against both, and assert the outputs are identical after stripping per-SDK file paths. Proves the legacy-resolver overlay actually FIXES the bug rather than introducing its own divergence. Skipped unless `--multi-sdk` is passed AND at least one legacy + one modern SDK is installed. | + +Sources under `apps/` are written for the test suite, not copied from +`samples/`. They're small (~10–25 lines each) and target specific edge +cases that aren't covered by the sample-CI integration tests. + +## Running + +```bash +# Default: against $WASI_SDK_PATH or /opt/wasi-sdk +./run_tests.sh +# or +python3 -m pytest -v + +# Fixture-only (fast, no builds) +./run_tests.sh -m "not slow" + +# Multi-SDK: parametrize build-based tests over every detected +# /opt/wasi-sdk-*-x86_64-linux installation. Required for the +# legacy/modern equivalence test. +./run_tests.sh --multi-sdk + +# Verbose: print each test's addr2line input + stdout/stderr so you +# can see what got resolved. Use -v -s to surface it live; -v alone +# captures it and only shows it on failure. +./run_tests.sh -v -s +``` + +## Background: the LLVM symbolizer wasm bug + +`addr2line.py` exists primarily to convert WAMR call-stack dumps into +source file:line:column. On most clang versions this is a thin wrapper +around `llvm-symbolizer -f -i`. But on **wasm targets emitted by clang +< 22**, the symbolizer's address-to-function resolver picks the wrong +`DW_TAG_subprogram` for some addresses (e.g., reports `recurse` as +`free`). The line table is correct; only the function name is wrong. + +`addr2line.py` therefore detects the wasi-sdk's clang major version at +startup and routes through one of two resolvers: + +| Resolver | Used when | What it does | +|----------|-----------|--------------| +| `resolve_address_modern` | clang ≥ 22 (wasi-sdk 33+) | Single `llvm-symbolizer` call per address | +| `resolve_address_legacy` | clang < 22 | Symbolizer call + an outermost-name overlay from a startup-built `DW_TAG_subprogram` interval table (`build_subprogram_intervals`) | + +By default the dispatch decision is silent; pass `-v` to addr2line.py to +log which path was picked to stderr. + +The `test_modern_legacy_equivalence` test (multi-SDK only) verifies +that for the same input both paths produce identical output (after +normalizing per-SDK file paths). This proves the legacy overlay +actually fixes the bug rather than introducing its own divergence. + +## Why we left samples/debug-tools/llvm-bug-experiment behind + +The earlier experiment was useful for diagnosing the bug but wasn't a +proper test suite — it was a one-shot script that printed status to +stdout. This pytest suite covers more cases, runs in CI per-PR (default +SDK) and nightly (multi-SDK), and is decoupled from sample evolution. + +## Layout + +``` +test-tools/addr2line/tests/ +├── README.md # this file +├── conftest.py # fixtures (wasi_sdk, build_wasm, run_addr2line, ...) +├── test_addr2line.py # the tests +├── pytest.ini # marker definitions +├── run_tests.sh # convenience wrapper +├── apps/ # purpose-built test sources +│ ├── simple.c +│ ├── always_inline.c +│ ├── deep_inline_chain.c +│ ├── multi_file_recur_main.c +│ ├── multi_file_recur.c +│ ├── trap_in_loop.c +│ ├── multi_frame.c +│ └── cxx_mangled.cpp +└── fixtures/ # plaintext call-stack inputs + ├── fast_interp_stack.txt + ├── aot_stack.txt + └── empty_stack.txt +``` diff --git a/test-tools/addr2line/tests/apps/always_inline.c b/test-tools/addr2line/tests/apps/always_inline.c new file mode 100644 index 0000000000..4be1c6eaed --- /dev/null +++ b/test-tools/addr2line/tests/apps/always_inline.c @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Forced-inline helper containing the trap. The trap address falls + inside the inlined region, so addr2line.py must report both + `inner` and `outer` under a single runtime frame, with `inner` + carrying the `(inlined into outer)` suffix. */ + +__attribute__((always_inline)) static inline void +inner(void) +{ + __builtin_trap(); +} + +void +outer(void) +{ + inner(); +} + +void +app_main(void) +{ + outer(); +} diff --git a/test-tools/addr2line/tests/apps/cxx_mangled.cpp b/test-tools/addr2line/tests/apps/cxx_mangled.cpp new file mode 100644 index 0000000000..29fafedf57 --- /dev/null +++ b/test-tools/addr2line/tests/apps/cxx_mangled.cpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* C++ source with a templated and namespaced function. addr2line.py + should pass the mangled symbol through llvm-cxxfilt and produce a + readable demangled name in the output. */ + +namespace app { +namespace ns { + +template +__attribute__((noinline)) static void +crash_with(T) +{ + __builtin_trap(); +} + +class Worker +{ + public: + __attribute__((noinline)) void run() { crash_with(42); } +}; + +} // namespace ns +} // namespace app + +extern "C" void +app_main(void) +{ + app::ns::Worker w; + w.run(); +} diff --git a/test-tools/addr2line/tests/apps/deep_inline_chain.c b/test-tools/addr2line/tests/apps/deep_inline_chain.c new file mode 100644 index 0000000000..b392a13bc5 --- /dev/null +++ b/test-tools/addr2line/tests/apps/deep_inline_chain.c @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Four nested always_inline helpers. The trap inside level4 must + produce a 4-deep inline chain in the output, with consistent + indentation across all four frames. */ + +__attribute__((always_inline)) static inline void +level4(void) +{ + __builtin_trap(); +} +__attribute__((always_inline)) static inline void +level3(void) +{ + level4(); +} +__attribute__((always_inline)) static inline void +level2(void) +{ + level3(); +} +__attribute__((always_inline)) static inline void +level1(void) +{ + level2(); +} + +void +app_main(void) +{ + level1(); +} diff --git a/test-tools/addr2line/tests/apps/multi_file_recur.c b/test-tools/addr2line/tests/apps/multi_file_recur.c new file mode 100644 index 0000000000..c63ca8f25e --- /dev/null +++ b/test-tools/addr2line/tests/apps/multi_file_recur.c @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +static volatile int sink; + +int +recur(int depth) +{ + /* Allocate stack space and use it so the call isn't trivially + optimized away. Trap inside the loop body. */ + volatile char buf[64]; + buf[0] = (char)depth; + buf[63] = (char)(depth >> 8); + sink = buf[0] + buf[63]; + if (depth == 100) { + __builtin_trap(); + } + /* Use the return value after the call to prevent tail-call + conversion to a loop under -Oz -flto. */ + int r = recur(depth + 1); + return r + buf[0]; +} diff --git a/test-tools/addr2line/tests/apps/multi_file_recur_main.c b/test-tools/addr2line/tests/apps/multi_file_recur_main.c new file mode 100644 index 0000000000..b24b355ce7 --- /dev/null +++ b/test-tools/addr2line/tests/apps/multi_file_recur_main.c @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Multi-file recursion: app_main calls into recur() defined in another + TU. Compiled with -Oz -g -flto, the linker can inline cross-TU and the + trap address resolves to a chain that crosses files. This is the + canonical case that triggers the LLVM symbolizer wasm bug on clang < 22. */ + +int +recur(int depth); + +void +app_main(void) +{ + recur(0); +} diff --git a/test-tools/addr2line/tests/apps/multi_frame.c b/test-tools/addr2line/tests/apps/multi_frame.c new file mode 100644 index 0000000000..144fa40664 --- /dev/null +++ b/test-tools/addr2line/tests/apps/multi_frame.c @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Three noinline functions producing a 3-frame WASM call stack at the + trap (plus app_main). addr2line.py must report all four frames with + distinct indices. */ + +__attribute__((noinline)) void +bot(void) +{ + __builtin_trap(); +} + +__attribute__((noinline)) void +mid(void) +{ + bot(); +} + +__attribute__((noinline)) void +top(void) +{ + mid(); +} + +void +app_main(void) +{ + top(); +} diff --git a/test-tools/addr2line/tests/apps/simple.c b/test-tools/addr2line/tests/apps/simple.c new file mode 100644 index 0000000000..5a2d0eeb6e --- /dev/null +++ b/test-tools/addr2line/tests/apps/simple.c @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Baseline test: single function, single trap. The address of + __builtin_trap() must symbolicate back to file=simple.c, function=crash. */ + +void +crash(void) +{ + __builtin_trap(); +} + +void +app_main(void) +{ + crash(); +} diff --git a/test-tools/addr2line/tests/apps/trap_in_loop.c b/test-tools/addr2line/tests/apps/trap_in_loop.c new file mode 100644 index 0000000000..15546da174 --- /dev/null +++ b/test-tools/addr2line/tests/apps/trap_in_loop.c @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Intel Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* Trap inside a for-loop body. The trap address is neither at function + entry nor at function exit; it's mid-function. addr2line.py must + resolve it to the trap line, not the function declaration line. */ + +static volatile int sink; + +void +crash(void) +{ + for (int i = 0; i < 10; i++) { + sink = i; + if (i == 7) { + __builtin_trap(); + } + } +} + +void +app_main(void) +{ + crash(); +} diff --git a/test-tools/addr2line/tests/conftest.py b/test-tools/addr2line/tests/conftest.py new file mode 100644 index 0000000000..5cde064672 --- /dev/null +++ b/test-tools/addr2line/tests/conftest.py @@ -0,0 +1,276 @@ +# Copyright (C) 2026 Intel Corporation. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +""" +pytest fixtures for the addr2line.py test suite. + +Default mode: a single wasi-sdk (from $WASI_SDK_PATH or /opt/wasi-sdk). +With --multi-sdk: discover all /opt/wasi-sdk-*-x86_64-linux installations +and parametrize the wasi_sdk fixture so build-based tests run once per SDK. +""" + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + + +# --- CLI options ---------------------------------------------------------- + +def pytest_addoption(parser): + parser.addoption( + "--multi-sdk", + action="store_true", + default=False, + help="Run build-based tests against every detected " + "/opt/wasi-sdk-*-x86_64-linux installation (default: only " + "the SDK from WASI_SDK_PATH or /opt/wasi-sdk).", + ) + + +# --- Tool-discovery helpers ---------------------------------------------- + +def _detect_sdks_under_opt(): + """Return a sorted list of (version_str, Path) tuples for every + wasi-sdk under /opt that has a working clang AND ships + llvm-symbolizer (addr2line.py requires it for address resolution; + pre-29 bundles omit it). Newest version last.""" + found = [] + for d in sorted(Path("/opt").glob("wasi-sdk-*-x86_64-linux")): + if not (d / "bin" / "clang").exists(): + continue + if not (d / "bin" / "llvm-symbolizer").exists(): + continue + ver = d.name.replace("wasi-sdk-", "").replace("-x86_64-linux", "") + found.append((ver, d)) + return found + + +def _default_sdk(): + """Return the single SDK to use when --multi-sdk is not given.""" + env = os.environ.get("WASI_SDK_PATH") + if env and (Path(env) / "bin" / "clang").exists(): + return Path(env) + default = Path("/opt/wasi-sdk") + if (default / "bin" / "clang").exists(): + return default + return None + + +# --- Parametrization ------------------------------------------------------ + +def pytest_generate_tests(metafunc): + """Parametrize the wasi_sdk fixture based on --multi-sdk.""" + if "wasi_sdk" not in metafunc.fixturenames: + return + + multi = metafunc.config.getoption("--multi-sdk") + if multi: + sdks = _detect_sdks_under_opt() + if not sdks: + pytest.skip("--multi-sdk: no /opt/wasi-sdk-*-x86_64-linux found") + ids = [ver for ver, _ in sdks] + paths = [path for _, path in sdks] + metafunc.parametrize("wasi_sdk", paths, ids=ids, indirect=False) + else: + sdk = _default_sdk() + if sdk is None: + pytest.skip("no wasi-sdk found (set WASI_SDK_PATH or install /opt/wasi-sdk)") + metafunc.parametrize("wasi_sdk", [sdk], ids=[sdk.name], indirect=False) + + +# --- Session-scoped path fixtures ---------------------------------------- + +@pytest.fixture(scope="session") +def wabt(): + p = Path(os.environ.get("WABT_PATH", "/opt/wabt")) + if not (p / "bin" / "wasm-objdump").exists(): + pytest.skip(f"wabt not found at {p}") + return p + + +@pytest.fixture(scope="session") +def binaryen(): + p = Path(os.environ.get("BINARYEN_PATH", "/opt/binaryen")) + if not (p / "bin" / "wasm-opt").exists(): + pytest.skip(f"binaryen not found at {p}") + return p + + +@pytest.fixture(scope="session") +def addr2line_script(): + """Return the absolute path to addr2line.py.""" + here = Path(__file__).resolve().parent + return here.parent / "addr2line.py" + + +@pytest.fixture(scope="session") +def apps_dir(): + return Path(__file__).resolve().parent / "apps" + + +@pytest.fixture(scope="session") +def fixtures_dir(): + return Path(__file__).resolve().parent / "fixtures" + + +# --- Build / invoke fixtures --------------------------------------------- + +@pytest.fixture +def build_wasm(wasi_sdk, apps_dir, tmp_path_factory): + """Build wasm from sources under apps/ with the given flags. + + Cached per (sources tuple, flags tuple, language) within this test + session and SDK. + """ + cache = {} + + def _build(sources, flags, language="c"): + key = (tuple(sources), tuple(flags), language, str(wasi_sdk)) + if key in cache: + return cache[key] + + compiler = wasi_sdk / "bin" / ("clang++" if language == "cxx" else "clang") + outdir = tmp_path_factory.mktemp("build", numbered=True) + out_wasm = outdir / "out.wasm" + + cmd = [str(compiler)] + list(flags) + [ + "--target=wasm32-wasi", + "-Wl,--export=app_main", + "-Wl,--no-entry", + "-nostartfiles", + "-o", str(out_wasm), + ] + [str(apps_dir / s) for s in sources] + + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + raise AssertionError( + f"build failed:\n cmd: {' '.join(cmd)}\n" + f" stdout: {p.stdout}\n stderr: {p.stderr}" + ) + cache[key] = out_wasm + return out_wasm + + return _build + + +@pytest.fixture +def wasm_opt_pass(binaryen, tmp_path): + """Run wasm-opt on a wasm file and return the new path.""" + def _pass(input_wasm, args): + out = tmp_path / (input_wasm.stem + ".opt.wasm") + cmd = [str(binaryen / "bin" / "wasm-opt")] + list(args) + [ + "-o", str(out), str(input_wasm), + ] + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + raise AssertionError( + f"wasm-opt failed:\n cmd: {' '.join(cmd)}\n" + f" stdout: {p.stdout}\n stderr: {p.stderr}" + ) + return out + return _pass + + +@pytest.fixture +def run_addr2line(addr2line_script, wabt, tmp_path, wasi_sdk, request): + """Invoke addr2line.py and capture stdout/stderr/exitcode. + + `call_stack` may be either a list of strings (one per frame line) or + a Path to an existing call-stack file. `extra_args` is appended to + the command line (e.g. ['--mode', 'aot']). + + Under `pytest -v`/`-vv` (verbosity >= 1) the invocation and resolved + output are printed; combine with `-s` to see them live, otherwise + pytest captures them and only surfaces them on failure. + """ + verbose = request.config.getoption("verbose") >= 1 + + def _run(wasm_file, call_stack, sdk_override=None, extra_args=()): + sdk = sdk_override if sdk_override is not None else wasi_sdk + if isinstance(call_stack, (list, tuple)): + cs_file = tmp_path / "callstack.txt" + cs_file.write_text("\n".join(call_stack) + ("\n" if call_stack else "")) + else: + cs_file = Path(call_stack) + + cmd = [ + sys.executable, str(addr2line_script), + "--wasi-sdk", str(sdk), + "--wabt", str(wabt), + "--wasm-file", str(wasm_file), + *list(extra_args), + str(cs_file), + ] + p = subprocess.run(cmd, capture_output=True, text=True) + + if verbose: + test_id = request.node.name + print(f"\n--- [{test_id}] addr2line input ---") + if isinstance(call_stack, (list, tuple)): + for line in call_stack: + print(f" {line}") + else: + print(f" (from {cs_file})") + extra = " ".join(extra_args) if extra_args else "(none)" + print(f"--- [{test_id}] extra args: {extra} ---") + print(f"--- [{test_id}] addr2line stdout (rc={p.returncode}) ---") + print(p.stdout.rstrip()) + if p.stderr.strip(): + print(f"--- [{test_id}] addr2line stderr ---") + print(p.stderr.rstrip()) + print(f"--- [{test_id}] end ---") + + return p.stdout, p.stderr, p.returncode + + return _run + + +# --- Helpers ------------------------------------------------------------- + +def find_dwarf_low_pc(wasi_sdk: Path, wasm: Path, func_name: str) -> int | None: + """Find the DW_AT_low_pc of the named DW_TAG_subprogram in `wasm`. + Returns int or None if not found.""" + p = subprocess.run( + [str(wasi_sdk / "bin" / "llvm-dwarfdump"), str(wasm)], + capture_output=True, text=True, + ) + in_subp = False + low = None + for line in p.stdout.splitlines(): + s = line.strip() + if "DW_TAG_subprogram" in s: + in_subp = True + low = None + continue + if in_subp: + import re + m = re.match(r'DW_AT_low_pc\s+\((0x[0-9a-fA-F]+)\)', s) + if m: + low = int(m.group(1), 16) + continue + m = re.match(r'DW_AT_name\s+\("' + re.escape(func_name) + r'"\)', s) + if m and low is not None: + return low + if s.startswith("DW_TAG_") or s == "": + in_subp = False + low = None + return None + + +def code_section_start(wabt: Path, wasm: Path) -> int: + """Read the Code section start offset from wasm-objdump -h.""" + p = subprocess.run( + [str(wabt / "bin" / "wasm-objdump"), "-h", str(wasm)], + capture_output=True, text=True, check=True, + ) + import re + for line in p.stdout.splitlines(): + s = line.strip() + if "Code" in s and "start=" in s: + m = re.search(r"start=(0x[0-9a-fA-F]+)", s) + if m: + return int(m.group(1), 16) + raise AssertionError("Code section not found in " + str(wasm)) diff --git a/test-tools/addr2line/tests/fixtures/aot_stack.txt b/test-tools/addr2line/tests/fixtures/aot_stack.txt new file mode 100644 index 0000000000..0d3426f793 --- /dev/null +++ b/test-tools/addr2line/tests/fixtures/aot_stack.txt @@ -0,0 +1,2 @@ +#00: 0x1e0a - $f12 +#01: 0x1dcc - app_main diff --git a/test-tools/addr2line/tests/fixtures/empty_stack.txt b/test-tools/addr2line/tests/fixtures/empty_stack.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test-tools/addr2line/tests/fixtures/fast_interp_stack.txt b/test-tools/addr2line/tests/fixtures/fast_interp_stack.txt new file mode 100644 index 0000000000..51783a5b72 --- /dev/null +++ b/test-tools/addr2line/tests/fixtures/fast_interp_stack.txt @@ -0,0 +1,2 @@ +#00: 0x0001 - $f0 +#01: 0x0001 - $f1 diff --git a/test-tools/addr2line/tests/pytest.ini b/test-tools/addr2line/tests/pytest.ini new file mode 100644 index 0000000000..cca0386e57 --- /dev/null +++ b/test-tools/addr2line/tests/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +markers = + multi_sdk: parametrize over all detected wasi-sdk-*-x86_64-linux installations under /opt + slow: long-running test (typically a build + run); excluded by default if running -m "not slow" +testpaths = . diff --git a/test-tools/addr2line/tests/run_tests.sh b/test-tools/addr2line/tests/run_tests.sh new file mode 100755 index 0000000000..7143e6b2fe --- /dev/null +++ b/test-tools/addr2line/tests/run_tests.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Copyright (C) 2019 Intel Corporation. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Thin wrapper around pytest for the addr2line.py test suite. +# +# Usage: +# ./run_tests.sh # default: single SDK from $WASI_SDK_PATH +# ./run_tests.sh -m "not slow" # only fixture-based tests +# ./run_tests.sh --multi-sdk # parametrize over /opt/wasi-sdk-* +# +# Env vars consumed: +# WASI_SDK_PATH (default /opt/wasi-sdk) +# WABT_PATH (default /opt/wabt) +# BINARYEN_PATH (default /opt/binaryen) + +set -euo pipefail +cd "$(dirname "$0")" +exec python3 -m pytest "$@" diff --git a/test-tools/addr2line/tests/test_addr2line.py b/test-tools/addr2line/tests/test_addr2line.py new file mode 100644 index 0000000000..9a299e81cd --- /dev/null +++ b/test-tools/addr2line/tests/test_addr2line.py @@ -0,0 +1,369 @@ +# Copyright (C) 2026 Intel Corporation. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +""" +Test suite for test-tools/addr2line/addr2line.py. + +Most tests build a wasm from a single apps/*.c source, capture the trap +address from DWARF, and check that addr2line.py resolves it correctly. + +Multi-SDK behavior is opt-in via `pytest --multi-sdk`. Cross-version +equivalence (test_modern_legacy_equivalence) requires it; everything +else runs against whichever SDK is active. +""" + +import re +import subprocess +from pathlib import Path + +import pytest + +from conftest import find_dwarf_low_pc, code_section_start + + +# Format a wasm file offset that points at a real instruction inside +# the function body, given its DWARF low_pc and the wasm Code section +# start. Offset is `code_section_start + low_pc + delta + 1` because +# addr2line.py applies a -1 adjustment for classic-interp mode. +def _stack_line(wabt, wasm, wasi_sdk, func_name, delta=1, frame_idx=0, + func_label=None): + cs = code_section_start(wabt, wasm) + low = find_dwarf_low_pc(wasi_sdk, wasm, func_name) + if low is None: + pytest.skip(f"DW_TAG_subprogram for {func_name} not found in {wasm}") + # +1 to compensate for addr2line.py's -1 in interp mode + file_off = cs + low + delta + 1 + label = func_label or "$f0" + return f"#{frame_idx:02d}: 0x{file_off:04x} - {label}" + + +# --- Build-based tests --------------------------------------------------- + +@pytest.mark.slow +def test_simple_resolution(build_wasm, wabt, run_addr2line, wasi_sdk): + """Single-function trap resolves to function=crash, file=simple.c.""" + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + line = _stack_line(wabt, wasm, wasi_sdk, "crash", delta=1, + func_label="$f0") + out, err, rc = run_addr2line(wasm, [line]) + assert rc == 0 + assert "crash" in out + assert "simple.c" in out + + +@pytest.mark.slow +def test_inline_chain_basic(build_wasm, wabt, run_addr2line, wasi_sdk): + """always_inline helper at trap site produces 2-frame inline chain.""" + wasm = build_wasm(["always_inline.c"], ["-O0", "-g"]) + # Trap is inside `inner` (always_inline'd into outer) + line = _stack_line(wabt, wasm, wasi_sdk, "outer", delta=2, + func_label="$f0") + out, err, rc = run_addr2line(wasm, [line]) + assert rc == 0 + assert "inner" in out + assert "outer" in out + # Inline annotation must appear + assert "(inlined into" in out + + +@pytest.mark.slow +def test_inline_chain_deep(build_wasm, wabt, run_addr2line, wasi_sdk): + """4-level inline chain produces all 4 frames in order.""" + wasm = build_wasm(["deep_inline_chain.c"], ["-O0", "-g"]) + # All 4 levels inlined into app_main; pick low_pc(app_main)+small + line = _stack_line(wabt, wasm, wasi_sdk, "app_main", delta=2, + func_label="$f0") + out, err, rc = run_addr2line(wasm, [line]) + assert rc == 0 + # All four levels should appear somewhere in the output + for fn in ("level1", "level2", "level3", "level4"): + assert fn in out, f"missing {fn} in output:\n{out}" + assert "(inlined into" in out + # The OUTERMOST frame must be app_main (not a wasi-libc symbol like + # `free` / `__multi3` that overlaps app_main's PC range — that's the + # exact regression the legacy resolver's subprogram-name overlay + # exists to prevent). + assert re.search(r"^\s+app_main\b", out, re.MULTILINE), ( + f"outermost frame is not app_main:\n{out}" + ) + + +@pytest.mark.slow +def test_cross_tu_inline(build_wasm, wasm_opt_pass, wabt, run_addr2line, + wasi_sdk): + """Multi-file recursion under -Oz -g -flto + wasm-opt -Oz -g. + + `wasm-opt -Oz -g` reorders DWARF in ways that confuse both legacy + and modern symbolizer paths on the function-name lookup; this test + pins the more limited contract that the line table itself stays + inside our sources (or returns `??:0`), never a wasi-libc path. + Outermost-name correctness for non-mangled DWARF is covered by + test_inline_chain_deep. + """ + raw = build_wasm( + ["multi_file_recur_main.c", "multi_file_recur.c"], + ["-Oz", "-g", "-flto"], + ) + debug = wasm_opt_pass(raw, ["-Oz", "-g"]) + line = _stack_line(wabt, debug, wasi_sdk, "recur", delta=0x40, + func_label="$f0") + out, err, rc = run_addr2line(debug, [line]) + assert rc == 0 + assert ( + "multi_file_recur" in out + or "??:0" in out + ), f"line table did not resolve to multi_file_recur* source:\n{out}" + + +@pytest.mark.slow +def test_trap_mid_function(build_wasm, wabt, run_addr2line, wasi_sdk): + """Trap inside a loop body resolves to the trap line, not entry line.""" + wasm = build_wasm(["trap_in_loop.c"], ["-O0", "-g"]) + # Probe well past entry so the line table catches the trap line + line = _stack_line(wabt, wasm, wasi_sdk, "crash", delta=0x20, + func_label="$f0") + out, err, rc = run_addr2line(wasm, [line]) + assert rc == 0 + assert "crash" in out + assert "trap_in_loop.c" in out + # The trap is on its own line — output should reference a line >= 14 + # (function declaration is on line ~10, trap is below it) + m = re.search(r"trap_in_loop\.c:(\d+)", out) + assert m is not None, f"no file:line in output:\n{out}" + line_num = int(m.group(1)) + assert line_num > 10, ( + f"resolved to line {line_num}, expected > 10 (post-declaration)" + ) + + +@pytest.mark.slow +def test_multi_frame_callstack(build_wasm, wabt, run_addr2line, wasi_sdk): + """Multi-frame call stack renders all frames with distinct indices.""" + wasm = build_wasm(["multi_frame.c"], ["-O0", "-g"]) + lines = [] + for idx, fn in enumerate(["bot", "mid", "top", "app_main"]): + lines.append( + _stack_line(wabt, wasm, wasi_sdk, fn, delta=2, + frame_idx=idx, func_label="$f0") + ) + out, err, rc = run_addr2line(wasm, lines) + assert rc == 0 + for fn in ("bot", "mid", "top", "app_main"): + assert fn in out, f"missing {fn} in output:\n{out}" + + +@pytest.mark.slow +def test_cxx_demangling(build_wasm, wabt, run_addr2line, wasi_sdk): + """C++ symbols come through the toolchain in a readable form. + + The DWARF that wasi-sdk's clang emits stores the demangled name in + DW_AT_name and the mangled `_Z...` form in DW_AT_linkage_name; both + llvm-symbolizer and the wasm name section emit the demangled form, + so addr2line.py's llvm-cxxfilt pass is usually a no-op. This test + asserts the end-to-end output is human-readable: source file from + our sample, line numbers present, no mangled `_Z...` leaks. + + Symbolizer-reported function names for templated/namespaced symbols + are not asserted — both clang < 22 and clang 22+ pick the wrong + DW_TAG_subprogram for some C++ template addresses on wasm targets + (e.g. reporting `app_main` for an address that DWARF clearly puts + inside `crash_with`). The line table is correct in both cases. + """ + wasm = build_wasm(["cxx_mangled.cpp"], ["-O0", "-g"], language="cxx") + # Look up by the DWARF DW_AT_name (already demangled, with template args). + line = _stack_line(wabt, wasm, wasi_sdk, "crash_with", + delta=2, func_label="$f0") + out, err, rc = run_addr2line(wasm, [line]) + assert rc == 0 + # No mangled `_Z...` should ever leak to the user. + assert "_ZN" not in out, f"mangled prefix leaked:\n{out}" + # Source file should resolve to our sample (line table is reliable). + assert "cxx_mangled.cpp" in out, ( + f"line table did not resolve to cxx_mangled.cpp:\n{out}" + ) + + +@pytest.mark.slow +def test_aot_mode(build_wasm, wasm_opt_pass, wabt, run_addr2line, wasi_sdk): + """--mode=aot uses no -1 adjustment and resolves correctly. + + For an address that's exactly at low_pc+delta (no -1), --mode=aot must + still resolve to the right function (whereas --mode=interp would + apply -1 and possibly land outside the function range). + """ + raw = build_wasm(["simple.c"], ["-Oz", "-g"]) + debug = wasm_opt_pass(raw, ["-Oz", "-g"]) + cs = code_section_start(wabt, debug) + low = find_dwarf_low_pc(wasi_sdk, debug, "crash") + if low is None: + pytest.skip("crash() inlined away under -Oz; skipping AOT mode test") + aot_offset = cs + low + 4 # AOT path uses offset verbatim + cs_line = f"#00: 0x{aot_offset:04x} - $f0" + out, err, rc = run_addr2line(debug, [cs_line], extra_args=["--mode", "aot"]) + assert rc == 0 + assert "crash" in out + + +# --- Fixture-based tests (no build) -------------------------------------- + +def test_fast_interp_mode(addr2line_script, wabt, wasi_sdk, fixtures_dir, + run_addr2line, build_wasm): + """--mode=fast-interp falls back to function-name lookup (no addr math). + + The fixture's $f0 and $f1 must resolve to simple.c's two functions + (`crash` and `app_main`) via the wasm name section. + """ + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + out, err, rc = run_addr2line( + wasm, fixtures_dir / "fast_interp_stack.txt", + extra_args=["--mode", "fast-interp"], + ) + assert rc == 0 + assert "crash" in out, f"expected $f0 -> crash:\n{out}" + assert "app_main" in out, f"expected $f1 -> app_main:\n{out}" + + +def test_no_addr_mode(build_wasm, run_addr2line, fixtures_dir): + """--no-addr resolves by function name, not address. + + Same fixture as fast-interp: $f0 -> crash, $f1 -> app_main. Under + --no-addr addr2line should additionally surface the decl-line from + DWARF (not just the function name). + """ + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + out, err, rc = run_addr2line( + wasm, fixtures_dir / "fast_interp_stack.txt", + extra_args=["--no-addr"], + ) + assert rc == 0 + assert "crash" in out, f"expected $f0 -> crash:\n{out}" + assert "app_main" in out, f"expected $f1 -> app_main:\n{out}" + assert "simple.c" in out, f"expected DWARF source file:\n{out}" + + +def test_offset_zero_fallback(build_wasm, run_addr2line): + """Runtime offset=0 falls back gracefully (no assertion crash).""" + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + out, err, rc = run_addr2line(wasm, ["#00: 0x0000 - app_main"]) + assert rc == 0 + # Output should mention app_main even with no useful offset + assert "app_main" in out + + +def test_empty_input(build_wasm, run_addr2line, fixtures_dir): + """Empty call stack file produces clean exit, minimal output.""" + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + out, err, rc = run_addr2line(wasm, fixtures_dir / "empty_stack.txt") + assert rc == 0 + + +# --- Cross-cutting ------------------------------------------------------- + +def test_dispatch_message_in_stderr(build_wasm, run_addr2line): + """The version-dispatch decision is logged to stderr at startup + when --verbose is passed; silent otherwise.""" + wasm = build_wasm(["simple.c"], ["-O0", "-g"]) + + # Default: stderr is silent (no dispatch message). + out, err, rc = run_addr2line(wasm, ["#00: 0x0040 - $f0"]) + assert rc == 0 + assert "modern resolver" not in err and "legacy resolver" not in err, ( + f"dispatch message leaked to stderr without -v:\n{err}" + ) + + # With -v: one of the three known messages must appear. + out, err, rc = run_addr2line(wasm, ["#00: 0x0040 - $f0"], + extra_args=["-v"]) + assert rc == 0 + assert ( + "modern resolver" in err + or "legacy resolver" in err + ), f"no dispatch message in stderr with -v:\n{err}" + + +@pytest.mark.multi_sdk +@pytest.mark.slow +def test_modern_legacy_equivalence(build_wasm, wasm_opt_pass, wabt, + addr2line_script, apps_dir, tmp_path): + """Multi-SDK only: legacy and modern paths produce equivalent output. + + Build the same source once per detected SDK and assert the + symbolicated stdout strings match (after stripping per-SDK file paths). + Skipped unless --multi-sdk is given AND at least 2 SDKs detected. + """ + pytest.importorskip("subprocess") # already always available; placeholder + + # Detect SDKs directly here (the wasi_sdk fixture is per-test, not a list) + from conftest import _detect_sdks_under_opt + sdks = _detect_sdks_under_opt() + if len(sdks) < 2: + pytest.skip("need >= 2 SDKs for equivalence test") + + # Pick one with clang < 22 and one with clang >= 22 if possible + import sys + sys.path.insert(0, str(addr2line_script.parent)) + from addr2line import detect_clang_major_version + + def _major(sdk_path: Path): + return detect_clang_major_version(sdk_path) + + # Require llvm-symbolizer in the SDK — older wasi-sdk packages + # (e.g. sdk25) ship without it and addr2line.py can't run. + def _has_symbolizer(p: Path): + return (p / "bin" / "llvm-symbolizer").exists() + + legacy_sdks = [(v, p) for v, p in sdks + if (_major(p) or 0) < 22 and _has_symbolizer(p)] + modern_sdks = [(v, p) for v, p in sdks + if (_major(p) or 0) >= 22 and _has_symbolizer(p)] + if not legacy_sdks or not modern_sdks: + pytest.skip( + f"need at least one legacy (clang<22) AND one modern (clang>=22) " + f"SDK with llvm-symbolizer present; " + f"have legacy={[v for v,_ in legacy_sdks]}, " + f"modern={[v for v,_ in modern_sdks]}" + ) + + # Build the same source under both SDKs and compare outputs. + # Use simple.c so every SDK can build it without flag interactions. + legacy_ver, legacy_path = legacy_sdks[0] + modern_ver, modern_path = modern_sdks[0] + + def _build_and_resolve(sdk_path): + # Build using subprocess directly so we don't tangle with the + # parametrized wasi_sdk fixture. + out_wasm = tmp_path / f"out_{sdk_path.name}.wasm" + compiler = sdk_path / "bin" / "clang" + cmd = [ + str(compiler), + "--target=wasm32-wasi", "-O0", "-g", + "-Wl,--export=app_main", "-Wl,--no-entry", "-nostartfiles", + "-o", str(out_wasm), + str(apps_dir / "simple.c"), + ] + r = subprocess.run(cmd, capture_output=True, text=True) + assert r.returncode == 0, r.stderr + + # Resolve via addr2line.py with this SDK + cs = tmp_path / f"cs_{sdk_path.name}.txt" + cs.write_text("#00: 0x0040 - $f0\n") + cmd = [ + "python3", str(addr2line_script), + "--wasi-sdk", str(sdk_path), + "--wabt", str(wabt), + "--wasm-file", str(out_wasm), + str(cs), + ] + r = subprocess.run(cmd, capture_output=True, text=True) + return r.stdout + + legacy_out = _build_and_resolve(legacy_path) + modern_out = _build_and_resolve(modern_path) + + # Strip absolute file paths to compare (the per-SDK build dirs differ) + def _normalize(s): + return re.sub(r"/[^\s:]+/", "/.../", s) + + assert _normalize(legacy_out) == _normalize(modern_out), ( + f"legacy ({legacy_ver}) and modern ({modern_ver}) outputs differ:\n" + f"--- legacy ---\n{legacy_out}\n--- modern ---\n{modern_out}" + )