複数のasyncを扱う際にreject起きるとNodeJSだとtry-catchをすり抜けるように見える件。あとPromiseのUnhandled Rejection

背景

これやってみると確かにそうなった。ツイートの内容の通りの対応でよいとは思う。

そもそも大本の書き方は正しくなくて、Promise.allなどですべて成功するか、一部失敗するかをまとめて待つべき、というのは体が覚えている。なんで覚えているかはよくわからない(多分jQuery.Deferredで色々痛い目を見ていた気がする)

それはそれとして、試してみるとNodeJSだけそうなるようだった。それがちょっとよくわからなかったのでメモ。

要約

結論をいえば以下の通り。

  • Unhandled Rejectionという「UnhandleかつReject状態のPromiseがあるときの振る舞い」がある
  • NodeJSのUnhandled Rejectionの扱い方がちょっと特殊っぽい
  • とはいえ、そもそもUnhandled Rejectionが起きるようなコードは悪いので、なぜ起きるかを理解し、起きないようなコードをかきましょう

詳しくは詳しい方々がたっぷりねっとり解説されているので読みましょう。

zenn.dev

zenn.dev

yosuke-furukawa.hatenablog.com

zenn.dev

なお、私は心が折れて全部読めなかった。以降はハルシネーションが起きている可能性があると思って読んでください。

調べてた時のログ

問題のコードを書く

元ネタからsleep関数を足したりログ足したりしているけど、概ね一緒だと思う。

function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

async function getUser() {
    await sleep(10);
}

async function getPost() {
    throw new Error("ERROR");
}

try {
    const userPromise = getUser();
    const postPromise = getPost();

    const user = await userPromise;
    const post = await postPromise;

    console.log("Done");
} catch (e) {
    console.log("catch!", e);
} finally {
    console.log("finally");
}
console.log("end");

NodeJS (v22) →catchされない

Node v22だと catchが行われていないようにみえる。

テキストのログを表示

PS T:\Temp> node .\test1.js
file:///T:/Temp/test1.js:10
    throw new Error("ERROR");
          ^

Error: ERROR
    at getPost (file:///T:/Temp/test1.js:10:11)
    at file:///T:/Temp/test1.js:15:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)

Node.js v22.12.0

Bun →catchされる

一応、Bunでも試してみた。ログが変だがちゃんと動作した。このログを取った直後は理解できていなかったが、おそらく変な理由はUnhandled Rejectionの処理とcatchの処理が動いているからだと思う。

テキストのログを表示

PS T:\Temp> bun run .\test1.js
 5 | async function getUser() {
 6 |     await sleep(10);
 7 | }
 8 |
 9 | async function getPost() {
10 |     throw new Error("ERROR");
               ^
error: ERROR
      at T:\Temp\test1.js:10:11
      at getPost (T:\Temp\test1.js:9:23)
      at T:\Temp\test1.js:15:25
catch!  5 | async function getUser() {
 6 |     await sleep(10);
 7 | }
 8 |
 9 | async function getPost() {
10 |     throw new Error("ERROR");
               ^
error: ERROR
      at T:\Temp\test1.js:10:11
      at getPost (T:\Temp\test1.js:9:23)
      at T:\Temp\test1.js:15:25

finally
end

Bun v1.1.38 (Windows x64)

ブラウザ →catchされる

ブラウザで挙動を見るために、こんなHTMLファイルを書く。

<!DOCTYPE html>
<html>

<head>
    <title>Home</title>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <script type="module">
        function sleep(ms) {
            return new Promise(r => setTimeout(r, ms));
        }

        async function getUser() {
            await sleep(10);
        }

        async function getPost() {
            throw new Error("ERROR");
        }

        try {
            const userPromise = getUser();
            const postPromise = getPost();

            const user = await userPromise;
            const post = await postPromise;

            console.log("Done");
        } catch (e) {
            console.log("catch!", e);
        } finally {
            console.log("finally");
        }
        console.log("end");
    </script>
</head>

<body>
    <main>
        <h1>see console</h1>
    </main>
</body>

</html>

FireFoxChromeも動作する。

firefox 133.0 Chrome 131.0.6778.86

NodeJSが変にみえてしまう。「だってtryで括ってるし、後とはいえawaitで待ってるじゃん!他の環境はまともなのになんで!」と。

多分私のPromiseの理解が足りなさそうだったので調べなおすことにした。

async/awaitを使わずPromiseで書いてみる

厳密には一致していないとは思うけど、こんな感じになっていると思われる。(変数のスコープとか細かい違いは再現できていないが、今回あまり関係ないので。)

一応、async/awaitのときと同じような動きをしていたので、動き的には近いはず。

new Promise((resolve,reject) => {
    const userPromise = getUser();
    const postPromise = getPost();
  
  userPromise.then((user) => {
    return postPromise.then((post) => {
        console.log("Done");
        resolve();
    });
  }).catch((e) => {
      console.log("catch!", e);
      reject(e);
    }).then(() => {
      console.log("end");
    });
});

要は、asyncはnew Promise、awaitの後の処理がthenになっている、みたいに読み替えればよい。

こうやってみると少し雰囲気が変わってくる。postPromise がcatchに関わらない状態がありそう、というのとか。

Promiseの動き

v8のコードを眺めながら追いかけてみる。雰囲気だけ。

今回のコードではgetUserのコード上、10ミリ秒待たせているのでpending状態になり、10ミリ秒後にfullfilled状態となる。それまではuserPromise.thenのコールバックを呼ぶことがないため、それまではpostPromiseのthenが呼ばれることはない。つまり少なくとも10ミリ秒の間 postPromiseはUnhandleな状態になる。

なので、getUserのコードが処理が終わるまでの10ミリ秒の間に、postPromiseの非同期処理を処理しようとしてthrow new Errorが発生してしまうと、UnhandleかつRejectedな状態になるため、ランタイムがこれを検知してUnhandled Rejectionが発生してしまうらしい。NodeJSではtry-catchが無視され。

これをステップバイステップで見てみるとこんな感じだと思う。

  • new Promiseのコールバックを処理する
    • getUserのコールバックをマイクロタスクキューに追加する(タスク1)
    • getPostのコールバックをマイクロタスクキューに追加する(タスク2)
    • userPromiseのthen、catchを処理し、コールバックを設定
    • 呼び出し元に返る
  • マイクロタスクキューからタスク1を取り出し処理する
    • await sleep(10)があるので、この処理が終わるまで待つ必要があるため中断(pending)
  • マイクロタスクキューからタスク2を取り出し処理する
    • throw new Error(...) が発生する
    • →Unhandled Rejectionが発生

そして、getUserのawait sleep(10)コメントアウトすると、以下のような動きになっていると思われる。

  • マイクロタスクキューからタスク1を取り出し処理する
    • 処理が終わるので、fullfiled状態になる
    • userPromise.thenに渡したコールバック処理を行う
      • postPromise.thenのコールバック処理を設定する(postPromiseがUnhandle -> Handleになる)
  • マイクロタスクキューからタスク2を取り出し処理する
    • throw new Error(...) が発生する
    • →Handle状態なので、Unhandled Rejectionにならない

実際、NodeJSでもUnhandled Rejectionにならないので、ちゃんとcatchが処理される。

しかし、実際はこんなシンプルではなく、「getUserが20ミリ秒後にfullfilledになり、getPostが30ミリ秒後にRejectedになる」となれば、ちゃんとtry-catchが動くし、「getUserが20ミリ秒後にfullfilledになり、getPostが10ミリ秒後にRejectedになる」となるとtry-catchをすり抜ける、という事態になる。

この振る舞いの変化に悩んでみたいですか?体験してみましょう。

https://jsbin.com/tuvujudepa/1/edit?js,console,output

何度か実行してみてください。unhandledrejectionハンドラが処理されているときが、NodeJSではプロセスダウンしているようなもの。

NodeJSはError-Likeなオブジェクトでrejectされると、Unhandle Rejectionが起きてもわかりにくくなる

それはそれとして、NodeJSだとcatchが行われていないようにみえるのは困る。

この疑問をツイートしていたら「nodeでは次のイベントループに進むまでにerror handlerがないとunhandled rejectionになる」とtyage先生に教えていただいた。

でもエラー出力を見てもそれらしい文言にならないのはなぜなのか、というのを調べた。

実装上、Errorっぽいオブジェクト(objectかつstackプロパティを持っている)がthrowされていたら、それをそのまま使う、というふるまいをしている。そのせいでエラーの情報は出ているがUnhandled Rejectionに関するメッセージが出ない状態になっているらしい。

github.com

実際に、 throw new Error("ERROR"); ではなく throw "ERROR"とすると、その様子がわかる。

 async function getPost(){
-   //throw new Error("ERROR");
+  throw "ERROR";
 }
PS T:\Temp> node test3.mjs
node:internal/process/promises:392
      new UnhandledPromiseRejection(reason);
      ^

UnhandledPromiseRejection: 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(). The promise rejected with the reason "ERROR".
    at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
    at processPromiseRejections (node:internal/process/promises:475:17)
    at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
  code: 'ERR_UNHANDLED_REJECTION'
}

Node.js v22.12.0

お、ERR_UNHANDLED_REJECTIONとはわかりやすいですね!まぁどこで問題が起きたかわからないので頭を抱えることになると思いますけど。

あとそもそも、人間がエラーを発生させるときはたいていthrow new Error(...) をベースにすると思うので、これに遭遇することは少ないでしょう。つまり、Errorが発生しているがcatchされないという事象に引っかかることが多いはず。

Unhandled Rejectionとかよくわからん。どうでもいいから、NodeJSでcatchの処理を保証してほしい

2つの方法がありそう。以下のいずれか。

個人的な感想。NodeJSの運用はあまり詳しくないので適当です。

  • process.on('unhandledRejection', (reason, promise) => { ... ; }) を実装しておくとよさそう。その場合、--unhandled-rejectionsフラグはstrict以外ほぼ意味がなくなる(警告の出方が微妙に違うだけになる)
  • unhandledRejectionハンドラを書けない場合は warnwarn-with-error-codeにすると、とりあえずcatchはされるようになる
  • exit codeはunhandledRejectionハンドラを登録している場合、その中でprocess.exit(123)とすればコントロールできるが、catchの処理がされないので一長一短感。要らないかな?

このあたりの挙動を表でまとめてみたけど、わかりにくい。

process.on('unhandledRejection') --unhandled-rejections exit code catchの処理 Unhandled rejectionの警告出力 unhandledRejectionイベント処理
なし none 0 × -
なし warn 0 -
なし warn-with-error-code 1 -
なし strict 1 × × -
なし throw (default) 1 × × -
あり none 0
あり warn 0
あり warn-with-error-code 0
あり strict 1 × × ×
あり throw (default) 0
あり(exitあり) none 123 × ×
あり(exitあり) warn 123 × ×
あり(exitあり) warn-with-error-code 123 × ×
あり(exitあり) strict 1 × × ×
あり(exitあり) throw (default) 123 × ×

確認したときのログを表示

PS T:\Temp> node --unhandled-rejections=none test1.mjs
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:46804) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
(Use `node --trace-warnings ...` to show where the warning was created)
PS T:\Temp> echo $LASTEXITCODE
0

PS T:\Temp> node --unhandled-rejections=warn test1.mjs
(node:24844) UnhandledPromiseRejectionWarning: Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:24844) 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)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:24844) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
PS T:\Temp> echo $LASTEXITCODE
0

PS T:\Temp> node --unhandled-rejections=warn-with-error-code test1.mjs
(node:15608) UnhandledPromiseRejectionWarning: Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:15608) 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)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:15608) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
PS T:\Temp> echo $LASTEXITCODE
1

PS T:\Temp> node --unhandled-rejections=strict test1.mjs
file:///T:/Temp/test1.mjs:12
    throw new Error("ERROR");
          ^

Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)

Node.js v22.12.0
PS T:\Temp> echo $LASTEXITCODE
1

PS T:\Temp> node --unhandled-rejections=throw test1.mjs
file:///T:/Temp/test1.mjs:12
    throw new Error("ERROR");
          ^

Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:12:11)
    at file:///T:/Temp/test1.mjs:18:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)

Node.js v22.12.0
PS T:\Temp> echo $LASTEXITCODE
1

unhandledRejectionあり

PS T:\Temp> node --unhandled-rejections=none test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:30736) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
(Use `node --trace-warnings ...` to show where the warning was created)
PS T:\Temp> echo $LASTEXITCODE
0

PS T:\Temp> node --unhandled-rejections=warn test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
(node:37820) UnhandledPromiseRejectionWarning: Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:37820) 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)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:37820) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
PS T:\Temp> echo $LASTEXITCODE
0

PS T:\Temp> node --unhandled-rejections=warn-with-error-code test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:29420) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
(Use `node --trace-warnings ...` to show where the warning was created)
PS T:\Temp> echo $LASTEXITCODE
0

PS T:\Temp> node --unhandled-rejections=strict test1.mjs
file:///T:/Temp/test1.mjs:17
    throw new Error("ERROR");
          ^

Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)

Node.js v22.12.0
PS T:\Temp> echo $LASTEXITCODE
1

PS T:\Temp> node --unhandled-rejections=throw test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
catch! Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:17:11)
    at file:///T:/Temp/test1.mjs:24:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
finally
end
(node:19112) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
(Use `node --trace-warnings ...` to show where the warning was created)
PS T:\Temp> echo $LASTEXITCODE
0

ハンドラあり + ハンドラでprocess.exit(123)

PS T:\Temp> node --unhandled-rejections=none test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:18:11)
    at file:///T:/Temp/test1.mjs:25:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
PS T:\Temp> echo $LASTEXITCODE
123

PS T:\Temp> node --unhandled-rejections=warn test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:18:11)
    at file:///T:/Temp/test1.mjs:25:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
PS T:\Temp> echo $LASTEXITCODE
123

PS T:\Temp> node --unhandled-rejections=warn-with-error-code test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:18:11)
    at file:///T:/Temp/test1.mjs:25:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
PS T:\Temp> echo $LASTEXITCODE
123

PS T:\Temp> node --unhandled-rejections=strict test1.mjs
file:///T:/Temp/test1.mjs:18
    throw new Error("ERROR");
          ^

Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:18:11)
    at file:///T:/Temp/test1.mjs:25:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)

Node.js v22.12.0
PS T:\Temp> echo $LASTEXITCODE
1

PS T:\Temp> node --unhandled-rejections=throw test1.mjs
on unhandledRejection Error: ERROR
    at getPost (file:///T:/Temp/test1.mjs:18:11)
    at file:///T:/Temp/test1.mjs:25:25
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:116:5)
PS T:\Temp> echo $LASTEXITCODE
123

Unhandled RejectionはESLintとかの静的解析で防げないか?

防げない。

@typescript-eslint/no-floating-promisesでは投げっぱなしのPromiseを検知はできるのだが、今回のコードはフロー上ちゃんと変数に入れているので検知はできない模様。試したがひっかからなかった

Issueには上がったようだが、対応しない模様。

github.com

メンテナーさんの意見もごもっとも。