-
-
Notifications
You must be signed in to change notification settings - Fork 35.1k
lib: add observePromise helper #62391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be helpful here to briefly explain the timing expectation. e.g. for "Called when the promise fulfills/rejects", does that follow the same timing as microtask queue? Does it happen synchronously when the promise hooks fire, etc. If the latter, I'd be very concerned about landing this. It's also very not clear: would the onFulfilled/onRejected callbacks receive the settled value/error? |
||
| * | ||
| * @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 }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<Function>()); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| static void ObservePromise(const FunctionCallbackInfo<Value>& args) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Environment* env = Environment::GetCurrent(args); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| CHECK(args[0]->IsPromise()); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| CHECK(args[1]->IsFunction()); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| CHECK(args[2]->IsFunction()); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Local<Promise> promise = args[0].As<Promise>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Local<Function> on_fulfilled = args[1].As<Function>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Local<Function> on_rejected = args[2].As<Function>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<Promise> derived; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!promise->Then(env->context(), on_fulfilled, on_rejected) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .ToLocal(&derived)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| env->set_observing_promise(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+175
to
+185
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not substantially different, but imo this is a bit easier to read if it's clear that the observing_promise toggle is a scope:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<Object> target, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Local<Value> unused, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Local<Context> context, | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -181,13 +222,15 @@ static void Initialize(Local<Object> target, | |||||||||||||||||||||||||||||||||||||||||||||||||
| events).Check(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| SetMethod( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| context, target, "setPromiseRejectCallback", SetPromiseRejectCallback); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| SetMethod(context, target, "observePromise", ObservePromise); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| void RegisterExternalReferences(ExternalReferenceRegistry* registry) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| registry->Register(EnqueueMicrotask); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| registry->Register(SetTickCallback); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| registry->Register(RunMicrotasks); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| registry->Register(SetPromiseRejectCallback); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| registry->Register(ObservePromise); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| } // namespace task_queue | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(() => {})); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe assert on |
||
|
|
||
| // 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'); | ||
| })); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm personally okay with a patch like this, but I imagine it would make things a bit easier if it was in a separate commit