Promise のコンストラクタに渡した async function 内で throw しても rejected な Promise は作られない(常に fulfilled になる)
何を当たり前のことをと思うかもしれませんが、勘違いしてたので恥を晒しておきます。
こんな使い方する人いるのか "no-async-promise-executor - Rules - ESLint - Pluggable JavaScript linter" https://t.co/BJy6aqMYQ0
— azu (@azu_re) 2018年8月4日
const result = new Promise(async function executor(resolve, reject) { readFile('foo.txt', function(err, result) { if (err) { reject(err); } else { resolve(result); } }); });
(説明のために arrow function を function にして名前を付けているので、元のコードそのままではありません)
Promise のコンストラクタに async function を渡してます。 まぁ普段こういう風には自分も書かない *1 ので違和感はありましたが、こう書いた場合何が問題になるか分かりませんでした。
- If a Promise executor function is using
await
, this is usually a sign that it is not actually necessary to use thenew Promise
constructor, or the scope of thenew Promise
constructor can be reduced.(私訳) executor 関数が
await
を使う場合、大体new Promise
コンストラクタを使用する必要はないというサインです。もしくはnew Promise
コンストラクタのスコープを減らせる可能性があります。
これは分かります。
Promise は Promise のまま扱えばよくて、以下のように await
で値を取り出す必要はないということです(このコードも引用です)。
const result = new Promise(async (resolve, reject) => { resolve(await foo); });
は以下のように書けます。
const result = Promise.resolve(foo);
If an async executor function throws an error, the error will be lost and won’t cause the newly-constructed Promise to reject. This could make it difficult to debug and handle some errors.
(私訳) もし async の executor 関数がエラーを throw した場合、エラーは失われ新しく作られた Promise が reject することはありません。これはデバッグとエラー処理を難しくします。
ただこれがよく分かりませんでした。 Promise のコンストラクタに指定したコールバック内で throw したら reject() 呼んだのと同じ結果にならない?と思ってて、それは正しかったのですが、 結果から言うと今回は reject() 呼んだのと同じ結果になりません。throw されないからです。
なぜなら async function 内で throw した場合は rejected な Promise が返るからです。
つまり簡単に言ってしまうと Promise のコンストラクタでは、指定したコールバックの返り値は見ておらず、エラーが起こったら rejected とする。
それ以外はコールバックに渡した resolve()
か reject()
が呼ばれるのを待つ、って事ですね。
よって async function 内で throw した場合、いつまでも resolve()
か reject()
が呼ばれるのを待つ事になります。
…が実際はいつまでも待つ事にはなりません。
どういう風に検知してるのか分かってないですが、
resolve()
も reject()
も未来永劫呼ばれないと Promise が判断すると unhandled rejection warning という警告が JavaScript 処理系で出ます。
ブラウザが unhandled rejection warning が発生した時にコンソールに出すかは実装依存で、
Node.js の場合は現在はエラー標準出力にメッセージが出ます。
// hello.js Promise.reject(42); console.log('hello');
$ node hello.js hello (node:31026) UnhandledPromiseRejectionWarning: 42 (node:31026) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1) (node:31026) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
警告にも書いてある通り、将来は Node.js プロセスごと異常終了するらしいのでちゃんと処理しましょう。
以下のようにして unhandled rejection を処理する事もできます。 エラーログに吐いたりとか、あるいは異常終了するのが正解な場合もあるかもしれません。
// Node.js の場合 process.on('unhandledRejection', (reason, promise) => { console.log('unhandled rejection: promise =', promise, ', reason =', reason); }); // ブラウザの場合 (引用元: https://developer.mozilla.org/en-US/docs/Web/Events/unhandledrejection) window.addEventListener("unhandledrejection", function (event) { console.warn("WARNING: Unhandled promise rejection. Shame on you! Reason: " + event.reason); });
詳しく
「コンストラクタに指定したコールバック内で throw した場合、Promise はそれを catch し reject() を呼んだ場合と同じ扱いとする」挙動に関してもうちょっと詳しく調べてみました。 この動作に関しては ECMAScript 25.4.3.1 Promise の項の 10 で規定されています(強調は自分)。
- If
NewTarget
is undefined, throw aTypeError
exception.new
演算子付きで呼び出されてなかったら TypeError
- If
IsCallable(executor)
is false, throw aTypeError
exception.- function など呼び出し可能なオブジェクトでなかったら TypeError
- Let
promise
beOrdinaryCreateFromConstructor(NewTarget, "%PromisePrototype%", «[[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]]» )
.- prototype プロパティから Promise オブジェクトを作成して
promise
にセット
- prototype プロパティから Promise オブジェクトを作成して
ReturnIfAbrupt(promise)
.- promise が abrupt completion なら return
- Set
promise
's[[PromiseState]]
internal slot to "pending".- 内部スロット
[[PromiseState]]
を "pending" にセット
- 内部スロット
- Set
promise
's[[PromiseFulfillReactions]]
internal slot to a new empty List.- 内部スロット
[[PromiseFulfillReactions]]
を空のリストにセット
- 内部スロット
- Set
promise
's[[PromiseRejectReactions]]
internal slot to a new empty List.- 内部スロット
[[PromiseRejectReactions]]
を空のリストにセット
- 内部スロット
- Let
resolvingFunctions
beCreateResolvingFunctions(promise)
.- resolvingFunctions を CreateResolvingFunctions(promise) にする
- Let
completion
beCall(executor, undefined, «resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]]»)
.executor
を呼び出し(this =undefined
, 第1引数 =resolvingFunctions.[[Resolve]]
, 第2引数 =resolvingFunctions.[[Reject]]
)、completion
をその結果とする
- If
completion
is an abrupt completion, then- もし
completion
が abrupt completion なら、その時は - Let
status
beCall(resolvingFunctions.[[Reject]], undefined, «completion.[[value]]»)
.resolvingFunctions.[[Reject]]
を呼び出し(this =undefined
, 第1引数 =completion.[[value]]
)、status
をその結果とする
ReturnIfAbrupt(status)
.status
が abrupt completion なら return
- もし
- Return
promise
.promise
を返す
今回は 9 で executor
関数が rejected 状態の Promise を返した場合、10 でどのような扱いになるかが知りたいのですが、
その前にまず abrupt completion や ReturnIfAbrupt() といったものが何者なのかを知る必要があります。
まず abrupt completion とは [[type]]
が normal 以外の Completion Record です。
Completion Record とは
The Completion type is a Record used to explain the runtime propagation of values and control flow such as the behaviour of statements (break, continue, return and throw) that perform nonlocal transfers of control.
Field | Value | Meaning |
---|---|---|
[[type]] |
One of normal, break, continue, return, or throw | The type of completion that occurred. |
[[value]] |
any ECMAScript language value or empty | The value that was produced. |
[[target]] |
any ECMAScript string or empty | The target label for directed control transfers. |
record の名の通り ECMAScript 仕様の中で JavaScript の値を入れる入れ物的な扱いをされてるようです(雑)。
そして [[type]]
のそれぞれの値はおそらく皆さんの想像通りです。
- throw すると引数が abrupt completion ならその値、もしくは
throw
になる(13.14.1 Runtime Semantics: Evaluation) - return すると引数が abrupt completion ならその値、もしくは
return
になる(13.10.1 Runtime Semantics: Evaluation) - break すると
break
になる(13.9.3 Runtime Semantics: Evaluation) - continue すると
continue
になる(13.8.3 Runtime Semantics: Evaluation)
今回は executor
関数が rejected 状態の Promise を返した場合どうなるかが知りたいので、
まず 9 の Call()` の結果がどういうオブジェクトになるかを調べます。
以下がCall()` の処理内容です。
Call(F, V, [argumentsList])
ReturnIfAbrupt(F)
.- If argumentsList was not passed, let argumentsList be a new empty List.
- If
IsCallable(F)
is false, throw a TypeError exception.- Return
F.[[Call]](V, argumentsList)
.
abrupt completion になる可能性があるのは 1,3,4 の場合のみです。
ReturnIfAbrupt()
は引数の値が abrupt completion なら return する関数です。
F は executor
関数ですが、実は元の Promise のコンストラクタで 2. If IsCallable(executor) is false, throw a TypeError exception.
とすでにチェックしています。
IsCallable()
は
- ReturnIfAbrupt(argument).
- If Type(argument) is not Object, return false.
- If argument has a
[[Call]]
internal method, return true.- Return false.
のように abrupt completion かもチェックしているので、1,3 のチェックに引っかかる事はここではありません。
そして 4. Return F.[[Call]](V, argumentsList).
により executor
関数の評価結果が返されますが、
async function は常に Promise を返すので、結果無事 Promise のコンストラクタに [[type]] = normal
な Completion Record が返され、11. Return promise.
が実行されます。
つまり reject()
関数が実行されることはありません。
まとめ
ようするに Promise 内で resolve も reject も使われず unhandled rejection warning になる可能性があるので、 Promise コンストラクタに指定するコールバックに async つけるのやめろって話でした。
途中から分かり切った事に対して半ば意地で仕様をしつこく調べてましたが、仕様の読み方とかが分かったので良かったです。