WebAssemblyのexnrefで例外を値として扱う
WebAssemblyの例外処理は、try_table命令と例外参照exnrefを使う形式に改定されました。例外の種類を表すタグとJavaScriptとの受け渡しから順に追い、旧形式では手に持てなかった例外を値として受け取れる、Baseline 2025の新形式を説明します。
- Chrome137
- Edge137
- Firefox131
- Safari18.4
はじめに
WebAssemblyは、C++やRustなどで書いたプログラムをブラウザで動かすためのバイナリ形式です。C++のthrowとcatchや、Rustのパニックを再現するには、WebAssembly側にも例外を投げて捕まえる仕組みが要ります。
この仕組みは2022年に主要ブラウザで使えるようになりました。ただ、最初の形式では捕まえた例外そのものを手に持てなかったため、提案が改定されて例外を値として扱える形式に置き換わりました。新形式はChrome 137、Firefox 131、Safari 18.4で揃い、Baseline 2025に加わっています。
この記事では、WebAssemblyのコードをWATというテキスト形式で書きます。実務ではコンパイラがバイナリを出力するので、WATを手で書くことはほとんどありません。それでも命令の意味を追うには、WATで書くのがいちばん短く済みます。WATの基本的な読み方はBranch Hintingの記事で扱っています。
WebAssemblyの例外はタグで種類を区別する
例外の投げ方とJavaScriptとの受け渡しは、旧形式でも新形式でも同じです。先にこの共通部分を見ておきます。
JavaScriptのthrowにはどんな値でも渡せますが、WebAssemblyの例外はタグという識別子で種類を区別します。タグは例外の型にあたり、例外が運ぶ値(ペイロード)の型もタグごとに決めます。
タグは、JavaScript側でWebAssembly.Tagとして作ってインポートで渡すか、WebAssembly側で定義してエクスポートします。JavaScriptが同じタグを持っていれば、WebAssemblyが投げた例外をcatchで受け取ったときに、どのタグの例外かを判定できます。次の例はJavaScript側で作る書き方です。
// 32ビット整数を1つ運ぶ例外の種類を定義する
const parseError = new WebAssembly.Tag({ parameters: ['i32'] });
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/parser.wasm'),
{ env: { parse_error: parseError } },
);
const parse = instance.exports.parse as (input: number) => void;
try {
parse(-1);
} catch (e) {
if (e instanceof WebAssembly.Exception && e.is(parseError)) {
WebAssemblyから来た例外は WebAssembly.Exception で、タグごとにペイロードを取り出せる
console.log('error code', e.getArg(parseError, 0));
} else {
throw e;
}
}WebAssembly側では、インポートしたタグをthrow命令に渡して例外を投げます。
(module
(tag $parse_error (import "env" "parse_error") (param i32))
(func (export "parse") (param $input i32)
;; 入力が負なら、エラーコード 42 を載せて例外を投げる
local.get $input
i32.const 0
i32.lt_s
if
i32.const 42
throw $parse_error
end
)
)旧形式と新形式で違うのは、WebAssemblyの中で例外を捕まえる部分です。
旧形式では例外を手に持てなかった
旧形式と新形式を比べるため、1つの処理を両方の形式で書きます。題材は、関数を呼んで例外が起きたら、後始末をしてから同じ例外を投げ直すコードです。JavaScriptならこう書きます。
try {
work(input);
} catch (e) {
cleanup();
throw e;
捕まえた例外は変数 e に入っていて、それを投げ直す
}旧形式のWATでは、JavaScriptの各行がそのまま命令に対応します。
(func $with_cleanup (param $input i32)
try ;; try {
local.get $input
call $work ;; work(input);
catch_all ;; } catch {
call $cleanup ;; cleanup();
rethrow 0 ;; throw e; に当たる
end ;; }
)JavaScriptと違うのは、catch_allに変数eがないことです。旧形式では例外を捕まえられても、捕まえた例外を手に持てません。投げ直しのrethrowも、例外を渡す代わりに、どのcatch_allが捕まえた例外かを番号で指します。0はrethrowをじかに囲むcatch_allを指します。
手に持てないので、JavaScriptなら当たり前の次の操作ができませんでした。例外を変数に取っておくこと、別の関数に渡すこと、tryを抜けたあとで投げ直すことです。
新形式では例外が値になった
新形式では、捕まえた例外を値として受け取れます。この値の型がexnrefで、JavaScriptのeに当たります。
書き方の構造も変わります。旧形式ではcatch_allの下に後始末を書きましたが、新形式のtry_tableは、例外を捕まえたときの行き先を指定するだけです。後始末は、その行き先に書きます。先ほどの処理を新形式で書くと、こうなります。
;; [!og]
(func $with_cleanup (param $input i32)
;; 捕まえた例外を入れておく変数。JavaScriptの e に当たる
(local $err exnref)
;; $handler という名前の範囲。終わりへ移るときに exnref を1つ持っていく
block $handler (result exnref)
;; この中で例外が起きたら、その例外を持って $handler の終わりへ移る
try_table (catch_all_ref $handler)
local.get $input
call $work ;; work(input);
end
;; 例外が起きなければここへ来て、そのまま関数から戻る
return
end
;; $handler の終わり。例外が起きたときだけここへ来る
local.set $err ;; 受け取った例外を $err に入れる
call $cleanup ;; cleanup();
local.get $err
throw_ref ;; throw e;
)$handlerは、例外が起きたときの行き先に付けた名前です。block $handlerから対応するendまでが1つの範囲で、例外が起きるとそのendの直後から実行が続きます。(result exnref)は、そのときにexnrefを1つ持っていくという宣言です。例外が起きなかったときは持っていくexnrefがないので、endに着く前にreturnで関数から戻っています。
順に追います。
try_tableの中で$workを呼ぶ- 例外が起きなければ、
returnで関数から戻る - 例外が起きると、その例外を指す
exnrefの値を持って、$handlerの終わりへ移る - 受け取った値を変数
$errに入れる $cleanupを呼んで後始末をする$errをthrow_refに渡し、同じ例外を投げ直す
旧形式との違いは、例外が$errという値として手元にあることです。値なので変数に取っておけますし、別の関数にも渡せます。投げ直しもcatchの番号を数えずに、throw_refに値を渡すだけで済みます。
捕まえ方は2つの選択の組み合わせ
try_tableに書く捕まえ方は4種類ありますが、覚えることは2つです。
- 何を捕まえるか:名前に
_allが付けばすべての例外、付かなければ指定したタグの例外だけ - 例外そのものを受け取るか:名前に
_refが付けばexnrefを受け取り、付かなければ受け取らない
タグを指定する捕まえ方は、これとは別にペイロードも受け取ります。タグが分かっていれば、運ばれてくる値の型も分かるからです。$labelには、先ほどの$handlerのように行き先の名前を書きます。組み合わせると次の4つになります。
catch $tag $label:指定したタグだけ捕まえ、ペイロードを受け取る。エラーコードを見て処理を分けたいときに使うcatch_ref $tag $label:指定したタグだけ捕まえ、ペイロードとexnrefを受け取る。中身を見たうえで投げ直したいときに使うcatch_all $label:すべて捕まえ、何も受け取らない。何が起きても既定値を返す、といったときに使うcatch_all_ref $label:すべて捕まえ、exnrefを受け取る。先ほどの例のように、後始末をしてから投げ直すときに使う
1つのtry_tableには複数を並べられ、上から見て最初に当てはまるものが選ばれます。たとえば特定のタグはcatchで処理し、それ以外はcatch_all_refで投げ直せます。
try_table
(catch $parse_error $on_parse_error)
(catch_all_ref $on_other)
local.get $input
call $work
endこれはtry_tableだけを抜き出した断片です。実際には$on_parse_errorと$on_otherという行き先を、先ほどの$handlerと同じように用意します。
catch_all_refはタグを問わないので、JavaScriptから投げられた例外も受け取れます。受け取ったexnrefをそのままthrow_refで投げ直せば、例外はWebAssemblyを素通りしてJavaScript側のcatchに届きます。
throw_refで投げ直した例外は、JavaScript側には元の値のまま届きます。WebAssemblyが投げたものなら同じWebAssembly.Exception、JavaScriptが投げたものなら元のErrorオブジェクトです。exnrefはWebAssemblyの中だけの型で、JavaScriptには公開されません。そのため新形式に移行しても、タグの節で示したJavaScript側のコードは書き換えずに済みます。
ツールチェインの対応
WATを手で書く場面は少ないので、実際に気にするのはコンパイラがどちらの形式を出力するかです。
- Emscripten:
-fwasm-exceptionsでWebAssemblyの例外を有効にすると、既定では旧形式が出力される。新形式にするには-sWASM_LEGACY_EXCEPTIONS=0も指定する - Binaryen:
wasm-optの--enable-exception-handlingで例外処理を有効にする。旧形式のバイナリは--translate-to-exnrefで新形式に変換できる - wabt:現行の
wat2wasmは既定でtry_tableとthrow_refを受け付ける。古い版では--enable-exceptionsを付ける
新形式のバイナリは、対応していない古いブラウザでは読み込みに失敗します。対象のブラウザがすべて新形式に対応しているなら新形式を選び、既存の旧形式のバイナリはBinaryenで変換しておきます。旧形式も互換性のためにブラウザに残っていますが、標準になったのは新形式で、旧形式はレガシー扱いです。
おわりに
旧形式のWebAssemblyでは、捕まえた例外を手に持てず、投げ直すときもcatchの番号で指すしかありませんでした。新形式では、捕まえた例外をexnrefという値として受け取れます。変数に取っておいてthrow_refに渡せば投げ直せるので、JavaScriptのcatch (e)と同じ感覚で例外を扱えます。JavaScript側のコードは変わりませんが、出力形式を新形式に切り替えると、動くブラウザの範囲が変わる点には注意が要ります。