Exceptions
A language with exceptions needs a way to unwind the stack to a handler,
and WebAssembly's hidden call stack (Chapter 5) means a compiler cannot do
it by itself. Before exception handling was added, compilers resorted to
calling out to JavaScript to throw and catch, or to checking a flag after
every call, both of which are slow. The exception handling feature, in
WebAssembly 3.0, adds the instructions to do it properly. This chapter
covers them, how JavaScript sees them, and how to compile try, catch
and finally with them.
#Tags and throw20.1
An exception is identified by a tag, declared at the module level with the types of the values the exception carries:
(tag $not_found (param i32)) ;; carries one i32
(tag $parse_error (param i32 i32)) ;; carries two
throw pops the tag's values and throws an exception with them. Control
leaves the current function, and every function between it and the
nearest handler, at once.
#try_table and catch20.2
A handler is a try_table block with a list of catch clauses. Each
clause names a tag and a label to branch to when an exception with that
tag is thrown inside the block. The exception's values are passed to the
label as branch operands, so the label's block must have results of the
right types:
(module
(tag $not_found (param i32))
(func $find (param $key i32) (result i32)
(if (i32.gt_u (local.get $key) (i32.const 5))
(then (throw $not_found (local.get $key))))
(i32.mul (local.get $key) (i32.const 100)))
(func (export "lookup") (param $key i32) (result i32)
(block $handler (result i32) ;; receives the exception's i32
(try_table (result i32) (catch $not_found $handler)
(call $find (local.get $key)))
(return)) ;; normal path: return the value
;; exceptional path: the key is on the stack
(i32.mul (i32.const -1))))
lookup(3) and lookup(9)300 -9
If $find returns normally, the try_table ends with its result on the
stack and the function returns it. If $find throws $not_found, the
catch clause branches to $handler, carrying the thrown key, and the
code after the $handler block negates it (multiplying by −1, since
the key is already on the stack and i32.sub would need it on top).
The four kinds of catch clause are:
| Clause | Catches | Passes to the label |
|---|---|---|
catch $tag $l |
exceptions with $tag |
the exception's values |
catch_ref $tag $l |
exceptions with $tag |
the values, then an exnref |
catch_all $l |
every exception | nothing |
catch_all_ref $l |
every exception | an exnref |
Clauses are tried in order. An exception that matches none of them continues outward to the next handler.
#exnref and rethrowing20.3
An exnref is a reference to a caught exception. Its main use is
throw_ref, which throws it again, unchanged. That is how a handler that
does some work and then lets the exception continue, such as a finally
block or a destructor, is compiled:
(module
(import "env" "print_i32" (func $print (param i32)))
(tag $error (param i32))
(func $work (param $fail i32)
(call $print (i32.const 1))
(if (local.get $fail) (then (throw $error (i32.const 42))))
(call $print (i32.const 2)))
;; try { work(fail) } finally { print(3) }
(func $with_finally (param $fail i32)
(block $on_exception (result exnref)
(try_table (catch_all_ref $on_exception)
(call $work (local.get $fail)))
(call $print (i32.const 3)) ;; finally, on the normal path
(return))
(call $print (i32.const 3)) ;; finally, on the exceptional path
(throw_ref)) ;; and carry on unwinding
(func (export "run") (param $fail i32)
(block $caught (result i32)
(try_table (catch $error $caught)
(call $with_finally (local.get $fail)))
(return))
(call $print))) ;; print the error's value
run(0) and run(1)1 2 3 1 3 42
The finally code appears twice, once on each path. That duplication is
normal: a compiler emits the cleanup code once per way out of the
protected region, or moves it into a function it calls from each.
#Exceptions and JavaScript20.4
Exceptions cross the boundary in both directions.
A WebAssembly exception that reaches JavaScript is a
WebAssembly.Exception object. If the module exports the tag, JavaScript
can check an exception's tag and read its values:
(module
(tag $overflow (export "overflow") (param i32 i32))
(func (export "add_checked") (param $a i32) (param $b i32) (result i32)
(local $sum i64)
(local.set $sum (i64.add (i64.extend_i32_s (local.get $a)) (i64.extend_i32_s (local.get $b))))
(if (i64.ne (local.get $sum) (i64.extend_i32_s (i32.wrap_i64 (local.get $sum))))
(then (throw $overflow (local.get $a) (local.get $b))))
(i32.wrap_i64 (local.get $sum))))
import fs from 'node:fs';
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { add_checked, overflow } = instance.exports;
console.log(add_checked(1, 2));
try {
add_checked(2 ** 31 - 1, 1);
} catch (e) {
console.log(e instanceof WebAssembly.Exception, e.is(overflow));
console.log('overflow adding', e.getArg(overflow, 0), 'and', e.getArg(overflow, 1));
}
3 true true overflow adding 2147483647 and 1
JavaScript can also create tags with new WebAssembly.Tag({ parameters:
['i32'] }), pass them to modules as imports, and throw WebAssembly
exceptions itself with new WebAssembly.Exception(tag, [values]).
A JavaScript exception thrown into WebAssembly, from an imported function,
can be caught by catch_all or catch_all_ref, since it has no tag the
module knows. The module cannot look inside it, but it can run cleanup
code and rethrow it with throw_ref, or swallow it:
(module
(import "env" "risky" (func $risky (param i32) (result i32)))
(func (export "safe") (param $x i32) (result i32)
(block $failed
(try_table (result i32) (catch_all $failed)
(call $risky (local.get $x)))
(return))
(i32.const -1)))
import fs from 'node:fs';
const env = { risky: (x) => { if (x < 0) throw new Error('negative!'); return x * 2; } };
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env });
console.log(instance.exports.safe(21), instance.exports.safe(-5));
42 -1
The JavaScript API also provides WebAssembly.JSTag, a tag whose single
externref value is the thrown JavaScript value, so a module that imports
it can catch JavaScript exceptions with catch and inspect what was
thrown.
#Compiling try and catch20.5
A source-level try/catch maps onto try_table directly: one tag per
exception type the language distinguishes, or one tag for all exceptions
carrying a pointer to an exception object, which is simpler and what
C++ compilers do. Matching by the source type, for languages where a
catch clause names a class and must also catch subclasses, then happens
in the handler: catch the one tag, test the object's type, and throw_ref
if it does not match.
Three details need care:
- The shadow stack pointer. If the code inside the
tryadjusted the stack pointer in linear memory (Chapter 10), unwinding skipped the epilogues that would have restored it. Save the stack pointer in a local at the start of thetry, and restore it at the start of each handler. - Destructors and
defer. Code that must run when a scope exits by any route is acatch_all_refthat runs the code and rethrows, exactly like thefinallyexample. - Traps are not exceptions. No catch clause catches a trap. A language
whose division by zero raises a catchable exception must test for zero
and
throw, rather than relying on the trap.
#The legacy instructions20.6
Before the current design, browsers shipped an earlier version of the
proposal, with try, catch, catch_all, rethrow and delegate
instructions, and no exnref. Toolchains emitted it for several years, so
you will find it in existing modules, and engines still accept it. It
proved hard to optimise and to fit with other features, and was replaced
by try_table and exnref in 2024. New compilers should emit only the
new instructions.
#What you have seen20.7
Tags declare exception types. throw raises an exception, and try_table
catches it with clauses that branch to labels, carrying the exception's
values and optionally an exnref. throw_ref rethrows, which is how
finally and destructors are compiled. Exceptions cross to and from
JavaScript, as WebAssembly.Exception objects one way and as catch-all
exceptions the other. Entering a handler is free; throwing is not.
The next chapter covers tail calls.