Slide 1

Slide 1 text

JavaScriptにおける async/await呼び出しの スタックトレースの困難と実装 JSConf JP 2025 / Sosuke Suzuki

Slide 2

Slide 2 text

⾃⼰紹介 Sosuke Suzuki ( https://x.com/__sosukesuzuki ) Systems Engineer at Bun WebKit Reviewer Prettier maintainer

Slide 3

Slide 3 text

現代JavaScript世界における⾮同期処理 かつてJavaScriptにおける⾮同期処理はコールバックが主流 const fs = require("node:fs"); fs.readFile('/etc/passwd', (_, data) => { console.log(data); }); ES2015でPromise、ES2017でasync関数とawait式が導⼊され、JavaScript世界 における⾮同期プログラミングは⼤きく変わった const fs = require("node:fs/promises"); const data = await fs.readFile('/etc/passwd');

Slide 4

Slide 4 text

Promiseを前提としたAPIの流⾏ 今では、Promiseを前提としたAPIが提供されることが多い 例えば、Web標準のFetch API const response = await fetch("https://example.org/products.json"); ORMライブラリのPrisma const user = await prisma.user.findFirst({ where: { name: 'Alice' }, }); などなど...

Slide 5

Slide 5 text

JSCにおけるasync関数のスタックトレースの問題 JavaScriptCoreではasync関数の中で作られるErrorのスタックトレースをプログ ラマが望む形では(※) 提供できてなかった ※ JavaScriptにおいてスタックトレースに乗せるべき情報は仕様では定められていないため、仕様 違反ではない。単に不便というだけ。 具体的には Errorが作成される直前のawait以降の情報しかスタックトレースに乗 らない という問題があった

Slide 6

Slide 6 text

async関数とスタックトレースの問題の具体例 async function foo() { await bar(); } async function bar() { await 1; throw new Error("oops"); } foo().catch(e => console.log(e.stack) ); 期待する出⼒ Error: oops at bar (test.js:6:13) at async foo (test.js:2:16) 実際の出⼒ (Bun 1.2) Error: oops at bar (test.js:6:13) スタックフレーム foo が⽋損している

Slide 7

Slide 7 text

async関数とスタックトレースの問題の具体例 async function foo() { await bar(); } async function bar() { await 1; throw new Error("oops"); } foo().catch(e => console.log(e.stack) ); 作成されたError の直前のawait 以降の情報(つまり関数bar)しかス タックトレースに乗らない

Slide 8

Slide 8 text

Bunにとっては重要な課題 これは、JavaScriptのセマンティクスから考えると当然(後述)なのだが、Chrome とFirefoxはこの⽋損が起こらないように特別な対応を以前からしている V8のレイヤで対応されているためNodeとDenoでもこの⽋損は起こらない JSCは2025年8⽉の時点でもこの問題があった Safariはまだフラグを有効にしていないので、今もある ユーザーのデバッグ容易性はBunにとってはプロダクト価値の⼀つであるため、 これは重要な問題だった Safariチームがどう考えているかは知らない

Slide 9

Slide 9 text

JavaScriptエンジンのレイヤーでの解決 スタックトレースの⽣成はJavaScriptエンジンのコアな領域であるため、Bunの レイヤーでの解決は困難 「関数フレームのリストの取得」はJavaScriptエンジンが、「取得した関数フレームのリストをど ういう⾒た⽬で表⽰するか」はランタイムレイヤで決定している BunはJavaScriptCoreをフォークしているが、可能な限りdownstream特有の変 更は増やしたくない(メンテナンスコストの⾼さ、コードの信頼性の低さ) 現在は、例えばV8互換のDate APIの追加や、Nodeのasync_hooksをサポートするための変更など が適⽤されている 今回はupstreamのJavaScriptCoreに⼤して修正を加えることで問題を解決した

Slide 10

Slide 10 text

なぜスタックフレームが⽋損するのか async関数はawait式に到達したときPromiseを返して(実質的に)returnし、コールス タックを抜ける async関数内でawaitされたPromiseがresolveされたとき、await以降の処理がマイクロ タスクキューに突っ込まれる 同期処理が全て終わったあと(コールスタックが空になったあと)マイクロタスクが順 次実⾏され、async関数のawait以降の処理が実⾏される このときasync関数の呼び出し元の関数はすでにコールスタックには残っていない そのため、通常のスタックトレースと同じ⽅法(エラーが発⽣した時点でのコールス タックを単純に⾛査する)では、async関数の呼び出し元を知ることができない

Slide 11

Slide 11 text

スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo

Slide 12

Slide 12 text

スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar

Slide 13

Slide 13 text

スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar エラーがthrowされる コールスタックは foo, bar よってスタックトレースにも foo, bar が乗る ✅

Slide 14

Slide 14 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo

Slide 15

Slide 15 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar

Slide 16

Slide 16 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar await p によってbarはPromiseをreturn コールスタックは foo

Slide 17

Slide 17 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar await 1 によってbarはPromiseをreturn コールスタックは foo await bar() によってfooはPromiseをreturn コールスタックは 空

Slide 18

Slide 18 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる

Slide 19

Slide 19 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる (コールスタックが空になったとき)barの残り の処理がマイクロタスクとして実⾏される コールスタックは bar

Slide 20

Slide 20 text

スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる (コールスタックが空になったとき)barの残り の処理がマイクロタスクとして実⾏される コールスタックは bar エラーがthrowされる コールスタックは bar よってスタックトレースには bar しか乗らない ❌

Slide 21

Slide 21 text

解決策: マイクロタスクから呼び出し元の情報を得る 基本的なアイデア:現在実⾏中のマイクロタスクを保持しておき、そこから辿れ る情報によって呼び出し元(だった)関数の情報を収集する。これはその時点で のコールスタックの情報には依存しない 現在実⾏中のマイクロタスクから参照のチェーンを辿ることによって、呼び出し 元の情報を収集できる 以降、実装についてはJavaScriptCoreを前提とする

Slide 22

Slide 22 text

マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); ちょうどエラーがthrowされたとする。このとき、現 在実⾏中のマイクロタスクはasync関数bazの後半部分 このマイクロタスクはジェネレータとどこまで進んだ かという状態で表現される。そしてそのジェネレータ は⾃⾝(baz)が返すPromiseへの参照を持つ。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2

Slide 23

Slide 23 text

マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); Promise は⾃⾝がresolveした時に⾏われる処理 (reaction)への参照を持つ。 reactionは⾃⾝が所属するasync関数(bar)に対応する ジェネレータへの参照を持つ。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2 reaction (r2 がresolveした時の処理) bar(reactionが所属するasync 関数に対応するジェネレータ)

Slide 24

Slide 24 text

マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); これで、実⾏中のマイクロタスクから、かつて⾃⾝を 呼び出したasync関数を取得できる。 これを再帰的に呼び出すことによって、連続した呼び 出しチェーンを再現する。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2 reaction (r2 がresolveした時の処理) bar(reactionが所属するasync 関数に対応するジェネレータ)

Slide 25

Slide 25 text

マイクロタスクから呼び出し元の情報を得る例 VMEntryRecord JSGenerator JSPromise JSPromiseReaction JSGenerator JSPromise JSPromiseReaction JSGenerator JSPromise JSPromiseReaction つまり、以下のように参照を参照を辿ることで、コールスタックからはすでに失 われたasync関数の呼び出し元の情報を復元できる baz bar foo 実⾏中のマイクロタスク を持つ状態

Slide 26

Slide 26 text

位置情報の取得 スタックトレースには、関数呼び出しのチェーンの情報だけではなく、関数が呼 び出された位置情報(⾏と列)が必要 Error: oops at bar (test.js:6:13) at async foo (test.js:2:16) JSCでは、関数内のバイトコードのインデックスから位置情報を復元できる

Slide 27

Slide 27 text

通常の関数呼び出し function foo() { bar(); } function bar() { throw new Error("oops"); } foo(); fooに対応するバイトコード(イメージ) [ 0] enter … [ 17] call_ignore_result callee:loc5, argc:1, argv:12 [ 22] ret value:Undefined(const0) 通常の関数呼び出しにおける位置情報の取得の例 call_ignore_result 命令が 関数bar()を呼び出す。 JSCでは、この call_ignore_result命令の関 数foo内でのインデックスが わかれば、位置情報を知る ことができる。

Slide 28

Slide 28 text

通常の関数呼び出し function foo() { bar(); } function bar() { throw new Error("oops"); } foo(); fooに対応するバイトコード(イメージ) [ 0] enter … [ 17] call_ignore_result callee:loc5, argc:1, argv:12 [ 22] ret value:Undefined(const0) 通常の関数呼び出しにおける位置情報の取得の例 barでErrorが作成されたと き、コールスタックは foo, bar。 このとき、fooの呼び出しに 対応するスタックフレーム は、foo内でのpc(=17)の情 報を持っている。 通常の関数呼び出しでは、 このpcから位置情報を取得 する。

Slide 29

Slide 29 text

async関数内における位置情報取得の問題 async function foo() { await bar(); } async function bar() { await 1; throw new Error("oops"); } foo(); async関数では、Errorが作成された時点ですでに コールスタックにfooは存在しない。 そのためfoo()に対応するスタックフレームのpcか ら、await bar(); の位置情報を得ることはできな い。

Slide 30

Slide 30 text

async関数内における位置情報の取得⽅法 async関数がバイトコードへとコンパイルされるときGeneratorへと変換される (Generatorification)。Generatorは、開始されうる箇所へとジャンプする分岐をもつ state(どのawaitまで処理が進んだのかを表す)付きswitchとして表現される。 async function foo() { // state 0 await bar(); // state 1 await baz(); // state 2 } 対応するバイトコード(イメージ) 0 op_enter 5 op_switch_imm [state] ← 再開時の分岐 case 0 → 10 case 1 → 30 (await bar() の次) case 2 → 50 (await baz() の次) 10 20 op_yield ← state=1 を保存して return 30 ← await bar() の後から再開 40 45 op_yield ← state=2 を保存して return 50 ← await baz() の後から再開 60 op_ret

Slide 31

Slide 31 text

async関数内における位置情報の取得⽅法 Errorが作成された時のfoo()におけるstateの値は、JSGeneratorオブジェクトか ら取得できる。stateの値がわかれば、関数に対応するバイトコードのswitchの caseのオペランドからawait bar();に対応するバイトコードインデックスがわかる async function foo() { // state 0 await bar(); // state 1 await baz(); // state 2 } 対応するバイトコード(イメージ) 0 op_enter 5 op_switch_imm [state] ← 再開時の分岐 case 0 → 10 case 1 → 30 (await bar() の次) case 2 → 50 (await baz() の次) 10 20 op_yield ← state=1 を保存して return 30 ← await bar() の後から再開 40 45 op_yield ← state=2 を保存して return 50 ← await baz() の後から再開 60 op_ret

Slide 32

Slide 32 text

async関数内における位置情報の取得⽅法 参照を⼿繰り寄せて⼊⼿したJSGeneratorから、該当する関数へのバイトコード とのそのstateの情報から、bar を呼び出した位置情報がわかる VMEntryRecord JSGenerator JSPromise JSPromiseReaction JSGenerator bar foo 実⾏中のマイクロタスク を持つ状態 バイトコード 位置情報

Slide 33

Slide 33 text

この実装における限界 この実装には、いくつかの限界がある。 1. GCが起こるタイミングによっては、バイトコードが解放されてしまい、位置 情報が得られない時がある。この場合は、位置情報がない状態でスタックト レースを⽣成する(関数名だけでもある⽅がマシだから) a. JSGeneratorにstateと⼀緒に対応するジャンプ先のバイトコードインデックスを保存すれば 解決するはず? 2. await しない場合にはスタックトレースを⽣成できない a. return await foo(); VS return foo();

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

関連資料 ● V8の実装のデザインドキュメント ○ https://docs.google.com/document/d/13Sy_kBIJGP0XT34V1CV3nkWya4TwYx9L3Yv45LdG B6Q ○ 細かい実装は違うが、JSCはこの設計を⼤いに参考にしている ● V8の⾮同期スタックトレースの実装 ○ https://chromium.googlesource.com/v8/v8.git/+/f537d77845c666240c8d13466e224a5206 12f7c5 ● WebKitの⾮同期スタックトレースの実装 ○ https://github.com/WebKit/WebKit/pull/50290