From 4535857cbfc903b3925c1495c3034369b5ab22d5 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Mon, 29 Jun 2026 15:33:19 -0400 Subject: [PATCH] errors: trace unhandled promise rejection location Print where an unhandled promise is been rejected with flag `--trace-uncaught`. This also locates the rejection site when a promise is rejected with a primitive values. Signed-off-by: Chengzhong Wu --- lib/internal/process/promises.js | 19 +++++++----- src/node_errors.cc | 30 ++++++++++++++----- src/node_errors.h | 5 ++++ src/node_task_queue.cc | 13 +++++++- .../fixtures/uncaught/exception_async_tick.js | 3 ++ .../uncaught/exception_async_tick.snapshot | 11 +++++++ test/fixtures/uncaught/exception_null.js | 1 + .../fixtures/uncaught/exception_null.snapshot | 11 +++++++ .../uncaught/exception_stack_malformed.js | 8 +++++ .../exception_stack_malformed.snapshot | 11 +++++++ test/fixtures/uncaught/exception_symbol.js | 1 + .../uncaught/exception_symbol.snapshot | 11 +++++++ test/fixtures/uncaught/exception_undefined.js | 1 + .../uncaught/exception_undefined.snapshot | 11 +++++++ .../uncaught/rejection_async_fn_throw.js | 6 ++++ .../rejection_async_fn_throw.snapshot | 14 +++++++++ .../uncaught/rejection_async_fn_throw_err.js | 8 +++++ .../rejection_async_fn_throw_err.snapshot | 12 ++++++++ .../uncaught/rejection_promise_reject.js | 3 ++ .../rejection_promise_reject.snapshot | 15 ++++++++++ .../test-node-output-trace-uncaught.mjs | 24 +++++++++++++++ ...t-throw-error-with-getter-throw-traced.mjs | 27 ----------------- .../test-throw-undefined-or-null-traced.mjs | 21 ------------- 23 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 test/fixtures/uncaught/exception_async_tick.js create mode 100644 test/fixtures/uncaught/exception_async_tick.snapshot create mode 100644 test/fixtures/uncaught/exception_null.js create mode 100644 test/fixtures/uncaught/exception_null.snapshot create mode 100644 test/fixtures/uncaught/exception_stack_malformed.js create mode 100644 test/fixtures/uncaught/exception_stack_malformed.snapshot create mode 100644 test/fixtures/uncaught/exception_symbol.js create mode 100644 test/fixtures/uncaught/exception_symbol.snapshot create mode 100644 test/fixtures/uncaught/exception_undefined.js create mode 100644 test/fixtures/uncaught/exception_undefined.snapshot create mode 100644 test/fixtures/uncaught/rejection_async_fn_throw.js create mode 100644 test/fixtures/uncaught/rejection_async_fn_throw.snapshot create mode 100644 test/fixtures/uncaught/rejection_async_fn_throw_err.js create mode 100644 test/fixtures/uncaught/rejection_async_fn_throw_err.snapshot create mode 100644 test/fixtures/uncaught/rejection_promise_reject.js create mode 100644 test/fixtures/uncaught/rejection_promise_reject.snapshot create mode 100644 test/parallel/test-node-output-trace-uncaught.mjs delete mode 100644 test/parallel/test-throw-error-with-getter-throw-traced.mjs delete mode 100644 test/parallel/test-throw-undefined-or-null-traced.mjs diff --git a/lib/internal/process/promises.js b/lib/internal/process/promises.js index 8bb2062b3a9f0f..079ab1656a32a5 100644 --- a/lib/internal/process/promises.js +++ b/lib/internal/process/promises.js @@ -99,7 +99,7 @@ class PromiseRejectionHandledWarning extends Error { /** * @typedef PromiseInfo - * @property {*} reason the reason for the rejection + * @property {unknown} reason the reason for the rejection * @property {number} uid the unique id of the promise * @property {boolean} warned whether the rejection has been warned * @property {object} [domain] the domain the promise was created in @@ -153,9 +153,10 @@ function isErrorLike(obj) { /** * @param {0|1|2|3} type * @param {Promise} promise - * @param {Error} reason + * @param {unknown} reason + * @param {Error} rejectionSite */ -function promiseRejectHandler(type, promise, reason) { +function promiseRejectHandler(type, promise, reason, rejectionSite) { if (unhandledRejectionsMode === undefined) { unhandledRejectionsMode = getUnhandledRejectionsMode(); } @@ -163,7 +164,7 @@ function promiseRejectHandler(type, promise, reason) { // filtered out in C++ (src/node_task_queue.cc) and never reach JS. switch (type) { case kPromiseRejectWithNoHandler: // 0 - unhandledRejection(promise, reason); + unhandledRejection(promise, reason, rejectionSite); break; case kPromiseHandlerAddedAfterReject: // 1 handledRejection(promise); @@ -184,15 +185,17 @@ const emitUnhandledRejection = (promise, promiseInfo) => { /** * @param {Promise} promise - * @param {Error} reason + * @param {unknown} reason + * @param {Error} rejectionSite */ -function unhandledRejection(promise, reason) { +function unhandledRejection(promise, reason, rejectionSite) { pendingUnhandledRejections.set(promise, { reason, uid: ++lastPromiseId, warned: false, domain: process.domain, contextFrame: AsyncContextFrame.current(), + rejectionSite, }); setHasRejectionToWarn(true); } @@ -272,7 +275,7 @@ function strictUnhandledRejectionsMode(promise, promiseInfo, promiseAsyncId) { const err = isErrorLike(reason) ? reason : new UnhandledPromiseRejection(reason); // This destroys the async stack, don't clear it after - triggerUncaughtException(err, true /* fromPromise */); + triggerUncaughtException(err, true /* fromPromise */, promiseInfo.rejectionSite); if (promiseAsyncId !== undefined) { pushAsyncContext( promise[kAsyncIdSymbol], @@ -321,7 +324,7 @@ function throwUnhandledRejectionsMode(promise, promiseInfo) { reason : new UnhandledPromiseRejection(reason); // This destroys the async stack, don't clear it after - triggerUncaughtException(err, true /* fromPromise */); + triggerUncaughtException(err, true /* fromPromise */, promiseInfo.rejectionSite); return false; } return true; diff --git a/src/node_errors.cc b/src/node_errors.cc index 63db97f6a56db0..f796addacacb2c 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -422,7 +422,8 @@ enum class EnhanceFatalException { kEnhance, kDontEnhance }; static void ReportFatalException(Environment* env, Local error, Local message, - EnhanceFatalException enhance_stack) { + EnhanceFatalException enhance_stack, + bool from_promise) { if (!env->can_call_into_js()) enhance_stack = EnhanceFatalException::kDontEnhance; @@ -553,7 +554,11 @@ static void ReportFatalException(Environment* env, if (env->options()->trace_uncaught) { Local trace = message->GetStackTrace(); if (!trace.IsEmpty()) { - FPrintF(stderr, "Thrown at:\n"); + if (from_promise) { + FPrintF(stderr, "Rejected at:\n"); + } else { + FPrintF(stderr, "Thrown at:\n"); + } PrintStackTrace(env->isolate(), trace); } } @@ -715,7 +720,7 @@ TryCatchScope::~TryCatchScope() { EnhanceFatalException::kEnhance : EnhanceFatalException::kDontEnhance; if (message.IsEmpty()) message = Exception::CreateMessage(env_->isolate(), exception); - ReportFatalException(env_, exception, message, enhance); + ReportFatalException(env_, exception, message, enhance, false); env_->Exit(ExitCode::kExceptionInFatalExceptionHandler); } } @@ -1165,13 +1170,21 @@ static void TriggerUncaughtException(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); Environment* env = Environment::GetCurrent(isolate); Local exception = args[0]; - Local message = Exception::CreateMessage(isolate, exception); + bool from_promise = args[1]->IsTrue(); + Local throw_site = args[2]; + + Local message; + if (!throw_site->IsUndefined()) { + message = Exception::CreateMessage(isolate, throw_site); + } else { + message = Exception::CreateMessage(isolate, exception); + } + if (env != nullptr && env->abort_on_uncaught_exception()) { ReportFatalException( - env, exception, message, EnhanceFatalException::kEnhance); + env, exception, message, EnhanceFatalException::kEnhance, from_promise); ABORT(); } - bool from_promise = args[1]->IsTrue(); errors::TriggerUncaughtException(isolate, exception, message, from_promise); } @@ -1306,7 +1319,7 @@ void TriggerUncaughtException(Isolate* isolate, // the current Node.js instance. if (!fatal_exception_function->IsFunction()) { ReportFatalException( - env, error, message, EnhanceFatalException::kDontEnhance); + env, error, message, EnhanceFatalException::kDontEnhance, from_promise); env->Exit(ExitCode::kInvalidFatalExceptionMonkeyPatching); return; } @@ -1367,7 +1380,8 @@ void TriggerUncaughtException(Isolate* isolate, } // Now we are certain that the exception is fatal. - ReportFatalException(env, error, message, EnhanceFatalException::kEnhance); + ReportFatalException( + env, error, message, EnhanceFatalException::kEnhance, from_promise); RunAtExit(env); // If the global uncaught exception handler sets process.exitCode, diff --git a/src/node_errors.h b/src/node_errors.h index ed8e7eed9e3d9b..61f0fb2ce95689 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -130,6 +130,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details); V(ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED, Error) \ V(ERR_VM_MODULE_CACHED_DATA_REJECTED, Error) \ V(ERR_VM_MODULE_LINK_FAILURE, Error) \ + V(ERR_UNHANDLED_REJECTION, Error) \ V(ERR_WASI_NOT_STARTED, Error) \ V(ERR_ZLIB_INITIALIZATION_FAILED, Error) \ V(ERR_WORKER_INIT_FAILED, Error) \ @@ -244,6 +245,10 @@ ERRORS_WITH_CODE(V) V(ERR_SCRIPT_EXECUTION_INTERRUPTED, \ "Script execution was interrupted by `SIGINT`") \ V(ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED, "Failed to set PSK identity hint") \ + V(ERR_UNHANDLED_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().") \ V(ERR_WASI_NOT_STARTED, "wasi.start() has not been called") \ V(ERR_WORKER_INIT_FAILED, "Worker initialization failure") \ V(ERR_PROTO_ACCESS, \ diff --git a/src/node_task_queue.cc b/src/node_task_queue.cc index 396ed5c4758c16..180813f5f0a633 100644 --- a/src/node_task_queue.cc +++ b/src/node_task_queue.cc @@ -89,7 +89,18 @@ void PromiseRejectCallback(PromiseRejectMessage message) { value = Undefined(isolate); } - Local args[] = { type, promise, value }; + Local rejection_site; + if (event == kPromiseRejectWithNoHandler) { + // Native error carries the throw site. Create a dummy error to capture the + // stack trace where the rejection actually happened, when no native error + // is available. + rejection_site = + value->IsNativeError() ? value : ERR_UNHANDLED_REJECTION(isolate); + } else { + rejection_site = Undefined(isolate); + } + + Local args[] = {type, promise, value, rejection_site}; double async_id = AsyncWrap::kInvalidAsyncId; double trigger_async_id = AsyncWrap::kInvalidAsyncId; diff --git a/test/fixtures/uncaught/exception_async_tick.js b/test/fixtures/uncaught/exception_async_tick.js new file mode 100644 index 00000000000000..d37e9a4cfcf49d --- /dev/null +++ b/test/fixtures/uncaught/exception_async_tick.js @@ -0,0 +1,3 @@ +process.nextTick(() => { + throw null; +}); diff --git a/test/fixtures/uncaught/exception_async_tick.snapshot b/test/fixtures/uncaught/exception_async_tick.snapshot new file mode 100644 index 00000000000000..c6413857d457d1 --- /dev/null +++ b/test/fixtures/uncaught/exception_async_tick.snapshot @@ -0,0 +1,11 @@ + +/test/fixtures/uncaught/exception_async_tick.js:2 + throw null; + ^ +null +Thrown at: + at /test/fixtures/uncaught/exception_async_tick.js:2:3 + at + + +Node.js diff --git a/test/fixtures/uncaught/exception_null.js b/test/fixtures/uncaught/exception_null.js new file mode 100644 index 00000000000000..37d3d14b8bfe1b --- /dev/null +++ b/test/fixtures/uncaught/exception_null.js @@ -0,0 +1 @@ +throw null; diff --git a/test/fixtures/uncaught/exception_null.snapshot b/test/fixtures/uncaught/exception_null.snapshot new file mode 100644 index 00000000000000..3a1a4436219b98 --- /dev/null +++ b/test/fixtures/uncaught/exception_null.snapshot @@ -0,0 +1,11 @@ + +/test/fixtures/uncaught/exception_null.js:1 +throw null; +^ +null +Thrown at: + at /test/fixtures/uncaught/exception_null.js:1:1 + at + + +Node.js diff --git a/test/fixtures/uncaught/exception_stack_malformed.js b/test/fixtures/uncaught/exception_stack_malformed.js new file mode 100644 index 00000000000000..08c7821534aa76 --- /dev/null +++ b/test/fixtures/uncaught/exception_stack_malformed.js @@ -0,0 +1,8 @@ +throw { + get stack() { + throw new Error('weird throw but ok'); + }, + get name() { + throw new Error('weird throw but ok'); + }, +}; diff --git a/test/fixtures/uncaught/exception_stack_malformed.snapshot b/test/fixtures/uncaught/exception_stack_malformed.snapshot new file mode 100644 index 00000000000000..eba7effc90e6a0 --- /dev/null +++ b/test/fixtures/uncaught/exception_stack_malformed.snapshot @@ -0,0 +1,11 @@ + +/test/fixtures/uncaught/exception_stack_malformed.js:1 +throw { +^ +[object Object] +Thrown at: + at /test/fixtures/uncaught/exception_stack_malformed.js:1:1 + at + + +Node.js diff --git a/test/fixtures/uncaught/exception_symbol.js b/test/fixtures/uncaught/exception_symbol.js new file mode 100644 index 00000000000000..19b0a36c7fa870 --- /dev/null +++ b/test/fixtures/uncaught/exception_symbol.js @@ -0,0 +1 @@ +throw Symbol('foo'); diff --git a/test/fixtures/uncaught/exception_symbol.snapshot b/test/fixtures/uncaught/exception_symbol.snapshot new file mode 100644 index 00000000000000..c0b31ce68b4ed3 --- /dev/null +++ b/test/fixtures/uncaught/exception_symbol.snapshot @@ -0,0 +1,11 @@ + +/test/fixtures/uncaught/exception_symbol.js:1 +throw Symbol('foo'); +^ + +Thrown at: + at /test/fixtures/uncaught/exception_symbol.js:1:1 + at + + +Node.js diff --git a/test/fixtures/uncaught/exception_undefined.js b/test/fixtures/uncaught/exception_undefined.js new file mode 100644 index 00000000000000..38ecbdff9801f6 --- /dev/null +++ b/test/fixtures/uncaught/exception_undefined.js @@ -0,0 +1 @@ +throw undefined; diff --git a/test/fixtures/uncaught/exception_undefined.snapshot b/test/fixtures/uncaught/exception_undefined.snapshot new file mode 100644 index 00000000000000..3c80aa64dd6b6f --- /dev/null +++ b/test/fixtures/uncaught/exception_undefined.snapshot @@ -0,0 +1,11 @@ + +/test/fixtures/uncaught/exception_undefined.js:1 +throw undefined; +^ +undefined +Thrown at: + at /test/fixtures/uncaught/exception_undefined.js:1:1 + at + + +Node.js diff --git a/test/fixtures/uncaught/rejection_async_fn_throw.js b/test/fixtures/uncaught/rejection_async_fn_throw.js new file mode 100644 index 00000000000000..f4e81bb09f5887 --- /dev/null +++ b/test/fixtures/uncaught/rejection_async_fn_throw.js @@ -0,0 +1,6 @@ +setImmediate(() => { + (async () => { + await 1; + throw null; + })(); +}); diff --git a/test/fixtures/uncaught/rejection_async_fn_throw.snapshot b/test/fixtures/uncaught/rejection_async_fn_throw.snapshot new file mode 100644 index 00000000000000..7967893b6230a8 --- /dev/null +++ b/test/fixtures/uncaught/rejection_async_fn_throw.snapshot @@ -0,0 +1,14 @@ +/test/fixtures/uncaught/rejection_async_fn_throw.js:4 + throw null; + ^ + +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 "null". + at + at { + code: 'ERR_UNHANDLED_REJECTION' +} +Rejected at: + at /test/fixtures/uncaught/rejection_async_fn_throw.js:4:5 + + +Node.js diff --git a/test/fixtures/uncaught/rejection_async_fn_throw_err.js b/test/fixtures/uncaught/rejection_async_fn_throw_err.js new file mode 100644 index 00000000000000..0ae0d90248f35d --- /dev/null +++ b/test/fixtures/uncaught/rejection_async_fn_throw_err.js @@ -0,0 +1,8 @@ +const err = new Error('err with wrong stack'); + +setImmediate(() => { + (async () => { + await 1; + throw err; + })(); +}); diff --git a/test/fixtures/uncaught/rejection_async_fn_throw_err.snapshot b/test/fixtures/uncaught/rejection_async_fn_throw_err.snapshot new file mode 100644 index 00000000000000..d98924d2c2f02d --- /dev/null +++ b/test/fixtures/uncaught/rejection_async_fn_throw_err.snapshot @@ -0,0 +1,12 @@ +/test/fixtures/uncaught/rejection_async_fn_throw_err.js:6 + throw err; + ^ + +Error: err with wrong stack + at Object. (/test/fixtures/uncaught/rejection_async_fn_throw_err.js:1:13) + at +Rejected at: + at /test/fixtures/uncaught/rejection_async_fn_throw_err.js:6:5 + + +Node.js diff --git a/test/fixtures/uncaught/rejection_promise_reject.js b/test/fixtures/uncaught/rejection_promise_reject.js new file mode 100644 index 00000000000000..b31c81c314c504 --- /dev/null +++ b/test/fixtures/uncaught/rejection_promise_reject.js @@ -0,0 +1,3 @@ +setImmediate(() => { + Promise.reject(null); +}); diff --git a/test/fixtures/uncaught/rejection_promise_reject.snapshot b/test/fixtures/uncaught/rejection_promise_reject.snapshot new file mode 100644 index 00000000000000..66e3ba31d7caf6 --- /dev/null +++ b/test/fixtures/uncaught/rejection_promise_reject.snapshot @@ -0,0 +1,15 @@ +/test/fixtures/uncaught/rejection_promise_reject.js:2 + Promise.reject(null); + ^ + +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 "null". + at + at { + code: 'ERR_UNHANDLED_REJECTION' +} +Rejected at: + at /test/fixtures/uncaught/rejection_promise_reject.js:2:11 + at + + +Node.js diff --git a/test/parallel/test-node-output-trace-uncaught.mjs b/test/parallel/test-node-output-trace-uncaught.mjs new file mode 100644 index 00000000000000..226a20dcc44033 --- /dev/null +++ b/test/parallel/test-node-output-trace-uncaught.mjs @@ -0,0 +1,24 @@ +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import * as snapshot from '../common/assertSnapshot.js'; +import { describe, it } from 'node:test'; + +describe('--trace-uncaught', { concurrency: !process.env.TEST_PARALLEL }, () => { + const tests = [ + { name: 'uncaught/exception_async_tick.js' }, + { name: 'uncaught/exception_null.js' }, + { name: 'uncaught/exception_stack_malformed.js' }, + { name: 'uncaught/exception_symbol.js' }, + { name: 'uncaught/exception_undefined.js' }, + { name: 'uncaught/rejection_async_fn_throw_err.js' }, + { name: 'uncaught/rejection_async_fn_throw.js' }, + { name: 'uncaught/rejection_promise_reject.js' }, + ]; + for (const { name } of tests) { + it(name, async () => { + await snapshot.spawnAndAssert(fixtures.path(name), snapshot.defaultTransform, { + flags: ['--trace-uncaught'], + }); + }); + } +}); diff --git a/test/parallel/test-throw-error-with-getter-throw-traced.mjs b/test/parallel/test-throw-error-with-getter-throw-traced.mjs deleted file mode 100644 index 50dfed3292df8f..00000000000000 --- a/test/parallel/test-throw-error-with-getter-throw-traced.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { spawnPromisified } from '../common/index.mjs'; -import assert from 'node:assert'; -import { execPath } from 'node:process'; -import { describe, it } from 'node:test'; - - -describe('--trace-uncaught', () => { - it('prints a trace on process exit for uncaught errors', async () => { - const { code, signal, stderr } = await spawnPromisified(execPath, [ - '--trace-uncaught', - '--eval', - `throw { - get stack() { - throw new Error('weird throw but ok'); - }, - get name() { - throw new Error('weird throw but ok'); - }, - };`, - ]); - - assert.match(stderr, /^Thrown at:$/m); - assert.match(stderr, /^ {4}at \[eval\]:1:1$/m); - assert.strictEqual(code, 1); - assert.strictEqual(signal, null); - }); -}); diff --git a/test/parallel/test-throw-undefined-or-null-traced.mjs b/test/parallel/test-throw-undefined-or-null-traced.mjs deleted file mode 100644 index e27339365b0edf..00000000000000 --- a/test/parallel/test-throw-undefined-or-null-traced.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import { spawnPromisified } from '../common/index.mjs'; -import assert from 'node:assert'; -import { execPath } from 'node:process'; -import { describe, it } from 'node:test'; - - -describe('--trace-uncaught', () => { - it('prints a trace on process exit for uncaught errors', async () => { - for (const value of [null, undefined]) { - const { code, signal, stderr } = await spawnPromisified(execPath, [ - '--trace-uncaught', - '--eval', - `throw ${value};`, - ]); - assert.match(stderr, /^Thrown at:$/m); - assert.match(stderr, /^ {4}at \[eval\]:1:1$/m); - assert.strictEqual(code, 1); - assert.strictEqual(signal, null); - } - }); -});