How to Fix: Exceptions: dynamic context load during stack walk doesn’t account for stack-arg region

6 min read

Fixing Wasmtime Exception Unwinding When the Dynamic Context Slot Ignores Stack Arguments

When ./target/debug/wasmtime -W=all-proposals=y bug.wasm crashes, the thrown exception is only the trigger. The real bug is that the stack walker reloads the dynamic context from the wrong address whenever a frame reserves an outgoing stack-arg region. Once the unwinder reads a bogus context pointer, frame metadata, landing pads, and the next unwind step all become unreliable.

Reproduce the Failure

Use the attached bug.zip test case and run Wasmtime with proposal support enabled:

unzip bug.zip
cargo build -p wasmtime-cli
RUST_BACKTRACE=1 ./target/debug/wasmtime -W=all-proposals=y bug.wasm

If the module forces enough call parameters onto the stack, the unwind path attempts to load the caller’s dynamic context from a slot that is shifted by the stack-argument area.

Understanding the Root Cause

During exception handling, Wasmtime cannot trust live machine registers alone. It performs a stack walk and reconstructs each frame from recorded ABI and frame-layout metadata. One of the values it must recover is the dynamic context pointer, which is then used to resolve frame metadata and continue unwinding safely.

The bug appears because the address calculation for that load was based on a partial frame size. It accounted for locals, spills, or other fixed slots, but it did not include the frame’s reserved stack-arg region. That region exists when a call signature exceeds the target architecture’s register-passing budget, so some arguments are written to the stack.

As soon as the frame has stack-passed arguments, the unwinder reads from the wrong slot. The loaded pointer is no longer the real dynamic context, which means the next metadata lookup is wrong. Depending on the build and architecture, this can surface as a crash, assertion failure, bogus landing-pad selection, or a trap while walking the stack.

The key takeaway is simple: the unwind path must use the exact same canonical frame layout as code generation. Any separate offset math will eventually drift.

Step-by-Step Solution

The robust fix is to make the stack walker compute the dynamic-context address from the full frame layout, including the outgoing stack-argument size, instead of reconstructing the address with ad hoc offsets.

1. Confirm which layout value is missing

rg 'dynamic context|stack walk|stack_arg|outgoing_args|arg area' cranelift crates winch

Look for the place where the unwinder reloads the dynamic context pointer. If the code is using an offset that only reflects locals, spills, or fixed slots, that is the fault line.

2. Replace partial offset math with a canonical helper

Do not keep sprinkling conditional additions in the unwinder. Centralize the calculation so code generation and stack walking agree on one definition of the frame base.

// Pseudo-Rust
// Bad: ignores the reserved outgoing stack-argument area.
let dynctx_addr = unwind_sp + frame_layout.dynamic_context_offset;

// Good: resolve from the canonical unwind base used by the ABI/frame layout.
// That base must already include the stack-argument reservation for the frame.
let unwind_base = frame_layout.unwind_base(fp, sp);
let dynctx_addr = unwind_base + frame_layout.dynamic_context_offset;
let dynctx = read_ptr(dynctx_addr)?;

If your implementation does not have an unwind_base helper yet, add one. The requirement is that it uses the full frame size, including the outgoing stack-argument area, rather than only locals or spill slots.

3. If your code is SP-relative, explicitly include stack arguments

Some backends compute the dynamic-context address directly from a saved stack pointer. In that case, fold the stack-argument region into the calculation instead of assuming a zero-sized argument area.

// Pseudo-Rust
let dynctx_addr = saved_sp
    + frame_layout.outgoing_stack_args_size
    + frame_layout.locals_and_spills_size
    + frame_layout.dynamic_context_slot_offset;

If your layout is FP-relative, do not blindly add the stack-arg size a second time. Normalize the frame base first, then apply one slot offset. The safest fix is always to share one helper across code generation and unwinding.

4. Add a regression test that forces stack arguments during exception unwind

The reproducer must not rely on a lucky register assignment. Make the call signature large enough that the ABI must spill arguments to the stack, then throw or catch across that call so the unwinder walks the frame.

Regression test requirements:
- A Wasm function with enough parameters to exceed the target ABI's argument registers.
- Exception handling enabled.
- An unwind path that crosses at least one frame with stack-passed arguments.
- An assertion that execution completes without crashing or misrouting the exception.

5. Rebuild and verify

cargo test
RUST_BACKTRACE=1 ./target/debug/wasmtime -W=all-proposals=y bug.wasm

The correct result is not just a missing panic. You also want the exception to unwind through the expected frames and land in the expected handler.

Common Edge Cases

  • Architecture-specific calling conventions: x86_64 SysV, Windows x64, and AArch64 have different register budgets and stack alignment rules. A hardcoded threshold for where stack arguments start is fragile.
  • Mixed host and Wasm frames: if the unwind crosses a host boundary, make sure the host-side frame metadata uses the same canonical addressing rules.
  • Tail calls or frame elision: if a backend can omit or compress frames, the unwinder must use the layout metadata for the actual emitted frame, not the nominal function signature.
  • Alignment padding: even when only one or two arguments spill, alignment can move slot addresses. Counting arguments is not enough; use the final byte size from ABI layout.
  • Debug-only success: a debug build may accidentally mask the bug because spill patterns change. Validate with release-style code generation too.

FAQ

Why does this mostly show up with exceptions enabled?

Because the faulty code path is the stack unwinder. Normal execution can run correctly even with a miscomputed dynamic-context slot, but exception handling forces a stack walk and exposes the bad address calculation immediately.

Why does the bug depend on the function signature?

The failure typically requires an outgoing stack-argument region. That only appears when the call uses more parameters than the ABI can keep in registers, or when alignment and aggregate rules force some values onto the stack.

Is the right fix to add one more offset at the load site?

Usually no. The durable fix is to stop reconstructing frame addresses in multiple places. Put the canonical frame-base calculation in one helper, make it aware of stack-argument space, and reuse it from both code generation and unwinding.

Once the dynamic context is loaded from the correct slot, Wasmtime can resolve the right frame metadata, continue the unwind, and execute the exception path without corrupting the stack walk.

Leave a Reply

Your email address will not be published. Required fields are marked *