Skip to content

Commit a49a1b2

Browse files
committed
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.
1 parent 69fdff9 commit a49a1b2

File tree

8 files changed

+204
-1
lines changed

8 files changed

+204
-1
lines changed

deps/v8/include/v8-promise.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ class V8_EXPORT Promise : public Object {
102102
*/
103103
void MarkAsHandled();
104104

105+
/**
106+
* Marks this promise as unhandled, re-enabling unhandled rejection tracking.
107+
*/
108+
void MarkAsUnhandled();
109+
105110
/**
106111
* Marks this promise as silent to prevent pausing the debugger when the
107112
* promise is rejected.

deps/v8/src/api/api.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8606,6 +8606,10 @@ void Promise::MarkAsHandled() {
86068606
Utils::OpenDirectHandle(this)->set_has_handler(true);
86078607
}
86088608

8609+
void Promise::MarkAsUnhandled() {
8610+
Utils::OpenDirectHandle(this)->set_has_handler(false);
8611+
}
8612+
86098613
void Promise::MarkAsSilent() {
86108614
Utils::OpenDirectHandle(this)->set_is_silent(true);
86118615
}

doc/api/packages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ added: v0.4.0
10121012
The `"main"` field defines the entry point of a package when imported by name
10131013
via a `node_modules` lookup. Its value is a path.
10141014

1015-
The [`"exports"`][] field, if it exists, takes precedence over the
1015+
The [`"exports"`][] field, if it exists, takes precedence over the
10161016
`"main"` field when importing the package by name.
10171017

10181018
It also defines the script that is used when the [package directory is loaded

lib/internal/promise_observe.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict';
2+
3+
const { observePromise: _observe } = internalBinding('task_queue');
4+
5+
/**
6+
* Observes a promise's fulfillment or rejection without suppressing
7+
* the `unhandledRejection` event. APM and diagnostics tools can use
8+
* this to be notified of promise outcomes while still allowing
9+
* `unhandledRejection` to fire if no real handler exists.
10+
*
11+
* @param {Promise} promise - The promise to observe.
12+
* @param {Function} [onFulfilled] - Called when the promise fulfills.
13+
* @param {Function} [onRejected] - Called when the promise rejects.
14+
*/
15+
function observePromise(promise, onFulfilled, onRejected) {
16+
_observe(
17+
promise,
18+
onFulfilled ?? (() => {}),
19+
onRejected ?? (() => {}),
20+
);
21+
}
22+
23+
module.exports = { observePromise };

src/env-inl.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,14 @@ bool Environment::source_maps_enabled() const {
701701
return source_maps_enabled_;
702702
}
703703

704+
void Environment::set_observing_promise(bool on) {
705+
observing_promise_ = on;
706+
}
707+
708+
bool Environment::observing_promise() const {
709+
return observing_promise_;
710+
}
711+
704712
inline uint64_t Environment::thread_id() const {
705713
return thread_id_;
706714
}

src/env.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,9 @@ class Environment final : public MemoryRetainer {
831831
inline void set_source_maps_enabled(bool on);
832832
inline bool source_maps_enabled() const;
833833

834+
inline void set_observing_promise(bool on);
835+
inline bool observing_promise() const;
836+
834837
inline void ThrowError(const char* errmsg);
835838
inline void ThrowTypeError(const char* errmsg);
836839
inline void ThrowRangeError(const char* errmsg);
@@ -1114,6 +1117,7 @@ class Environment final : public MemoryRetainer {
11141117
bool emit_env_nonstring_warning_ = true;
11151118
bool emit_err_name_warning_ = true;
11161119
bool source_maps_enabled_ = false;
1120+
bool observing_promise_ = false;
11171121

11181122
size_t async_callback_scope_depth_ = 0;
11191123
std::vector<double> destroy_async_id_list_;

src/node_task_queue.cc

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ void PromiseRejectCallback(PromiseRejectMessage message) {
7171
"unhandled", unhandledRejections,
7272
"handledAfter", rejectionsHandledAfter);
7373
} else if (event == kPromiseHandlerAddedAfterReject) {
74+
// If this notification was triggered by ObservePromise's internal .then()
75+
// call, suppress it so the promise remains in pendingUnhandledRejections
76+
// and unhandledRejection still fires.
77+
if (env->observing_promise()) return;
7478
value = Undefined(isolate);
7579
rejectionsHandledAfter++;
7680
TRACE_COUNTER2(TRACING_CATEGORY_NODE2(promises, rejections),
@@ -156,6 +160,43 @@ static void SetPromiseRejectCallback(
156160
env->set_promise_reject_callback(args[0].As<Function>());
157161
}
158162

163+
static void ObservePromise(const FunctionCallbackInfo<Value>& args) {
164+
Environment* env = Environment::GetCurrent(args);
165+
CHECK(args[0]->IsPromise());
166+
CHECK(args[1]->IsFunction());
167+
CHECK(args[2]->IsFunction());
168+
169+
Local<Promise> promise = args[0].As<Promise>();
170+
Local<Function> on_fulfilled = args[1].As<Function>();
171+
Local<Function> on_rejected = args[2].As<Function>();
172+
173+
bool was_handled = promise->HasHandler();
174+
175+
// Set flag BEFORE .Then() so that if V8 fires kPromiseHandlerAddedAfterReject
176+
// synchronously (because the promise is already rejected), PromiseRejectCallback
177+
// suppresses it and the promise stays in pendingUnhandledRejections.
178+
env->set_observing_promise(true);
179+
180+
Local<Promise> derived;
181+
if (!promise->Then(env->context(), on_fulfilled, on_rejected)
182+
.ToLocal(&derived)) {
183+
env->set_observing_promise(false);
184+
return;
185+
}
186+
187+
env->set_observing_promise(false);
188+
189+
// The derived promise from .then() should never itself trigger unhandled
190+
// rejection warnings — it's an internal observer chain.
191+
derived->MarkAsHandled();
192+
193+
// Restore the original unhandled state so unhandledRejection still fires.
194+
// Only clear if it wasn't already handled by a real handler before we observed.
195+
if (!was_handled) {
196+
promise->MarkAsUnhandled();
197+
}
198+
}
199+
159200
static void Initialize(Local<Object> target,
160201
Local<Value> unused,
161202
Local<Context> context,
@@ -181,13 +222,15 @@ static void Initialize(Local<Object> target,
181222
events).Check();
182223
SetMethod(
183224
context, target, "setPromiseRejectCallback", SetPromiseRejectCallback);
225+
SetMethod(context, target, "observePromise", ObservePromise);
184226
}
185227

186228
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
187229
registry->Register(EnqueueMicrotask);
188230
registry->Register(SetTickCallback);
189231
registry->Register(RunMicrotasks);
190232
registry->Register(SetPromiseRejectCallback);
233+
registry->Register(ObservePromise);
191234
}
192235

193236
} // namespace task_queue
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict';
2+
// This test ensures that observePromise() lets APM/diagnostic tools observe
3+
// promise outcomes without suppressing the `unhandledRejection` event.
4+
// Flags: --expose-internals
5+
const common = require('../common');
6+
const assert = require('node:assert');
7+
const { observePromise } = require('internal/promise_observe');
8+
9+
// Execution order within a tick: nextTick queue → microtasks → processPromiseRejections
10+
// (which fires unhandledRejection). setImmediate runs in the next event loop
11+
// iteration after all of the above, so we use it for final assertions.
12+
//
13+
// All tests share the same process, so we track unhandledRejection by promise
14+
// identity rather than using process.once, which would fire for all rejections.
15+
16+
// Track all unhandled rejections by promise identity.
17+
const unhandledByPromise = new Map();
18+
process.on('unhandledRejection', (reason, promise) => {
19+
unhandledByPromise.set(promise, reason);
20+
});
21+
22+
// --- Test 1: Observe a rejected promise — unhandledRejection still fires ---
23+
{
24+
const err1 = new Error('test1');
25+
const p1 = Promise.reject(err1);
26+
27+
observePromise(p1, null, common.mustCall((err) => {
28+
assert.strictEqual(err, err1);
29+
}));
30+
31+
setImmediate(common.mustCall(() => {
32+
assert.ok(unhandledByPromise.has(p1), 'Test 1: unhandledRejection should have fired');
33+
assert.strictEqual(unhandledByPromise.get(p1), err1);
34+
}));
35+
}
36+
37+
// --- Test 2: Observe then add real handler — no unhandledRejection ---
38+
{
39+
const err2 = new Error('test2');
40+
const p2 = Promise.reject(err2);
41+
42+
observePromise(p2, null, common.mustCall(() => {}));
43+
44+
// Real handler added synchronously after observing.
45+
p2.catch(() => {});
46+
47+
setImmediate(common.mustCall(() => {
48+
assert.ok(!unhandledByPromise.has(p2), 'Test 2: unhandledRejection should NOT have fired');
49+
}));
50+
}
51+
52+
// --- Test 3: Observe pending promise that later rejects ---
53+
{
54+
const err3 = new Error('test3');
55+
const { promise: p3, reject: reject3 } = Promise.withResolvers();
56+
57+
observePromise(p3, null, common.mustCall((err) => {
58+
assert.strictEqual(err, err3);
59+
}));
60+
61+
reject3(err3);
62+
63+
// Two rounds of setImmediate to ensure both the observer callback and
64+
// unhandledRejection have had a chance to run.
65+
setImmediate(common.mustCall(() => {
66+
setImmediate(common.mustCall(() => {
67+
assert.ok(unhandledByPromise.has(p3), 'Test 3: unhandledRejection should have fired');
68+
}));
69+
}));
70+
}
71+
72+
// --- Test 4: Observe pending promise that fulfills — no warnings ---
73+
{
74+
const { promise: p4, resolve: resolve4 } = Promise.withResolvers();
75+
76+
observePromise(p4, common.mustCall((val) => {
77+
assert.strictEqual(val, 42);
78+
}), null);
79+
80+
resolve4(42);
81+
82+
setImmediate(common.mustCall(() => {
83+
setImmediate(common.mustCall(() => {
84+
assert.ok(!unhandledByPromise.has(p4), 'Test 4: unhandledRejection should NOT have fired');
85+
}));
86+
}));
87+
}
88+
89+
// --- Test 5: Multiple observers — all called, unhandledRejection still fires ---
90+
{
91+
const err5 = new Error('test5');
92+
const p5 = Promise.reject(err5);
93+
94+
observePromise(p5, null, common.mustCall(() => {}));
95+
observePromise(p5, null, common.mustCall(() => {}));
96+
97+
setImmediate(common.mustCall(() => {
98+
assert.ok(unhandledByPromise.has(p5), 'Test 5: unhandledRejection should have fired');
99+
assert.strictEqual(unhandledByPromise.get(p5), err5);
100+
}));
101+
}
102+
103+
// --- Test 6: Observe already-handled promise — no unhandledRejection ---
104+
{
105+
const err6 = new Error('test6');
106+
const p6 = Promise.reject(err6);
107+
108+
// Real handler added first.
109+
p6.catch(() => {});
110+
111+
observePromise(p6, null, common.mustCall(() => {}));
112+
113+
setImmediate(common.mustCall(() => {
114+
assert.ok(!unhandledByPromise.has(p6), 'Test 6: unhandledRejection should NOT have fired');
115+
}));
116+
}

0 commit comments

Comments
 (0)