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'); + })); +}