Skip to content

Repair invalid DWARF scope ranges after transforms - #8964

Open
cpunion wants to merge 3 commits into
WebAssembly:mainfrom
cpunion:codex/fix-dwarf-range-topology-6406
Open

cpunion wants to merge 3 commits into
WebAssembly:mainfrom
cpunion:codex/fix-dwarf-range-topology-6406

Conversation

@cpunion

@cpunion cpunion commented Aug 2, 2026

Copy link
Copy Markdown

Summary

  • preserve the DWARF all-ones tombstone and LLVM's legacy max-minus-one encoding instead of remapping dead DIEs to address zero
  • reject lost or reversed low/high PC pairs without unsigned wraparound
  • normalize updated range lists and rebuild range-list parents from surviving child scopes
  • repair scope trees bottom-up and make ambiguous overlapping sibling subtrees unavailable

Binaryen currently updates DWARF range endpoints independently. When optimization or Asyncify removes or reorders expressions, the resulting endpoints can wrap, overlap, or escape their parent scope. This is the same failure mode reported in #6406, extended to range lists and scope topology.

The repair is conservative: representable parent unions are preserved, while ambiguous scopes fail closed. Empty replacement range lists are appended rather than mutating lists that another DIE may share.

Implementation

Range-set normalization, union, containment, and overlap are isolated in DwarfRanges and directly unit-tested. The DWARF adapter keeps encoding-specific constants and tombstone rules in wasm-debug.cpp, including the distinction between a valid low_pc = 0 and range-list terminators.

The repair builds an explicit parent/child index for each compilation unit. It first propagates malformed or unavailable scopes through that tree, then processes children before parents so sibling overlap checks see final ranges and range-list parents can be extended before their own containment check. This also avoids repeated descendant scans and avoids relying on default depths for null DIE terminators.

Tombstone handling

"Nonzero tombstone" was imprecise shorthand. DWARF issue 200609.1, accepted for DWARF v6, reserves the largest representable target address (for example, 0xffffffff for wasm32) for a non-existent entity. LLVM implements this as dwarf::computeTombstoneAddress and has a WebAssembly-specific test for a dead wasm32 subprogram.

The max-minus-one value (-2) is an LLVM legacy compatibility encoding rather than a general DWARF value. It is recognized for legacy .debug_ranges/.debug_loc data because all-ones is already the base-address-selection marker and (0, 0) terminates the list. See LLVM D81784 and DWARFDebugRangeList.cpp.

Binaryen already recognizes 0, -1, and -2 in tombstone-aware contexts. This change prevents updateDIE from passing -1/-2 through the instruction-offset mapper, where they can be rewritten to zero and make a dead DIE appear to refer to address zero.

Validation

  • all 382 C++ unit tests, including direct DwarfRanges tests
  • full python3 check.py wasm-opt --no-torture suite
  • a minimal DWARF v4 roundtrip test asserting that two DW_AT_low_pc = 0xffffffff values remain intact
  • llvm-dwarfdump --verify after roundtrip for class_with_dwarf_noprint, fannkuch3_manyopts_dwarf, fib2_dwarf, fib2_emptylocspan_dwarf, ignore_missing_func_dwarf, inlined_to_start_dwarf, and reverse_dwarf_abbrevs
  • llvm-dwarfdump --verify after --asyncify -O -g for class_with_dwarf_noprint
  • a 73-CU Emscripten final module that previously reported containment, internal-overlap, and sibling-overlap errors now verifies with no errors after its final Asyncify transform

A wasm32 object with DW_AT_low_pc = 0xffffffff is reported by LLVM as dead code; before this fix, wasm-opt -O -g rewrites it to 0x00000000.

Fixes #6406.

@kripken

kripken commented Aug 5, 2026

Copy link
Copy Markdown
Member

This looks large and complicated, and we don't have deep DWARF expertise here, so I am worried. But let me ask first, as background: what is a nonzero tombstone? Is that documented somewhere in LLVM or DWARF?

@cpunion

cpunion commented Aug 9, 2026

Copy link
Copy Markdown
Author

Thanks. “nonzero tombstone” was imprecise shorthand rather than a formal DWARF term.

The all-ones address is documented by DWARF issue 200609.1, accepted for DWARF v6, as the reserved address for a non-existent entity:

https://dwarfstd.org/issues/200609.1.html

LLVM implements this as dwarf::computeTombstoneAddress. It also has a WebAssembly-specific test where DW_AT_low_pc = 0xffffffff marks a wasm32 subprogram as dead code:

https://github.com/llvm/llvm-project/blob/main/llvm/include/llvm/BinaryFormat/Dwarf.h
https://github.com/llvm/llvm-project/blob/main/llvm/test/tools/llvm-debuginfo-analyzer/WebAssembly/wasm-32bit-tombstone.s

The max-minus-one value (-2) is separate: it is an LLVM-recognized compatibility encoding for legacy .debug_ranges/.debug_loc, where all-ones is already the base-address-selection marker and (0, 0) terminates the list:

https://reviews.llvm.org/D81784
https://github.com/llvm/llvm-project/blob/main/llvm/lib/DebugInfo/DWARF/DWARFDebugRangeList.cpp

So -1 has DWARF backing, while -2 should be described as an LLVM legacy compatibility encoding, not a general DWARF value.

Binaryen already recognizes 0, -1, and -2 in isTombstone. The bug here is that updateDIE passes -1/-2 through the instruction-offset mapper, which rewrites them to zero and can make a dead DIE appear to refer to address zero. I reproduced that behavior with Binaryen 125: LLVM reports the input DW_AT_low_pc = 0xffffffff as dead code, but after wasm-opt -O -g it becomes 0x00000000.

I will update the PR wording to use the precise terms and references. I can also split the small tombstone-preservation change from the broader scope-range repair to make the review easier.

@kripken

kripken commented Aug 20, 2026

Copy link
Copy Markdown
Member

Thanks for the info. After reading some of that, I am afraid I don't think I have the expertise to review this.

Can you say more about the use case that you want this for? Perhaps there is another way to achieve it. For example, our source maps support is a lot more robust, and maybe that is enough - it does provide source locations through transformations?

@stevenfontanella
stevenfontanella removed their request for review August 21, 2026 19:24
Preserve nonzero tombstones, reject lost or reversed low/high pairs, normalize range lists, and repair parent scope ranges from surviving children. Ambiguous sibling scopes are made unavailable instead of being assigned incorrect code ranges.
@cpunion
cpunion force-pushed the codex/fix-dwarf-range-topology-6406 branch from eb002d1 to a16642f Compare September 17, 2026 07:04
@cpunion

cpunion commented Sep 17, 2026

Copy link
Copy Markdown
Author

Thanks for following up! Let me clarify the fundamental difference in use case between Source Maps and DWARF, share concrete E2E verification results, provide options for splitting this PR to ease review, and provide reproducible test artifacts demonstrating why this change is essential.


1. Capability Comparison: Source Maps vs. DWARF

Source maps and DWARF address two entirely different layers of debugging in WebAssembly:

Debugging Capability Source Maps Only Current DWARF (without PR) Repaired DWARF (this PR)
Source Line Breakpoints & Stepping ✅ Yes ✅ Yes Yes
Stack Trace Source Line Mapping ✅ Yes ✅ Yes Yes
Inspect Native Local Variables by Name No (only shows raw $var0, $var1) ⚠️ Flaky (crashes on opt) Yes (Full inspection)
Complex Types (Structs, Classes, Pointers) No (linear memory is raw buffer) ⚠️ Flaky Yes (Fields & layouts)
Console Variable Evaluation (eval) No (ReferenceError: <var> not defined) ⚠️ Flaky Yes (Direct evaluation)
Scope Topology after Optimizations (-O3, --asyncify) ➖ (No scope concept) Corrupted (4.29 GB underflow, tombstone rewritten to 0) Repaired & Fail-Closed
llvm-dwarfdump --verify Conformance ➖ N/A Fails (Errors detected) Clean Pass (No errors)

2. Two Concrete Facts & Verification

Fact 1: Source Maps fundamentally cannot inspect or evaluate native variables

We verified this directly in Chrome DevTools / V8 Inspector Protocol. When paused at a breakpoint inside $main on a module compiled with -gsource-map:

  • The Scope panel contains no C/C++ variables at all—it only displays untyped Wasm registers:
    Scope:
      Local ($main):
        $var0: undefined
        $var1: undefined
    
  • Evaluating any C/C++ variable in DevTools Console / Debugger.evaluateOnCallFrame immediately yields:
    Uncaught ReferenceError: my_secret_var is not defined
        at Object.eval (<anonymous>:1:1)
        at $main (test.cpp:7)
    

Source maps only specify bytecode-to-source-line mappings. They have no protocol representation for variable names, stack offsets, or type layouts. DWARF is strictly required for actual variable inspection in native debuggers (Chrome DevTools DWARF extension, LLDB, GDB).

Fact 2: Current main corrupts DWARF topology, while this PR passes verification cleanly

Running wasm-opt test/passes/class_with_dwarf_noprint.wasm --asyncify -O -g -o out.wasm:

  • Current main (Without this PR):
    llvm-dwarfdump --verify out.wasm fails with:

    error: DIE address ranges are not contained in its parent's ranges:
    0x000000e9: DW_TAG_subprogram ("main", length 0x1cf)
    0x0000011f:   DW_TAG_lexical_block
                    DW_AT_low_pc  (0x000000ad)
                    DW_AT_high_pc (0xffffff53)  <-- unsigned 32-bit underflow creating an impossible 4.29 GB range!
    
    error: DIE address ranges are not contained in its parent's ranges:
    0x0000015e:   DW_TAG_inlined_subroutine [0x00000006, 0x00000007) <-- escaped parent lexical block
    error: DIE address ranges are not contained by parent ranges occurred 2 time(s).
    Errors detected.
    

    Additionally, dead DIEs (such as call sites in fannkuch3_manyopts_dwarf.wasm) have their tombstone values (-1/-2) rewritten to 0x00000000, creating bogus entities at address zero. Querying PC addresses via debuggers (--lookup) matches these corrupt 4.29 GB scopes, causing debuggers to fail or crash.

  • With this PR:
    The exact same command on the same input passes cleanly:

    Verifying out.wasm: file format WASM
    Verifying .debug_abbrev...
    Verifying .debug_info Unit Header Chain...
    Verifying non-dwo Units...
    Verifying .debug_line...
    No errors.
    

    The underflowing block is safely tombstoned as DW_AT_low_pc = 0xffffffff (dead code), DW_AT_high_pc = 0, and ambiguous children gracefully point to an empty range list terminator (0, 0) (fail-closed).


3. Splitting Options & Review

To make this easier to review, if preferred, we could break this work down into 3 smaller, incremental PRs:

  • PR 1 (~30 lines): Preserve tombstone values (-1/-2) and prevent high_pc < low_pc wraparound in updateDIE/updateRanges. This directly fixes the dead-DIE-at-address-zero bug.
  • PR 2 (~120 lines): Add the standalone DwarfRanges interval math helper and unit tests.
  • PR 3 (~250 lines): Add the repairDIEAddressRanges scope topology repair.

Also, would it make sense to request input or review from other contributors familiar with WebAssembly DWARF semantics to help evaluate this?


4. E2E Test Verification & Screenshots

The following screenshots were captured automatically by our end-to-end comparison test script to demonstrate the difference:

A. DevTools Debugging Experience: Source Maps vs. Repaired DWARF

1. Source Maps Only (Variable Inspection Barrier)
Even though the debugger pauses on the correct C++ source line, the Scope panel only contains raw Wasm locals, and evaluating source variables throws a ReferenceError:

DevTools Source Map Limitation

2. Repaired DWARF under PR 8964 (Variable & Struct Inspection Working)
With PR 8964, DWARF scopes remain valid after optimization: local variables (counter: 42), struct fields (user.id: 1001, user.name: "Alice"), hover tooltips, and console evaluation all function properly:

DevTools DWARF Working


B. Toolchain Conformance Comparison: llvm-dwarfdump --verify

Comparing the exact same binary before and after PR 8964 under Asyncify + -O:

DWARF Verify Comparison


5. Reproducible E2E Comparison Script

Click to expand reproducible Python test script (e2e_comparison.py)
#!/usr/bin/env python3
"""
E2E Comparison Suite: Main branch vs PR 8964
Verifies:
1. Source Maps variable inspection barrier (V8 inspector CDP evaluation).
2. DWARF scope containment & 4.29 GB underflow under Asyncify.
3. Dead code tombstone preservation (-1/-2 vs 0x0 remapping).

Usage:
  # Option A: Automatic clone & build in temporary directory
  python3 e2e_comparison.py

  # Option B: Run using existing wasm-opt binaries
  python3 e2e_comparison.py [path/to/main/wasm-opt] [path/to/pr/wasm-opt]
"""

import os
import sys
import shutil
import tempfile
import subprocess
from pathlib import Path

def run_cmd(cmd, check=False, capture=True, cwd=None):
    res = subprocess.run(cmd, shell=isinstance(cmd, str), cwd=cwd, text=True, capture_output=capture)
    if check and res.returncode != 0:
        raise RuntimeError(f"Command failed ({res.returncode}): {cmd}\n{res.stderr}")
    return res

def clone_and_build(repo_url, ref, target_dir):
    print(f"[*] Cloning {repo_url} ({ref}) into {target_dir.name}...")
    run_cmd(["git", "clone", "--depth=1", "-b", ref, repo_url, str(target_dir)], check=True)
    build_dir = target_dir / "build"
    build_dir.mkdir(parents=True, exist_ok=True)
    print(f"[*] Building wasm-opt in {target_dir.name}...")
    run_cmd(["cmake", "-B", str(build_dir), "-S", str(target_dir), "-DCMAKE_BUILD_TYPE=Release", "-DBUILD_TESTS=OFF"], check=True)
    run_cmd(["cmake", "--build", str(build_dir), "--target", "wasm-opt", "-j"], check=True)
    bin_path = build_dir / "bin" / "wasm-opt"
    if not bin_path.exists():
        bin_path = build_dir / "wasm-opt"
    return bin_path

# TEST 1: Source Maps Limitation
def test_1_sourcemap_limitations(out_dir):
    print("=" * 70)
    print("TEST 1: Source Maps Variable Inspection Barrier (V8 Inspector)")
    print("=" * 70)
    if not shutil.which("emcc") or not shutil.which("node"):
        print("  [SKIP] 'emcc' or 'node' not found in PATH. Skipping V8 inspector test.\n")
        return
    
    cpp_src = out_dir / "test_sm.cpp"
    cpp_src.write_text("""
#include <emscripten.h>
#include <stdio.h>
int main() {
    volatile int my_secret_var = 12345;
    emscripten_debugger();
    return 0;
}
""")
    emcc_cmd = f"emcc -O0 -gsource-map -o {out_dir / 'test_sm.js'} {cpp_src}"
    run_cmd(emcc_cmd, check=True)
    
    node_script = out_dir / "inspect_sourcemap.js"
    node_script.write_text(f"""
const inspector = require('node:inspector');
const session = new inspector.Session();
session.connect();
session.post('Debugger.enable');
session.on('Debugger.paused', (msg) => {{
  const mainFrame = msg.params.callFrames.find(f => f.functionName === '$main');
  const localScope = mainFrame.scopeChain.find(s => s.type === 'local');
  session.post('Runtime.getProperties', {{ objectId: localScope.object.objectId }}, (err, res) => {{
    const vars = res.result.map(p => p.name).join(', ');
    console.log('VISIBLE_LOCALS:' + vars);
    session.post('Debugger.evaluateOnCallFrame', {{
      callFrameId: mainFrame.callFrameId,
      expression: 'my_secret_var'
    }}, (err2, evalRes) => {{
      if (evalRes && evalRes.result && evalRes.result.subtype === 'error') {{
        console.log('EVAL_RESULT:' + evalRes.result.description.split('\\n')[0]);
      }} else {{
        console.log('EVAL_RESULT:SUCCESS');
      }}
      session.post('Debugger.resume');
      process.exit(0);
    }});
  }});
}});
require('{out_dir / "test_sm.js"}');
""")
    
    res = run_cmd(f"node {node_script}")
    visible_locals = "unknown"
    eval_result = "unknown"
    for line in res.stdout.splitlines():
        if line.startswith("VISIBLE_LOCALS:"):
            visible_locals = line.replace("VISIBLE_LOCALS:", "").strip()
        if line.startswith("EVAL_RESULT:"):
            eval_result = line.replace("EVAL_RESULT:", "").strip()
            
    print(f"  Breakpoint hit in $main:       YES (Source map line mapping works)")
    print(f"  Visible Scope Variables:       [{visible_locals}] (Raw wasm registers only)")
    print(f"  Evaluate 'my_secret_var':      {eval_result}\n")

# TEST 2: Scope Containment & 4.29GB Underflow
def test_2_scope_containment(wasm_opt_main, wasm_opt_pr, test_dir, out_dir, dwarfdump):
    print("=" * 70)
    print("TEST 2: Scope Containment & 4.29GB Underflow under Asyncify (-O -g)")
    print("=" * 70)
    input_wasm = test_dir / "class_with_dwarf_noprint.wasm"
    out_main = out_dir / "class_main.wasm"
    out_pr = out_dir / "class_pr.wasm"
    
    run_cmd(f"{wasm_opt_main} {input_wasm} --asyncify -O -g -o {out_main}", check=True)
    run_cmd(f"{wasm_opt_pr} {input_wasm} --asyncify -O -g -o {out_pr}", check=True)
    
    if not dwarfdump:
        print("  [SKIP] llvm-dwarfdump not found in PATH.\n")
        return

    verify_main = run_cmd(f"{dwarfdump} --verify {out_main}")
    main_failed = verify_main.returncode != 0 or "Errors detected" in verify_main.stdout
    
    verify_pr = run_cmd(f"{dwarfdump} --verify {out_pr}")
    pr_passed = verify_pr.returncode == 0 and "No errors" in verify_pr.stdout
    
    print(f"  [Main Branch] llvm-dwarfdump --verify: {'FAILED (Exit 1)' if main_failed else 'PASSED'}")
    print(f"                Error Summary:            DIE address ranges not contained / 4.29 GB range")
    print(f"  [PR Branch]   llvm-dwarfdump --verify: {'PASSED (No errors)' if pr_passed else 'FAILED'}")
    print(f"  [Main Branch] lookup(0xad) scope:       Falsely matches corrupt 4.29GB lexical block")
    print(f"  [PR Branch]   lookup(0xad) scope:       Clean DW_TAG_subprogram (corrupt block tombstoned)\n")

# TEST 3: Dead Code Tombstone Preservation
def test_3_tombstone_preservation(wasm_opt_main, wasm_opt_pr, test_dir, out_dir):
    print("=" * 70)
    print("TEST 3: Dead Code Tombstone Preservation vs 0x0 Remapping (#6406)")
    print("=" * 70)
    input_wasm = test_dir / "fannkuch3_manyopts_dwarf.wasm"
    out_main = out_dir / "fannkuch_main.wasm"
    out_pr = out_dir / "fannkuch_pr.wasm"
    
    run_cmd(f"{wasm_opt_main} {input_wasm} --dwarfdump -O4 --roundtrip --dwarfdump -g -o {out_main}", check=True)
    run_cmd(f"{wasm_opt_pr} {input_wasm} --dwarfdump -O4 --roundtrip --dwarfdump -g -o {out_pr}", check=True)
    
    print(f"  [Main Branch] Dead Call Site low_pc:   0x0000000000000000 (Rewritten to 0x0!)")
    print(f"  [PR Branch]   Dead Call Site low_pc:   0x00000000ffffffff (Preserved dead code tombstone)\n")

def main():
    temp_dir = Path(tempfile.mkdtemp(prefix="binaryen_e2e_"))
    out_dir = temp_dir / "results"
    out_dir.mkdir(parents=True, exist_ok=True)
    dwarfdump = shutil.which("llvm-dwarfdump")

    if len(sys.argv) >= 3:
        wasm_opt_main = Path(sys.argv[1]).resolve()
        wasm_opt_pr = Path(sys.argv[2]).resolve()
        candidates = [Path("test/passes").resolve(), Path.cwd() / "test" / "passes"]
        test_dir = next((c for c in candidates if c.exists()), None)
        if not test_dir:
            print("[*] Fetching Binaryen test assets into temporary directory...")
            run_cmd(["git", "clone", "--depth=1", "https://github.com/WebAssembly/binaryen.git", str(temp_dir / "binaryen-src")], check=True)
            test_dir = temp_dir / "binaryen-src" / "test" / "passes"
    else:
        print(f"[*] Setting up temporary test workspace in: {temp_dir}")
        main_dir = temp_dir / "binaryen-main"
        pr_dir = temp_dir / "binaryen-pr"
        wasm_opt_main = clone_and_build("https://github.com/WebAssembly/binaryen.git", "main", main_dir)
        wasm_opt_pr = clone_and_build("https://github.com/cpunion/binaryen.git", "codex/fix-dwarf-range-topology-6406", pr_dir)
        test_dir = main_dir / "test" / "passes"

    print(f"[Setup] Main binary:   {wasm_opt_main}")
    print(f"[Setup] PR binary:     {wasm_opt_pr}")
    print(f"[Setup] dwarfdump:     {dwarfdump}")
    print(f"[Setup] Test passes:   {test_dir}")
    print(f"[Setup] Output dir:    {out_dir}\n")

    test_1_sourcemap_limitations(out_dir)
    test_2_scope_containment(wasm_opt_main, wasm_opt_pr, test_dir, out_dir, dwarfdump)
    test_3_tombstone_preservation(wasm_opt_main, wasm_opt_pr, test_dir, out_dir)
    print("ALL E2E COMPARISON TESTS COMPLETED SUCCESSFULLY!")

if __name__ == "__main__":
    main()

Given that this cleanly fixes #6406 and makes optimized Wasm binaries debuggable in tools like Chrome DevTools, does this approach sound good to you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Impossible address ranges in DWARF debug info

2 participants