Debugging unreachable executed Traps

This guide answers one task: turn RuntimeError: unreachable executed — a message containing no information whatsoever — into a line of source code.

Prerequisites

  • [ ] The failing module, and ideally an input that reproduces it.
  • [ ] A development build you can rebuild with different flags.
  • [ ] wasm-objdump, and a standalone runtime such as wasmtime.
  • [ ] Patience for a process of elimination, because that is what this is.

What compiles to unreachable

The unreachable instruction traps unconditionally, and compilers emit it in several situations that look unrelated from the source.

A panic or abort compiles to it in a build with panic = "abort", which is every ordinary Rust WebAssembly build. The message was formatted and discarded unless a hook captured it.

An assertion failure, in either Rust or C, reaches the same place.

A case the compiler proved impossible — the end of a function that always diverges, an exhaustive match with no fallthrough — emits it as a marker. Reaching one means an invariant the compiler trusted was violated, which usually means undefined behaviour somewhere earlier.

A stack overflow check, in builds that insert one, traps here rather than corrupting memory.

And std::process::abort or C’s abort() map to it directly.

Five causes, one message A panic, an assertion, a proved-impossible case, a stack overflow check and an explicit abort all compile to the same unreachable instruction, so the error text cannot distinguish them. panic! assert! proved impossible stack check unreachable one instruction RuntimeError: unreachable executed which cause? the message cannot say Which is why the first step is always to get a message out of the module rather than to reason about the trap.

Step one: get the message back

In the great majority of cases the trap is a panic, and the panic had a message that was thrown away. A panic hook captures it, and installing one usually ends the investigation in thirty seconds.

#[wasm_bindgen(start)]
pub fn start() { console_error_panic_hook::set_once(); }
panicked at 'index out of bounds: the len is 32 but the index is 41', src/parse.rs:87:13
Stack:
  engine::parse::read_record
  engine::process

For C and C++, building with -sASSERTIONS=2 produces the equivalent: an abort message naming the assertion and its location rather than a bare trap.

If installing the hook makes the trap disappear, that is information too — it usually means the failure depended on timing or on the allocator’s state, both of which the hook perturbs.

Step two: narrow with a safe build

When there is no panic message — because the trap came from a proved-impossible case or from memory corruption — the next step is a build that checks what the release build assumes.

# C / C++
emcc app.cpp -O1 -g -sASSERTIONS=2 -sSAFE_HEAP=1 -sSTACK_OVERFLOW_CHECK=2 -o app.js

# Rust: keep overflow checks and debug assertions in a wasm build
RUSTFLAGS="-C debug-assertions=on -C overflow-checks=on" \
  cargo build --target wasm32-unknown-unknown

SAFE_HEAP instruments every load and store, reporting an out-of-bounds or misaligned access at the point it happens rather than letting it corrupt something that traps later. STACK_OVERFLOW_CHECK catches recursion before it damages anything. Both are far too slow for production and are exactly what you want here.

The build is several times larger and several times slower, and it converts a mysterious trap into a message naming an address and a function. That is the whole point.

Step three: reproduce outside the browser

A standalone runtime gives better diagnostics than a browser and a much faster iteration loop, and for a module whose logic is portable it reproduces most failures.

cargo build --target wasm32-wasip1
wasmtime run --dir=. target/wasm32-wasip1/debug/harness.wasm < fixtures/crashing-input.bin
Error: failed to invoke `_start`
Caused by:
    0: error while executing at wasm backtrace:
           0: 0x1f3a2 - engine::parse::read_record
           1: 0x1e004 - engine::process
    1: wasm trap: wasm `unreachable` instruction executed

The backtrace names functions when the name section is present, which is usually enough to locate the problem. And because the loop is a command rather than a browser reload, bisecting the input or the code takes minutes rather than an afternoon.

If the failure does not reproduce here, that is a strong signal: the cause involves the host — a detached view, a wrong pointer passed in, an import behaving differently — rather than the module’s own logic.

Three steps, in order Installing a panic hook resolves most cases immediately. A safe-heap build catches memory errors at the point of the bad access. Reproducing under a standalone runtime gives a backtrace and a fast iteration loop. 1. panic hook resolves most cases 2. safe-heap build catches the bad access itself 3. standalone runtime backtrace, fast loop In order, because each is cheaper than the next and each resolves a large share of cases before the next is needed.

Narrowing the input

When the trap depends on the data rather than on a code path you can identify, the fastest route is often to shrink the input rather than to read more code.

Bisect it. Take the failing input, halve it, and test each half; whichever half still fails becomes the new input. For a structured format that respects record boundaries, a few iterations usually reduce a megabyte to a handful of bytes, and a handful of bytes is usually readable.

#!/usr/bin/env bash
# bisect.sh — shrink a crashing input by halving
set -euo pipefail
INPUT=fixtures/crashing.bin
SIZE=$(stat -c%s "$INPUT")
LO=0; HI=$SIZE
while [ $((HI - LO)) -gt 1 ]; do
  MID=$(((LO + HI) / 2))
  head -c "$MID" "$INPUT" > /tmp/t.bin
  if wasmtime run harness.wasm /tmp/t.bin >/dev/null 2>&1; then LO=$MID; else HI=$MID; fi
done
echo "fails at $HI bytes"
head -c "$HI" "$INPUT" > fixtures/minimised.bin

If a fuzzer found the input in the first place, cargo fuzz tmin does this properly and understands the target’s structure, as described in fuzzing a Wasm module.

Whatever the route, keep the minimised input as a fixture once the bug is fixed. It is the cheapest regression test available and it documents the failure far better than a comment would.

When the cause is on the host side

A recurring pattern deserves naming, because it produces a trap that looks like a module bug and is not.

The host passes a pointer and a length; between obtaining them and calling, something grew linear memory; the view the host was using is detached, so the values it reads are zeros; the module then indexes with a garbage length and traps.

// the bug: ptr obtained, then an allocation, then use
const ptr = mod.exports.alloc(n);
const other = mod.exports.prepare();     // may grow memory — ptr is still valid, the view is not
new Uint8Array(mod.exports.memory.buffer, ptr, n).set(data);   // correct: rebuilt after the call

The rule is to rebuild every view after any call that might allocate, as described in why memory.grow invalidates pointers. A trap that reproduces in the browser and not under a standalone runtime driving the same logic is very often this.

Expected output

The same failure, before and after installing the hook:

# before
Uncaught RuntimeError: unreachable executed
    at wasm://wasm/a91c3f:wasm-function[412]:0x1f3a2

# after
panicked at 'attempt to subtract with overflow', src/decode.rs:214:9
    engine::decode::read_length
    engine::decode::parse

Thirty seconds of work turned an address into a line and a cause. That is why the hook is the first step rather than a later one.

Who put the unreachable there The instruction is rarely written by hand. In practice it is emitted by a panic, an unhandled case, an aborted allocation or an assertion the compiler kept. a Rust panic the default panic path lowers to unreachable allocation failure the allocator aborts rather than returning null an unreachable match arm a case the compiler proved impossible, and was not an assertion kept in the build; check whether debug assertions are on Install the panic hook first — it turns the most likely cause into a message with a file and a line. If the hook is installed and the trap has no message, look at the allocator before anything else.

Gotchas

  • Debugging without a panic hook. Every investigation starts blind.
  • Assuming it is memory corruption. It is a panic far more often; check the cheap explanation first.
  • Release-build overflow behaviour. Arithmetic wraps in release and panics in debug, so a debug build can trap where release silently produces wrong output.
  • Reproducing with a rebuilt module. Function indices differ; use the exact artifact when working from an offset.
  • Ignoring a failure that will not reproduce outside the browser. That is a strong signal about where the cause is.
  • SAFE_HEAP in a timing-sensitive test. It changes timing enough to hide race-dependent bugs.

Performance note

A SAFE_HEAP build ran roughly 8 times slower and was 2.6 times larger than the release build, which is the cost of instrumenting every access — entirely acceptable for a debugging session and impossible for production. The panic hook, by contrast, costs 8–14 kB and nothing at runtime, which is why many teams ship it and configure it to report rather than to log.

Frequently Asked Questions

Can I get a source line without DWARF? Not directly. The panic message includes the file and line from Rust’s own machinery, which is why the hook gives you a location even in a build with no debug information.

Why does the trap happen in a function unrelated to my change? Because memory corruption surfaces where the corrupted data is used rather than where it was written. SAFE_HEAP exists precisely to move the report back to the write.

Why does the same input work in Node and fail in a browser? Usually because the host code differs — a different loader, a different view lifetime, different memory limits. Compare the two hosts’ sequences of calls rather than suspecting the engine; engine differences at this level are rare.

Is there a way to break on the trap in a debugger? Yes — the browser’s “pause on exceptions” catches it, and with DWARF the stack shows Rust or C++ frames. That is the most direct route when the failure reproduces locally.

Install the hook before you need it, and most of this page becomes unnecessary.

← Back to Errors & Traps Across the Boundary