Humanity

Edit the world by your favorite way

Promise のコンストラクタに渡した async function 内で throw しても rejected な Promise は作られない(常に fulfilled になる)

何を当たり前のことをと思うかもしれませんが、勘違いしてたので恥を晒しておきます。

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 the new Promise constructor, or the scope of the new 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 で規定されています(強調は自分)。

  1. If NewTarget is undefined, throw a TypeError exception.
    • new 演算子付きで呼び出されてなかったら TypeError
  2. If IsCallable(executor) is false, throw a TypeError exception.
    • function など呼び出し可能なオブジェクトでなかったら TypeError
  3. Let promise be OrdinaryCreateFromConstructor(NewTarget, "%PromisePrototype%", «‍[[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]]» ).
    • prototype プロパティから Promise オブジェクトを作成して promise にセット
  4. ReturnIfAbrupt(promise).
    • promise が abrupt completion なら return
  5. Set promise's [[PromiseState]] internal slot to "pending".
    • 内部スロット [[PromiseState]] を "pending" にセット
  6. Set promise's [[PromiseFulfillReactions]] internal slot to a new empty List.
    • 内部スロット [[PromiseFulfillReactions]] を空のリストにセット
  7. Set promise's [[PromiseRejectReactions]] internal slot to a new empty List.
    • 内部スロット [[PromiseRejectReactions]] を空のリストにセット
  8. Let resolvingFunctions be CreateResolvingFunctions(promise).
    • resolvingFunctions を CreateResolvingFunctions(promise) にする
  9. Let completion be Call(executor, undefined, «resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]]»).
    • executor を呼び出し(this = undefined, 第1引数 = resolvingFunctions.[[Resolve]], 第2引数 = resolvingFunctions.[[Reject]])、completion をその結果とする
  10. If completion is an abrupt completion, then
    • もし completion が abrupt completion なら、その時は
    • Let status be Call(resolvingFunctions.[[Reject]], undefined, «completion.[[value]]»).
      • resolvingFunctions.[[Reject]] を呼び出し(this = undefined, 第1引数 = completion.[[value]])、status をその結果とする
    • ReturnIfAbrupt(status).
      • status が abrupt completion なら return
  11. 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]] のそれぞれの値はおそらく皆さんの想像通りです。

今回は executor 関数が rejected 状態の Promise を返した場合どうなるかが知りたいので、 まず 9 の Call()` の結果がどういうオブジェクトになるかを調べます。 以下がCall()` の処理内容です。

Call(F, V, [argumentsList])

  1. ReturnIfAbrupt(F).
  2. If argumentsList was not passed, let argumentsList be a new empty List.
  3. If IsCallable(F) is false, throw a TypeError exception.
  4. 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()

  1. ReturnIfAbrupt(argument).
  2. If Type(argument) is not Object, return false.
  3. If argument has a [[Call]] internal method, return true.
  4. 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 つけるのやめろって話でした。

途中から分かり切った事に対して半ば意地で仕様をしつこく調べてましたが、仕様の読み方とかが分かったので良かったです。

*1:Promise を返す関数を書いても、わざわざ Promise のコンストラクタに与える関数だけを切り出して Promise に渡したりはしないでしょう