From 5fe8a00cdbd27deeabdf349ac0a931b4fe84ad38 Mon Sep 17 00:00:00 2001 From: TianlongLiang Date: Thu, 25 Jun 2026 10:16:26 +0800 Subject: [PATCH] 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 the interval-table overlay contract and the tool's other behaviors: - Baseline single-function resolution - Inline expansion (always_inline, 4-level deep chain) - Cross-TU LTO inlining (multi-file recursion + wasm-opt -Oz -g, which exercises both LLVM outermost-name failure modes) - 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 - Skip-guard invariant on build_subprogram_intervals (test_build_subprogram_intervals_filters_invalid_dies): rejects DW_AT_declaration=true DIEs, low_pc == 0 (wasm-opt DCE ghosts), and high_pc <= low_pc entries at parse time. Layout: test-tools/addr2line/tests/ ├── README.md -- documentation ├── conftest.py -- pytest fixtures (sdk discovery, build, │ run_addr2line invocation, multi-sdk │ parametrization) ├── test_addr2line.py -- 12 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. Multi-SDK mode (--multi-sdk) parametrizes build-based tests over every detected wasi-sdk under /opt to catch DWARF-encoding differences between clang versions. Depends on the addr2line.py refactor. --- test-tools/addr2line/tests/README.md | 140 ++++++++ .../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 | 277 ++++++++++++++++ .../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 | 298 ++++++++++++++++++ 16 files changed, 959 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..74a35b7532 --- /dev/null +++ b/test-tools/addr2line/tests/README.md @@ -0,0 +1,140 @@ +# 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_build_subprogram_intervals_filters_invalid_dies` | `apps/simple.c` (+ wasm-opt post-process) | Builds a wasi-libc-linked wasm and post-processes with `wasm-opt -Oz -g` to trigger the DCE-ghost pattern. Asserts the resulting interval table contains no `low_pc = 0` entries and no `high_pc <= low_pc` entries. Guards the skip-guard invariant in `build_subprogram_intervals`. | +| `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 trigger for both LLVM outermost-name failure modes: two TUs, `-Oz -flto`, then `wasm-opt -Oz -g` (which produces DCE ghosts). Both the symbol-table override and the AddrDieMap invariant violation apply 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. | + +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. Run each build-based +# test once per detected wasi-sdk. +./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: why the interval-table overlay exists + +`addr2line.py` exists primarily to convert WAMR call-stack dumps into +source `file:line:column`. On most well-behaved DWARF inputs this is +a thin wrapper around `llvm-symbolizer -f -i`. But on wasm targets, +the symbolizer's OUTERMOST frame's function name can be wrong for +two independent reasons — both source-verified against upstream +LLVM (`SymbolizableObjectFile.cpp` and `DWARFUnit.cpp`): + +1. **Symbol-table override on the outermost frame** (LLVM design + choice). `symbolizeInlinedCode` unconditionally rewrites the + outermost frame's `FunctionName` from `getNameFromSymbolTable` + whenever `FunctionNameKind::LinkageName` and `UseSymbolTable` are + both set. Inline frames are provably NOT rewritten — the override + is applied only to `getMutableFrame(getNumberOfFrames() - 1)`. On + some wasi-sdk builds the symbol-table lookup returns the wrong + function for an address that DWARF places correctly. +2. **`AddrDieMap` invariant violation** (root cause in binaryen's + DWARF preservation model). `wasm-opt -Oz -g` deletes functions + from the wasm module but its DWARF updater does not remove the + corresponding `DW_TAG_subprogram` DIEs — instead, when an old + address can no longer be mapped to a surviving IR node, + `LocationUpdater::getNewFuncStart` in `src/wasm/wasm-debug.cpp` + returns `0` as a tombstone value and `updateDIE` writes it into + `DW_AT_low_pc`. The `DW_TAG_subprogram` remains in place; sometimes + its `DW_AT_high_pc` retains its original value. Result: stale + "ghost" DIEs like `fmaf [0, 0x72)`. LLVM's `updateAddressDieMap` + in `DWARFUnit.cpp` assumes children are inserted after parents + and each child range ⊆ parent range; these ghosts violate that + invariant, so the map's `upper_bound − 1` lookup returns an + insertion-order-dependent DIE for real addresses. + +The symptom is the same in both cases — only the OUTERMOST frame's +function name is wrong. Line/column info and inner inlined frame +names are correct because they come from a different LLVM code path +(`getInliningInfoForAddress`, which walks `getParent()` links from +the leaf DIE and does NOT go through the symbol-table override). + +`addr2line.py` always runs `llvm-dwarfdump --debug-info` at startup, +builds a `(low_pc, high_pc, name)` interval table for every real +callable `DW_TAG_subprogram` (three skip guards described below), and +overlays the outermost frame's name from an innermost-wins lookup on +that table. + +### Skip guards applied to the interval table + +- `DW_AT_declaration=true` → forward declaration only, no code. +- `low_pc == 0` → structurally impossible for real code (wasm binary + format guarantees the Code section starts after Type + Function + section headers). Any `low_pc = 0` DIE is a wasm-opt DCE ghost or + a broken producer's output. +- `high_pc <= low_pc` → empty or degenerate range. Never observed + in practice; kept as a defense-in-depth guard against future + toolchain changes. + +Multi-SDK CI (`./run_tests.sh --multi-sdk`) exercises the parser +against DWARF produced by wasi-sdk 29 (clang 21) and wasi-sdk 33 +(clang 22) to catch DIE-encoding differences between clang versions. + +## 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..242e321181 --- /dev/null +++ b/test-tools/addr2line/tests/conftest.py @@ -0,0 +1,277 @@ +# 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 wasi-sdk 29+; pre-29 + bundles omit llvm-symbolizer and are filtered out here. 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..4e322a58ca --- /dev/null +++ b/test-tools/addr2line/tests/test_addr2line.py @@ -0,0 +1,298 @@ +# 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` — build-based +tests re-run once per detected SDK to catch DWARF-encoding changes +across clang versions. +""" + +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 interval-table overlay in resolve_address + # 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 ------------------------------------------------------- + + + +@pytest.mark.slow +def test_build_subprogram_intervals_filters_invalid_dies( + build_wasm, wasm_opt_pass, wasi_sdk, addr2line_script +): + """Guard against wasm-opt DCE ghosts and DW_AT_declaration DIEs + leaking into the interval table. + + Empirically, `wasm-opt -Oz -g` leaves DIEs for dead-code-eliminated + compiler-rt / libm helpers behind with low_pc=0 and tiny/empty + high_pc. Those must NOT appear as entries in the interval table. + """ + import sys + sys.path.insert(0, str(addr2line_script.parent)) + from addr2line import build_subprogram_intervals + + # Build a wasi-libc-linked wasm and post-process with wasm-opt -Oz -g + # to trigger the DCE-ghost pattern. + raw = build_wasm(["simple.c"], ["-Oz", "-g", "-flto"]) + debug = wasm_opt_pass(raw, ["-Oz", "-g"]) + dwarf_dump = wasi_sdk / "bin" / "llvm-dwarfdump" + + intervals = build_subprogram_intervals(dwarf_dump, debug) + assert intervals, "expected at least one subprogram in the interval table" + + # No entry may have low_pc == 0 (structurally impossible for real code: + # the wasm Code section never starts at file offset 0). + zero_lowpc = [(lo, hi, nm) for (lo, hi, nm) in intervals if lo == 0] + assert not zero_lowpc, ( + f"interval table contains low_pc=0 entries (wasm-opt DCE ghosts): " + f"{zero_lowpc[:5]}" + ) + + # No entry may have high_pc <= low_pc. + empty = [(lo, hi, nm) for (lo, hi, nm) in intervals if hi <= lo] + assert not empty, f"interval table contains empty ranges: {empty[:5]}" +