11月13日、Alistair Sharpが「Fixing a WebKit bug to see if promises are made of train tracks」と題した記事を公開した。JavaScriptCoreのPromise実装に潜んでいたバグの原因と修正、そしてその過程で露わになるPromise仕様の深い挙動を詳述した内容だ。
修正のパッチはわずか**+16行**。しかしそこに至るまでの調査は、Promiseという「誰もが使うが誰もが完全には理解していない」機能の複雑な内部構造を鮮明に浮かび上がらせる。
再現コード:たった8行でSafariとBunが止まる
const p = Promise.resolve(1);
p.constructor = {
get [Symbol.species]() {
throw new Error("boom");
},
};
console.log("before");
await p;
console.log("done");
このコードを今年7月以前のBun(1.3.14以前)やSafari 26以前で実行すると、'before'を出力した後、エラーを報告しつつプログラムが永久に止まる。'done'は出力されない。一方、Node.jsは仕様通りエラーでクラッシュする。
BunとSafariが同じ挙動を示す理由は単純で、どちらもJavaScriptCore(WebKitのJSエンジン)を組み込んでいるからだ。このバグはWebKit bug 318399として登録され、SharpがWebKit/WebKit#68749で修正した。Bun 1.4とSafari 27以降ではNode.jsと同様に仕様準拠の挙動になっている。
resolveとrejectは対称ではない
原因を理解するには、まずrejectとresolveの非対称性を把握する必要がある。
reject(reason)はシンプルだ。受け取った値をそのまま拒否の理由として記録すれば終わる。しかしresolve(value)は違う。まずvalueに.then()メソッドが存在するかをチェックする。
.then()がない場合 → Promiseはvalueでfulfilled(完了).then()がある場合 →valueはthenableと見なされ、value.then(resolve, reject)を呼び出して結果を委譲する.thenの読み取り自体が例外を投げた場合 → Promiseはその例外でrejected
3つ目のケースが今回のバグの核心だ。プロパティの読み取りはgetterを実行し、getterは例外を投げ得る。冒頭のコードはまさにこれを突いている。
この非対称性には理由がある。失敗はすでに「起きた事実」だが、成功は将来の値に依存し得る(例:resolve(response.json()))。だからresolve側だけにthenableのチェックが必要なのだ。
仕様が定める「手順の順序」が破られていた
仕様では、thenableを検出したとき.then()をすぐ呼び出さず、マイクロタスクとしてキューに積む(マイクロタスクとは、現在の同期処理が終わった直後・次のイベントループの前に実行される小さな非同期タスクのこと)。これをNewPromiseResolveThenableJobと呼ぶ。その手順は以下の3ステップだ。
- 新しいresolve/reject関数のペアを作成する
thenable.then(resolve, reject)を呼び出す.then()が例外を投げたなら、そのPromiseをrejectする
JavaScriptCoreは、thenableが通常のPromiseである最頻ケースを高速処理するファストパスを持っていた。しかしそのファストパスでは、resolve/reject関数を作成する前にSymbol.speciesのルックアップを行っていた。
Symbol.speciesとは、map()やthen()など派生オブジェクトを返すメソッドが「返り値のコンストラクタをどれにするか」を決めるために参照するシンボルだ(MDN: Symbol.species)。Promise.prototype.thenは返却するPromiseを生成するコンストラクタを調べるため、this.constructor[Symbol.species]を読みに行く(仕様のSpeciesConstructorステップ)。冒頭のコードはここでgetterを爆発させる。ところがその時点ではresolve/reject関数がまだ存在しない。例外はジョブの外に逃げ出し、Promiseは永遠にpendingのまま放置された。
Sharpはこれを「列車が脱線した」と表現している。fulfillとrejectのどちらのトラックにも到達できず、列車は駅に着かなかった。
修正は明快だ。resolve/reject関数を.then()呼び出しより先に作成するようにすれば、例外が発生しても既存のエラーハンドリングがrejectを発火できる。アロケーション自体は例外を投げないため、順序を入れ替えるだけでよく、差分は+16行に収まった。
TypeScriptがPromise<T, E>を採用しない理由も説明できる
この修正はついでに、長年の議論に具体的な根拠も与えている。TypeScriptでは2015年からPromise<T, E>(型パラメータでrejectの型も指定できるように)を求めるissue #6283が存在し、今も1,000件超のリアクションを集める人気の要望だ。
この提案に反対したRon Bucktonは「設計時に例外の正確な型を保証できない」と述べていた。今回の件はその根拠を具体的に示している。ユーザーランドのコードが一切reject()を呼ばなくても、仕様自体がrejectトラックに値を置き得る。Symbol.speciesのgetterが投げたエラーが、ユーザーのコードを経由せずrejectedの理由になるのだ。これではrejectの型パラメータをunknown以外にする方法がなく、型安全の恩恵は得られない。
詳細はFixing a WebKit bug to see if promises are made of train tracksを参照していただきたい。