Chapter 17

Traps

A trap is WebAssembly's only built-in kind of failure. When an instruction cannot do what it says, such as reading past the end of memory or dividing by zero, execution stops at once, the WebAssembly frames on the stack are abandoned, and the host gets an error. This chapter lists what traps, shows what the host sees, and covers what a compiler should do about traps, including using them deliberately.

#Everything that traps17.1

Here is one module with a function for each of the common traps, and a host that calls each one and prints what it catches:

(module
  (memory 1)
  (type $v (func))
  (table 2 funcref)
  (elem (i32.const 0) $f)
  (func $f (param i32))
  (func (export "unreachable") unreachable)
  (func (export "divide by zero") (drop (i32.div_u (i32.const 1) (i32.const 0))))
  (func (export "division overflow") (drop (i32.div_s (i32.const 0x80000000) (i32.const -1))))
  (func (export "bad conversion") (drop (i32.trunc_f32_s (f32.const nan))))
  (func (export "memory out of bounds") (drop (i32.load (i32.const 70000))))
  (func (export "table out of bounds") (call_indirect (type $v) (i32.const 5)))
  (func (export "null table entry") (call_indirect (type $v) (i32.const 1)))
  (func (export "signature mismatch") (call_indirect (type $v) (i32.const 0)))
  (func $deep (export "stack overflow") (call $deep)))
import fs from 'node:fs';

const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
for (const [name, f] of Object.entries(instance.exports)) {
  try {
    f();
  } catch (e) {
    console.log(`${name.padEnd(22)} ${e.constructor.name}: ${e.message}`);
  }
}

unreachable            RuntimeError: unreachable
divide by zero         RuntimeError: divide by zero
division overflow      RuntimeError: divide result unrepresentable
bad conversion         RuntimeError: float unrepresentable in integer range
memory out of bounds   RuntimeError: memory access out of bounds
table out of bounds    RuntimeError: table index is out of bounds
null table entry       RuntimeError: null function or function signature mismatch
signature mismatch     RuntimeError: null function or function signature mismatch
stack overflow         RangeError: Maximum call stack size exceeded

The messages are V8's; other engines word them differently, and the specification does not fix them. What is fixed is that each of these traps, on every engine, at exactly the same instruction. That determinism is what lets a test suite check traps at all, and Part VIII relies on it.

Stack overflow is the odd one out. It is a RangeError rather than a RuntimeError, because V8 reports it the same way as runaway JavaScript recursion, and the point at which it happens depends on the engine and its settings, so it is not deterministic in the same way.

Later features add a few more traps: dereferencing a null GC reference, a failed ref.cast, an out-of-bounds GC array access (Chapter 22), and an unaligned atomic access (Chapter 23). Instantiation can also trap, if an active data or element segment does not fit in its memory or table.

#After a trap17.2

A trap does not destroy the instance. Its memory, tables and globals keep whatever values they had at the moment of the trap, and its exports can be called again. Whether that is safe depends entirely on what the code was doing when it stopped.

Here is the problem in its most common form. A function with a shadow stack frame (Chapter 10) traps halfway through, so its epilogue never runs, and the stack pointer is never restored:

(module
  (memory 1)
  (global $sp (export "sp") (mut i32) (i32.const 65536))
  (func (export "work") (param $fail i32)
    (local $frame i32)
    (local.set $frame (i32.sub (global.get $sp) (i32.const 256)))
    (global.set $sp (local.get $frame))
    (if (local.get $fail) (then unreachable))
    (global.set $sp (i32.add (local.get $frame) (i32.const 256)))))
import fs from 'node:fs';

const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { work, sp } = instance.exports;
work(0);
console.log('after a normal call:', sp.value);
for (let i = 0; i < 3; i++) {
  try { work(1); } catch (e) { console.log('trapped, sp is now', sp.value); }
}

after a normal call: 65536
trapped, sp is now 65280
trapped, sp is now 65024
trapped, sp is now 64768

Each trap leaked 256 bytes of shadow stack. Locks held, half-updated data structures, an allocator interrupted mid-operation: anything the code was in the middle of stays that way.

So a host has three reasonable responses to a trap:

  1. Treat it as fatal. Report the error and discard the instance. This is the only response that is always safe, and the right default.
  2. Reset what you know about. If the module's runtime keeps all its cross-call state in a few known places, such as the stack pointer, the host can restore them. Saving sp.value before each call from the host, and restoring it after a trap, fixes the example above. The module must be designed for this.
  3. Throw it away and start again. Since instantiating an already compiled module is cheap, a game that runs untrusted or buggy scripts can create a fresh instance for each script after a trap, keeping the compiled module.

#Traps and the language you are compiling17.3

Your language has its own notion of failure: panics, assertions, exceptions, undefined behaviour. Each maps onto WebAssembly differently.

Undefined behaviour in the source can become whatever the WebAssembly instructions do. C's signed overflow simply wraps; a C null dereference reads address 0. Nothing traps unless an instruction traps by itself.

Checks your language promises, such as bounds checks on arrays or checks for integer overflow, are your compiler's job. Emit the comparison, and on failure, unreachable:

(module
  (memory 1)
  ;; an array of 4 i32s at address 16; get(i) checks i against the length
  (func (export "get") (param $i i32) (result i32)
    (if (i32.ge_u (local.get $i) (i32.const 4))
      (then unreachable))
    (i32.load offset=16 (i32.shl (local.get $i) (i32.const 2)))))
called as get(7)
RuntimeError: unreachable

The single i32.ge_u handles negative indices too, since as unsigned numbers they are enormous. Engines lay out the trapping path out of line, so the cost on the normal path is one compare and one predictable branch.

Panics with messages need the message to reach the developer. The usual approach is an imported function that records the message, followed by unreachable:

(module
  (import "env" "print" (func $print (param i32 i32)))
  (memory (export "memory") 1)
  (data (i32.const 0) "panic: index out of range")
  (func $panic (param $msg i32) (param $len i32)
    (call $print (local.get $msg) (local.get $len))
    unreachable)
  (func (export "main")
    (call $panic (i32.const 0) (i32.const 25))))
panic: index out of range
RuntimeError: unreachable

A host can make the import throw a JavaScript error carrying the message instead of printing it, which makes the message part of the error the caller catches. Either way, the trap's stack trace shows where the panic happened, provided the module has a name section.

Recoverable errors that your language expects programs to catch, such as exceptions, should not be traps at all, since WebAssembly code cannot catch a trap. They need the exception handling of Chapter 20, or a convention of returning error codes.

#What you have seen17.4

Traps stop execution at a precise instruction: unreachable code, integer division errors, failed float-to-int conversions, out-of-bounds memory and table accesses, and bad indirect calls. The host sees a WebAssembly.RuntimeError, or a RangeError for stack overflow. The instance survives, with whatever partial state the trap left, so the safest response is to discard it. Compilers use unreachable to implement the checks their languages promise, with an import to carry a message.

That ends Part V. Part VI covers the features added to WebAssembly since its first version.