Skip to content

lib: add observePromise helper#62391

Closed
Qard wants to merge 1 commit intonodejs:mainfrom
Qard:observe-promise-without-handled-state
Closed

lib: add observePromise helper#62391
Qard wants to merge 1 commit intonodejs:mainfrom
Qard:observe-promise-without-handled-state

Conversation

@Qard
Copy link
Member

@Qard Qard commented Mar 22, 2026

I'm looking for feedback on this experiment. 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. This includes a very small V8 change. Assuming people find the intent of this change reasonable, I would try to get that upstreamed to V8. I just don't want to put in all the effort to make that happen if the thing itself is not likely to be accepted.

The particular use case I have in mind for this is the tracePromise method in TracingChannel. Currently it wraps thenables in a Promise.resolve(...) and then calls the then(...) on it. This is problematic though as the thenable could have other methods which disappear as the return value becomes a native promise instead. Rather than using chaining there which produces a new and crucially different promise, I would like to return the original promise. The issue is that if we just branch internally for our observation and then return the original that original is considered handled and so would no longer trigger unhandledRejection if no other continuation was attached. This change would provide an interface such that we can observe that result but opt-out of the handled transition.

I imagine there might be other use cases for this in other parts of the codebase. This is intended purely as an internal utility, I don't expect we'd actually expose it in the public API anywhere.

What do folks think? Is this a reasonable solution we should be applying?

cc @nodejs/diagnostics @nodejs/v8

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.
@Qard Qard self-assigned this Mar 22, 2026
@Qard Qard added discuss Issues opened for discussions and feedbacks. v8 engine Issues and PRs related to the V8 dependency. lib / src Issues and PRs related to general changes in the lib or src directory. promises Issues and PRs related to ECMAScript promises. diagnostics_channel Issues and PRs related to diagnostics channel labels Mar 22, 2026
@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/security-wg
  • @nodejs/v8-update

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Mar 22, 2026
@mcollina
Copy link
Member

What's the runtime overhead?

@Qard
Copy link
Member Author

Qard commented Mar 22, 2026

No idea. Haven't measured it yet. Just wanted to validate the approach seemed reasonable before pursuing it too far. I could always write an intrinsic for setting the handled marker tough, as I did for AsyncLocalStorage with the ContinuationPreservedEmbedderData interface, if the perf impact turns out to be enough to matter. Just a bit more work.

const err2 = new Error('test2');
const p2 = Promise.reject(err2);

observePromise(p2, null, common.mustCall(() => {}));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe assert on err2 like in test 1 and 3

@Flarna
Copy link
Member

Flarna commented Mar 22, 2026

I like the idea.
But I think it is actually useful for userland - like you wrote "APM and diagnostics tools can use this...".
If it is not expose they can't use it. Only via tracePromise once it is added there but I think this is an unneeded restriction.

@Qard
Copy link
Member Author

Qard commented Mar 22, 2026

I'm unclear what use case there would be to make it public though. If you have any ideas, I'd be happy to entertain the idea. My plan was to just try it out internally, see if it works okay, and we can always decide to make it public later if we're happy with it. A lot harder to go the other direction. 😅

@Flarna
Copy link
Member

Flarna commented Mar 23, 2026

The usecases are mostly the same as tracePromise in diagnostic channel.
tracePromise does not fit all possible cases so users/apm tools/... might want to implement a their own variant fitting to their concrete needed.

If this API is internal only it would imply that such very specific cases would require DC enhancements.

As you wrote in the comment "APM and diagnostics tools can use this" - but node.js itself is no APM nor a diagnostics tool - but there are quite a lot such tools using node.js as runtime.

But I agree, there is no need to make it public on first iteration.

@mcollina
Copy link
Member

I like the idea, with the overhead being the biggest blocker.

@Qard
Copy link
Member Author

Qard commented Mar 23, 2026

Would people be averse to landing this basically as-is and separately working on a PR to make use of it in tracePromise where we can get some more real-world benchmark results? For either approach (exposing the setter for the handled marker in C++ or exposing it via a JS intrinsic) it will require V8 changes. The current approach is the simplest and possibly small enough it might be okay to float a patch for now? The intrinsic change would be a bit more involved and would likely be harder to get V8 folks to agree to it.

Alternatively, I could expand this PR to integrate the change into diagnostics_channel here and we can aim for landing the whole thing together. That way we could have some benchmark numbers before we land anything. I'm not too worried about the perf landing this in a disconnected state though.

/**
* Marks this promise as unhandled, re-enabling unhandled rejection tracking.
*/
void MarkAsUnhandled();
Copy link
Member

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

Comment on lines +175 to +185
// 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;
}
Copy link
Member

Choose a reason for hiding this comment

The 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
// 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;
}
Local<Promise> derived;
{
// 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);
auto defer_reset = OnScopeLeave([&]() { env->set_observing_promise(false); });
if (!promise->Then(env->context(), on_fulfilled, on_rejected)
.ToLocal(&derived)) {
return;
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit.. unrelated edit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, my git has been acting strangely with this file for some reason. It keeps insisting there's a line feed change even if I reset it to whatever is in main. 😕

* 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.
Copy link
Member

Choose a reason for hiding this comment

The 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?

@Qard
Copy link
Member Author

Qard commented Mar 23, 2026

I thought of a better solution. Closing in favour of #62407 which simply branches rather than chains only for values which are not native promises, meaning a thenable. That preserves the unhandledRejection behaviour this was trying to resolve while still allowing thenables to work correctly which this PR actually doesn't resolve as it'd need to be converted to a native promise to have the handled state anyway. 🤦🏻

@Qard Qard closed this Mar 23, 2026
@Qard Qard deleted the observe-promise-without-handled-state branch March 23, 2026 14:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. diagnostics_channel Issues and PRs related to diagnostics channel discuss Issues opened for discussions and feedbacks. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. promises Issues and PRs related to ECMAScript promises. v8 engine Issues and PRs related to the V8 dependency.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants