💣

Node.js v15ではunhandled rejectionでプロセスがエラー終了する

2020/10/15に公開

今月20日にInitial Releaseが予定されているNode.js v15ですが、ここでのunhandled rejectionの挙動変更について解説します。

unhandled rejectionとは

async関数内でthrowされたエラーや、rejectされたPromiseが、.catch()などでハンドリングされずにrejectされたままになっている状態を、unhandled rejction(またはunhandled promise rejction)と呼びます。Node.js v14では、unhandled rejectionが発生すると次のような警告が出力されます。

$ node -e "Promise.reject()"
(node:22145) UnhandledPromiseRejectionWarning: undefined
(Use `node --trace-warnings ...` to show where the warning was created)
(node:22145) 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(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:22145) [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を使っている方ならきっとこの警告を見たことがあるでしょう。警告は2つあって、1つ目(UnhandledPromiseRejectionWarning)はunhandled rejectionが発生したことを通知するとともに、unhandled rejection発生時にプロセスを落としたい場合は--unhandled-rejections=strictフラグを指定するべきことを述べています。2つ目([DEP0018] DeprecationWarning)はunhandled rejectionがdeprecatedであり、将来的にはunhandled rejectionによってプロセスがエラー終了するであろうことを述べています。
2つ目の警告がNode.js v7で追加されてから4年が経過し、一体いつunhandled rejectionでプロセスがエラー終了するようになるのか、そもそも本当にそんな日が来るのか怪しんでいた方も少なからずいるのではないかと思います。しかしついにv15で、unhandled rejectionによってプロセスがエラー終了することになりました。

unhandled rejectionはいつunhandled rejectionになるのか

Promiseはその性質上、後からでもエラーハンドラを追加できます。Promiseインスタンスの状態がrejectedになったからといって、それが将来にわたってハンドリングされないままかどうかは、その時点では分かりません。Node.jsのドキュメントには次のような記述があります。

The 'unhandledRejection' event is emitted whenever a Promise is rejected and no error handler is attached to the promise within a turn of the event loop.

ここで、"within a turn of the event loop"とは、具体的にどういうことでしょうか。同期的にエラーハンドラが追加されればunhandled rejectionにならず、非同期的に追加されればunhandled rejectionになる、というルールであれば分かりやすいですが、ことはそう単純ではありません。実際にさまざまなタイミングでエラーハンドラを追加して、検証してみましょう。

// unhandled-rejections.js

// 同期的にエラーハンドラを追加
const p1 = Promise.reject(new Error('sync'))
p1.catch(() => {})

// process.nextTick()のコールバックでエラーハンドラを追加
const p2 = Promise.reject(new Error('process.nextTick()'))
process.nextTick(() => p2.catch(() => {}))

// queueMicrotask()のコールバックでエラーハンドラを追加
const p3 = Promise.reject(new Error('queueMicrotask()'))
queueMicrotask(() => p3.catch(() => {}))

// Promise.resolve().then()のコールバックでエラーハンドラを追加
const p4 = Promise.reject(new Error('Promise.resolve().then()'))
Promise.resolve().then(() => p4.catch(() => {}))

// setImmediate()のコールバックでエラーハンドラを追加
const p5 = Promise.reject(new Error('setImmediate()'))
setImmediate(() => p5.catch(() => {}))

// setTimeout()のコールバックでエラーハンドラを追加
const p6 = Promise.reject(new Error('setTimeout()'))
setTimeout(() => p6.catch(() => {}), 0)

// エラーハンドラなし
const p7 = Promise.reject(new Error('unhandled'))

nodeコマンドでこのファイルを実行します。

> node unhandled-rejections.js 
(node:21838) UnhandledPromiseRejectionWarning: Error: setImmediate()
    # ... (省略)
(node:21838) UnhandledPromiseRejectionWarning: Error: setTimeout()
    # ... (省略)
(node:21838) UnhandledPromiseRejectionWarning: Error: unhandled
    # ... (省略)
(node:21838) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 6)
(node:21838) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 5)

エラーハンドラを最後まで追加しなかったものがunhandled rejectionになるのは当然として、setImmediate()setTimeout()のコールバックでエラーハンドラを追加したものもunhandled rejectionとなることが分かります(ただし、これらについてはその後非同期的にエラーハンドラを追加したことが検知され、PromiseRejectionHandledWarningという警告が出ています)。一方、エラーハンドラを同期的に追加した場合や、process.nextTick()queueMicrotask()Promise.resolve().then()のコールバックで追加した場合は、unhandled rejectionになりませんでした。
各APIで登録した非同期コールバックがどのようなタイミングで実行されるかを知っていると、この挙動を理解しやすいでしょう。Node.jsのイベントループには役割の異なるいくつかのフェーズがあって、setTimeout()などのタイマーAPI、setImmediate()、またはI/O関連のAPIで登録したコールバックはそれぞれ専用のフェーズ(timerscheckpoll)で処理されます。したがって、これらのAPIを使って登録したコールバックが実行されるには、イベントループがそのフェーズまで進まなければなりません。一方、process.nextTick()で登録したコールバックはnextTickQueueと呼ばれるキューに、queueMicrotask()Promiseで登録したコールバックはmicroTaskQueueと呼ばれるキューに入ります。これらのキューは特定のフェーズに属さず、現在実行中の処理の完了後すぐ、イベントループが次のフェーズへ進む前に実行されます(なお、nextTickQueueの方がmicroTaskQueueより先に実行されます)。
したがって、unhandled-rejections.jsの実行結果は、エラーハンドラがnextTickQueuemicroTaskQueueの処理されるタイミングまでに追加された場合はunhandled rejectionにならず、イベントループのフェーズが進んでから追加された場合はunhandled rejectionになることを示しています。Promiseで登録したコールバックがmicroTaskQueueに入る以上、少なくともmicroTaskQueueのコールバックを実行し終えてからでなければrejectionがunhandledかどうかを確定できないと考えられるため、当然といえば当然かもしれません。表にまとめると、次のようになります。

エラーハンドラの追加方法 追加されるタイミング unhandled rejectionに
1 同期的 同期的 ならない
2 process.nextTick()のコールバック nextTickQueue処理時 ならない
3 queueMicrotask()のコールバック microTaskQueue処理時 ならない
4 Promise.resolve().then()のコールバック microTaskQueue処理時 ならない
5 setTimeout()のコールバック timersフェーズ なる
6 setImmediate()のコールバック checkフェーズ なる
7 追加しない - なる

イベントループのフェーズや各種キューについての詳細は、Node.jsのドキュメントやこちらの記事が参考になると思います。

なぜunhandled rejectionでプロセスがエラー終了すべきなのか

unhandled rejectionが発生したということは、Promiseを使った非同期処理において想定外のエラーが発生したか、あるべきエラーハンドリングが漏れているか、あるいは単にコードに不具合がある状況が考えられます。いずれの状況下でも、システムは不安定な状態にあり、継続して動かし続ければさらなる問題を引き起こす可能性があります。また、このような状況でプロセスがエラー終了するようになっていれば、エラーハンドリングの漏れやコードの不具合にいち早く気がつけます。
unhandled rejectionがprocessunhandledRejectionイベントとしてトラッキングできるようになった際、unhandled rejection発生時にNode.jsがどのように挙動すべきかが議論され、当然プロセスをエラー終了するべきという意見も上がりました。しかし、当時は大きな破壊的変更になることなどから反対意見が出て採用には至らず、結局UnhandledPromiseRejectionWarningを出力するのみにとどまりました。

uncaught exception

unhandled rejectionと類似のものとして、uncaught exceptionがあります。これはasync関数外でthrowされたエラーが、try...catchでハンドリングされなかった場合に発生し、processuncaughtExceptionイベントでトラッキングできます。Node.jsは古くから、uncaught exceptionが発生した場合は「なぜunhandled rejectionでプロセスがエラー終了すべきなのか」に書いたのと同様の理由でプロセスをエラー終了するようになっています。つまり、Node.jsはこれまで、unhandled rejectionとuncaught exceptionという性質のよく似たものに対して、異なる振る舞いを取ってきたことになります。
uncaughtExceptionにイベントリスナーを登録すると、そのままではプロセスがエラー終了しないようになるため、この挙動を回避できますが、そのような使い方は推奨されていませんuncaughtExceptionのイベントリスナーはプロセス終了前のクリーンアップを実行するためのものであり、これを使う場合は最後にprocess.exit(exitCode)を明示的に実行すべきです。

// プロセス終了前のクリーンアップが必要な場合、
// uncaughtExceptionのイベントリスナーを登録する
process.on('uncaughtException', () => {
  // ...
  
  // 最後にprocess.exit(exitCode)を明示的に実行する
  process.exit(1)
})

--unhandled-rejectionsフラグ

こうした状況の中での注目すべき変化として、v12.0.0で--unhandled-rejectionsフラグが導入されました。このフラグはunhandled rejection発生時のNode.jsの挙動を制御するためのもので、v14の最新バージョンでは次の5つのモードがサポートされています。

モード 挙動
throw unhandledRejectionイベントを発行し、そのリスナーがない場合はunhandled rejectionをuncaught exceptionとして扱う
strict unhandled rejectionをuncaught exceptionとして扱う
warn unhandledRejectionイベントを発行するとともに、UnhandledPromiseRejectionWarningを出力する
warn-with-error-code warnの挙動に加え、process.exitCode1がセットされる(そのままNode.jsのプロセスが終了するとシェルによってエラー終了と解釈される)
none unhandledRejectionイベントは発行するが、UnhandledPromiseRejectionWarningは出力しない

--unhandled-rejectionsフラグを指定しなかった場合のv14のデフォルトの挙動はwarnと似ていますが、デフォルトではUnhandledPromiseRejectionWarningに加え、[DEP0018] DeprecationWarningが出力される点が異なります。
throwまたはstrictでは、unhandled rejection発生時にプロセスがエラー終了することになります。unhandledRejectionthrowの場合)やuncaughtExceptionthrowまたはstrictの場合)にイベントリスナーを登録し、その中でprocess.exit(exitCode)を実行しないようにすれば、プロセスの終了を回避できますが、それなら最初からwarnなど別のモードを使うべきです。
warn-with-error-codeprocess.exitCode1がセットされることにどのような意味があるかというと、たとえばNode.jsでスクリプトを書き、その中でPromiseを使うような状況を考えると分かりやすいと思います。

const fs = require('fs')

// ...
const content = await fs.promises.readFile('no-such-file.txt')
// ...

no-such-file.txtが存在しない場合、このスクリプトはfs.promises.readFile()の行で終了しますが、このときv14のデフォルトではexitコードが0になり、シェルは正常終了と解釈してしまいます。この挙動は明らかに直感に反するでしょう。
この例はまた、特にasync関数やトップレベルawaitが利用可能な状況では、unhandled rejectionを引き起こすコードとuncaught exceptionを引き起こすコードは識別しづらく、両者の挙動が異なることは混乱の元になることを示しています(uncaught exceptionでプロセスが終了した場合は、exitコードはもちろん1になります)。

Node.jsユーザーを対象としたsurveyと、TSCでの投票

v15のリリースを前にして、[DEP0018] DeprecationWarningを取り除くべくさらなるアクションが取られました。まず、Node.jsのユーザー向けのsurveyにより、Promiseがどのように使われ、unhandled rejectionがどのようにハンドリングされているか、--unhandled-rejectionsフラグのデフォルト値が何であるべきかについて調査が行われました。結果はGitHubにエクセルファイルで保存されています。--unhandled-rejectionsフラグのあるべきデフォルト値についての質問の結果は、次のようなものでした(筆者がエクセルファイルから集計)。

モード 得票数
throw 984
strict 792
warn 270
warn-with-error-code 288
none 56
その他 33

70%以上のユーザーが、unhandled rejectionの結果としてプロセスがエラー終了する(throwまたはstrict)ことを支持しました。
この結果を受けて、Node.jsのTSC(Technical Steering Committee)で投票が行われ、ほぼ全会一致throwがデフォルトの挙動として選ばれました。その後、--unhandled-rejections=throwをデフォルトとするPRがマージされました

おわりに

これまでプロセスが動き続けていた状況下で、プロセスがエラー終了するようになるのは大きな破壊的変更であり、場合によっては従来正常に動作していたアプリが動かなくなってしまうこともありえます。ただし、デフォルトが--unhandled-rejections=throwであるバージョンがLTSになるのは、最短で来年の10月(v16がActive LTSになるタイミング)で、必ずしもすぐに何か対応が必要というわけではありません。また、v15リリース後に様子を見て、状況次第でこのデフォルトの挙動を変更する可能性について言及されていることにも注意すべきです。

If it turns out we release v15 and this decision becomes a huge pain across a large portion of the ecosystem (with evidence of that), we might reconsider it. But for now, throw is decided as the default.

surveyの結果は、unhandled rejectionによるプロセスのエラー終了を支持するユーザーが多数を占める一方、そうではないユーザーも少なからず存在することを示しています。実際、--unhandled-rejections=throwがデフォルトになることに反対する意見もすでに上がっています(たとえば、エラーハンドリングが後から追加される可能性を考慮して、UnhandledRejectionイベントのタイミングではなく、rejectedなPromiseインスタンスがGCされるタイミングでエラー終了すべき、など)。
いずれにせよ、現状もしunhandled rejectionが発生しているなら、その状況は好ましいとは言えないため、これまでよりもUnhandledPromiseRejectionWarningを修正する意識を高く持つところから始めるとよいのではないでしょうか。もちろん、デフォルトの挙動が受け入れられなければ、--unhandled-rejections=warnなどを明示的に設定して、unhandled rejectionによるエラー終了を回避することも考えられます。

おまけ(宣伝)

ハンズオンNode.js』という本を書きました。11月17日にオライリージャパンより出版されます。あんどうやすし(@technohippy)さんの『ハンズオンJavaScript』と同時発売です。

ハンズオンNode.js

Node.jsの入門書として、実際にコードを書き、それを実行しながら学んでいくスタイルの書籍ですが、1つ1つのテーマを掘り下げながら書いたので、すでにある程度Node.jsの経験がある方にとっても、読めばきっと何かしら新しい発見があるはずです。この記事を楽しんでいただけたなら、書籍の方も読み甲斐を感じていただけるのではないかと思います。
どうぞよろしくお願いいたします。

Discussion