Node.js v15ではunhandled rejectionでプロセスがエラー終了する
今月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 aPromise
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で登録したコールバックはそれぞれ専用のフェーズ(timers
、check
、poll
)で処理されます。したがって、これらのAPIを使って登録したコールバックが実行されるには、イベントループがそのフェーズまで進まなければなりません。一方、process.nextTick()
で登録したコールバックはnextTickQueue
と呼ばれるキューに、queueMicrotask()
やPromise
で登録したコールバックはmicroTaskQueue
と呼ばれるキューに入ります。これらのキューは特定のフェーズに属さず、現在実行中の処理の完了後すぐ、イベントループが次のフェーズへ進む前に実行されます(なお、nextTickQueue
の方がmicroTaskQueue
より先に実行されます)。
したがって、unhandled-rejections.js
の実行結果は、エラーハンドラがnextTickQueue
、microTaskQueue
の処理されるタイミングまでに追加された場合は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がprocess
のunhandledRejection
イベントとしてトラッキングできるようになった際、unhandled rejection発生時にNode.jsがどのように挙動すべきかが議論され、当然プロセスをエラー終了するべきという意見も上がりました。しかし、当時は大きな破壊的変更になることなどから反対意見が出て採用には至らず、結局UnhandledPromiseRejectionWarning
を出力するのみにとどまりました。
uncaught exception
unhandled rejectionと類似のものとして、uncaught exceptionがあります。これはasync関数外でthrowされたエラーが、try...catch
でハンドリングされなかった場合に発生し、process
のuncaughtException
イベントでトラッキングできます。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.exitCode に1 がセットされる(そのままNode.jsのプロセスが終了するとシェルによってエラー終了と解釈される) |
none |
unhandledRejection イベントは発行するが、UnhandledPromiseRejectionWarning は出力しない |
--unhandled-rejections
フラグを指定しなかった場合のv14のデフォルトの挙動はwarn
と似ていますが、デフォルトではUnhandledPromiseRejectionWarning
に加え、[DEP0018] DeprecationWarning
が出力される点が異なります。
throw
またはstrict
では、unhandled rejection発生時にプロセスがエラー終了することになります。unhandledRejection
(throw
の場合)やuncaughtException
(throw
またはstrict
の場合)にイベントリスナーを登録し、その中でprocess.exit(exitCode)
を実行しないようにすれば、プロセスの終了を回避できますが、それなら最初からwarn
など別のモードを使うべきです。
warn-with-error-code
でprocess.exitCode
に1
がセットされることにどのような意味があるかというと、たとえば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の入門書として、実際にコードを書き、それを実行しながら学んでいくスタイルの書籍ですが、1つ1つのテーマを掘り下げながら書いたので、すでにある程度Node.jsの経験がある方にとっても、読めばきっと何かしら新しい発見があるはずです。この記事を楽しんでいただけたなら、書籍の方も読み甲斐を感じていただけるのではないかと思います。
どうぞよろしくお願いいたします。
Discussion