From a49a1b280693e050c2cab985215a9a525ff56e4f Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Sun, 22 Mar 2026 19:37:16 +0800 Subject: [PATCH] lib: add observePromise helper This is helpful to observe promise resolution without considering the promise as handled. This allows the unhandledRejection event to still be produced if no other resolution handling occurs. --- deps/v8/include/v8-promise.h | 5 ++ deps/v8/src/api/api.cc | 4 + doc/api/packages.md | 2 +- lib/internal/promise_observe.js | 23 +++++ src/env-inl.h | 8 ++ src/env.h | 4 + src/node_task_queue.cc | 43 ++++++++++ test/parallel/test-promise-observe.js | 116 ++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 lib/internal/promise_observe.js create mode 100644 test/parallel/test-promise-observe.js diff --git a/deps/v8/include/v8-promise.h b/deps/v8/include/v8-promise.h index 8c127c8122a2ec..ffbd340b529f21 100644 --- a/deps/v8/include/v8-promise.h +++ b/deps/v8/include/v8-promise.h @@ -102,6 +102,11 @@ class V8_EXPORT Promise : public Object { */ void MarkAsHandled(); + /** + * Marks this promise as unhandled, re-enabling unhandled rejection tracking. + */ + void MarkAsUnhandled(); + /** * Marks this promise as silent to prevent pausing the debugger when the * promise is rejected. diff --git a/deps/v8/src/api/api.cc b/deps/v8/src/api/api.cc index 5a879e9ff5d9e8..a21b99c6f84cee 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -8606,6 +8606,10 @@ void Promise::MarkAsHandled() { Utils::OpenDirectHandle(this)->set_has_handler(true); } +void Promise::MarkAsUnhandled() { + Utils::OpenDirectHandle(this)->set_has_handler(false); +} + void Promise::MarkAsSilent() { Utils::OpenDirectHandle(this)->set_is_silent(true); } diff --git a/doc/api/packages.md b/doc/api/packages.md index d00eaeaf9df675..429b1b19b90801 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -1012,7 +1012,7 @@ added: v0.4.0 The `"main"` field defines the entry point of a package when imported by name via a `node_modules` lookup. Its value is a path. -The [`"exports"`][] field, if it exists, takes precedence over the +The [`"exports"`][] field, if it exists, takes precedence over the `"main"` field when importing the package by name. It also defines the script that is used when the [package directory is loaded diff --git a/lib/internal/promise_observe.js b/lib/internal/promise_observe.js new file mode 100644 index 00000000000000..70ebc766050f43 --- /dev/null +++ b/lib/internal/promise_observe.js @@ -0,0 +1,23 @@ +'use strict'; + +const { observePromise: _observe } = internalBinding('task_queue'); + +/** + * Observes a promise's fulfillment or rejection without suppressing + * the `unhandledRejection` event. APM and diagnostics tools can use + * this to be notified of promise outcomes while still allowing + * `unhandledRejection` to fire if no real handler exists. + * + * @param {Promise} promise - The promise to observe. + * @param {Function} [onFulfilled] - Called when the promise fulfills. + * @param {Function} [onRejected] - Called when the promise rejects. + */ +function observePromise(promise, onFulfilled, onRejected) { + _observe( + promise, + onFulfilled ?? (() => {}), + onRejected ?? (() => {}), + ); +} + +module.exports = { observePromise }; diff --git a/src/env-inl.h b/src/env-inl.h index 761a7bfc995528..df77bd44bb4009 100644 --- a/src/env-inl.h +++ b/src/env-inl.h @@ -701,6 +701,14 @@ bool Environment::source_maps_enabled() const { return source_maps_enabled_; } +void Environment::set_observing_promise(bool on) { + observing_promise_ = on; +} + +bool Environment::observing_promise() const { + return observing_promise_; +} + inline uint64_t Environment::thread_id() const { return thread_id_; } diff --git a/src/env.h b/src/env.h index 5fabc366c6e68b..ec1e3c2afd14f9 100644 --- a/src/env.h +++ b/src/env.h @@ -831,6 +831,9 @@ class Environment final : public MemoryRetainer { inline void set_source_maps_enabled(bool on); inline bool source_maps_enabled() const; + inline void set_observing_promise(bool on); + inline bool observing_promise() const; + inline void ThrowError(const char* errmsg); inline void ThrowTypeError(const char* errmsg); inline void ThrowRangeError(const char* errmsg); @@ -1114,6 +1117,7 @@ class Environment final : public MemoryRetainer { bool emit_env_nonstring_warning_ = true; bool emit_err_name_warning_ = true; bool source_maps_enabled_ = false; + bool observing_promise_ = false; size_t async_callback_scope_depth_ = 0; std::vector destroy_async_id_list_; diff --git a/src/node_task_queue.cc b/src/node_task_queue.cc index f1c53c44f201b2..8900056f5a36e1 100644 --- a/src/node_task_queue.cc +++ b/src/node_task_queue.cc @@ -71,6 +71,10 @@ void PromiseRejectCallback(PromiseRejectMessage message) { "unhandled", unhandledRejections, "handledAfter", rejectionsHandledAfter); } else if (event == kPromiseHandlerAddedAfterReject) { + // If this notification was triggered by ObservePromise's internal .then() + // call, suppress it so the promise remains in pendingUnhandledRejections + // and unhandledRejection still fires. + if (env->observing_promise()) return; value = Undefined(isolate); rejectionsHandledAfter++; TRACE_COUNTER2(TRACING_CATEGORY_NODE2(promises, rejections), @@ -156,6 +160,43 @@ static void SetPromiseRejectCallback( env->set_promise_reject_callback(args[0].As()); } +static void ObservePromise(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK(args[0]->IsPromise()); + CHECK(args[1]->IsFunction()); + CHECK(args[2]->IsFunction()); + + Local promise = args[0].As(); + Local on_fulfilled = args[1].As(); + Local on_rejected = args[2].As(); + + bool was_handled = promise->HasHandler(); + + // Set flag BEFORE .Then() so that if V8 fires kPromiseHandlerAddedAfterReject + // synchronously (because the promise is already rejected), PromiseRejectCallback + // suppresses it and the promise stays in pendingUnhandledRejections. + env->set_observing_promise(true); + + Local derived; + if (!promise->Then(env->context(), on_fulfilled, on_rejected) + .ToLocal(&derived)) { + env->set_observing_promise(false); + return; + } + + env->set_observing_promise(false); + + // The derived promise from .then() should never itself trigger unhandled + // rejection warnings — it's an internal observer chain. + derived->MarkAsHandled(); + + // Restore the original unhandled state so unhandledRejection still fires. + // Only clear if it wasn't already handled by a real handler before we observed. + if (!was_handled) { + promise->MarkAsUnhandled(); + } +} + static void Initialize(Local target, Local unused, Local context, @@ -181,6 +222,7 @@ static void Initialize(Local target, events).Check(); SetMethod( context, target, "setPromiseRejectCallback", SetPromiseRejectCallback); + SetMethod(context, target, "observePromise", ObservePromise); } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { @@ -188,6 +230,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SetTickCallback); registry->Register(RunMicrotasks); registry->Register(SetPromiseRejectCallback); + registry->Register(ObservePromise); } } // namespace task_queue diff --git a/test/parallel/test-promise-observe.js b/test/parallel/test-promise-observe.js new file mode 100644 index 00000000000000..b30983d1f1549e --- /dev/null +++ b/test/parallel/test-promise-observe.js @@ -0,0 +1,116 @@ +'use strict'; +// This test ensures that observePromise() lets APM/diagnostic tools observe +// promise outcomes without suppressing the `unhandledRejection` event. +// Flags: --expose-internals +const common = require('../common'); +const assert = require('node:assert'); +const { observePromise } = require('internal/promise_observe'); + +// Execution order within a tick: nextTick queue → microtasks → processPromiseRejections +// (which fires unhandledRejection). setImmediate runs in the next event loop +// iteration after all of the above, so we use it for final assertions. +// +// All tests share the same process, so we track unhandledRejection by promise +// identity rather than using process.once, which would fire for all rejections. + +// Track all unhandled rejections by promise identity. +const unhandledByPromise = new Map(); +process.on('unhandledRejection', (reason, promise) => { + unhandledByPromise.set(promise, reason); +}); + +// --- Test 1: Observe a rejected promise — unhandledRejection still fires --- +{ + const err1 = new Error('test1'); + const p1 = Promise.reject(err1); + + observePromise(p1, null, common.mustCall((err) => { + assert.strictEqual(err, err1); + })); + + setImmediate(common.mustCall(() => { + assert.ok(unhandledByPromise.has(p1), 'Test 1: unhandledRejection should have fired'); + assert.strictEqual(unhandledByPromise.get(p1), err1); + })); +} + +// --- Test 2: Observe then add real handler — no unhandledRejection --- +{ + const err2 = new Error('test2'); + const p2 = Promise.reject(err2); + + observePromise(p2, null, common.mustCall(() => {})); + + // Real handler added synchronously after observing. + p2.catch(() => {}); + + setImmediate(common.mustCall(() => { + assert.ok(!unhandledByPromise.has(p2), 'Test 2: unhandledRejection should NOT have fired'); + })); +} + +// --- Test 3: Observe pending promise that later rejects --- +{ + const err3 = new Error('test3'); + const { promise: p3, reject: reject3 } = Promise.withResolvers(); + + observePromise(p3, null, common.mustCall((err) => { + assert.strictEqual(err, err3); + })); + + reject3(err3); + + // Two rounds of setImmediate to ensure both the observer callback and + // unhandledRejection have had a chance to run. + setImmediate(common.mustCall(() => { + setImmediate(common.mustCall(() => { + assert.ok(unhandledByPromise.has(p3), 'Test 3: unhandledRejection should have fired'); + })); + })); +} + +// --- Test 4: Observe pending promise that fulfills — no warnings --- +{ + const { promise: p4, resolve: resolve4 } = Promise.withResolvers(); + + observePromise(p4, common.mustCall((val) => { + assert.strictEqual(val, 42); + }), null); + + resolve4(42); + + setImmediate(common.mustCall(() => { + setImmediate(common.mustCall(() => { + assert.ok(!unhandledByPromise.has(p4), 'Test 4: unhandledRejection should NOT have fired'); + })); + })); +} + +// --- Test 5: Multiple observers — all called, unhandledRejection still fires --- +{ + const err5 = new Error('test5'); + const p5 = Promise.reject(err5); + + observePromise(p5, null, common.mustCall(() => {})); + observePromise(p5, null, common.mustCall(() => {})); + + setImmediate(common.mustCall(() => { + assert.ok(unhandledByPromise.has(p5), 'Test 5: unhandledRejection should have fired'); + assert.strictEqual(unhandledByPromise.get(p5), err5); + })); +} + +// --- Test 6: Observe already-handled promise — no unhandledRejection --- +{ + const err6 = new Error('test6'); + const p6 = Promise.reject(err6); + + // Real handler added first. + p6.catch(() => {}); + + observePromise(p6, null, common.mustCall(() => {})); + + setImmediate(common.mustCall(() => { + assert.ok(!unhandledByPromise.has(p6), 'Test 6: unhandledRejection should NOT have fired'); + })); +}