Skip to content

Commit ed0ef62

Browse files
committed
diagnostics_channel: return original thenable
This makes tracePromise return the original thenable to allow custom thenable types to retain their methods rather than producing the chained result type.
1 parent 9d50432 commit ed0ef62

4 files changed

Lines changed: 87 additions & 16 deletions

doc/api/diagnostics_channel.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -948,20 +948,23 @@ added:
948948
- v19.9.0
949949
- v18.19.0
950950
changes:
951+
- version: REPLACEME
952+
pr-url: https://github.com/nodejs/node/pull/62407
953+
description: Non-native-Promise thenables are now returned as-is,
954+
preserving their original type and methods.
951955
- version: v26.0.0
952956
pr-url: https://github.com/nodejs/node/pull/61766
953-
description: Custom thenables will no longer be wrapped in native Promises.
954-
Non-thenables will be returned with a warning.
957+
description: Non-thenables will be returned with a warning.
955958
-->
956959

957960
* `fn` {Function} Function to wrap a trace around
958961
* `context` {Object} Shared object to correlate trace events through
959962
* `thisArg` {any} The receiver to be used for the function call
960963
* `...args` {any} Optional arguments to pass to the function
961-
* Returns: {any} The return value of the given function, or the result of
962-
calling `.then(...)` on the return value if the tracing channel has active
963-
subscribers. If the return value is not a Promise or thenable, then
964-
it is returned as-is and a warning is emitted.
964+
* Returns: {any} The return value of the given function. If the return value
965+
is a Promise or thenable, tracing events will be published when it settles.
966+
If the return value is not a Promise or thenable, it is returned as-is and
967+
a warning is emitted.
965968

966969
Trace an asynchronous function call which returns a {Promise} or
967970
[thenable object][]. This will always produce a [`start` event][] and

lib/diagnostics_channel.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const {
1010
ObjectDefineProperty,
1111
ObjectGetPrototypeOf,
1212
ObjectSetPrototypeOf,
13+
PromisePrototype,
1314
PromisePrototypeThen,
14-
PromiseReject,
1515
ReflectApply,
1616
SafeFinalizationRegistry,
1717
SafeMap,
@@ -29,6 +29,7 @@ const {
2929
} = require('internal/validators');
3030

3131
const { triggerUncaughtException } = internalBinding('errors');
32+
const { isPromise } = require('internal/util/types');
3233

3334
const dc_binding = internalBinding('diagnostics_channel');
3435
const { subscribers: subscriberCounts } = dc_binding;
@@ -546,17 +547,21 @@ class TracingChannel {
546547
const { error } = this;
547548
const continuationWindow = this.#continuationWindow;
548549

549-
function reject(err) {
550+
function onReject(err) {
550551
context.error = err;
551552
error.publish(context);
552553
// Use continuation window for asyncStart/asyncEnd
553554
// eslint-disable-next-line no-unused-vars
554555
using scope = continuationWindow.withScope(context);
555556
// TODO: Is there a way to have asyncEnd _after_ the continuation?
556-
return PromiseReject(err);
557557
}
558558

559-
function resolve(result) {
559+
function onRejectWithRethrow(err) {
560+
onReject(err);
561+
throw err;
562+
}
563+
564+
function onResolve(result) {
560565
context.result = result;
561566
// Use continuation window for asyncStart/asyncEnd
562567
// eslint-disable-next-line no-unused-vars
@@ -576,12 +581,17 @@ class TracingChannel {
576581
context.result = result;
577582
return result;
578583
}
579-
// For native Promises use PromisePrototypeThen to avoid user overrides.
580-
if (isPromise(result)) {
581-
return PromisePrototypeThen(result, resolve, reject);
584+
// isPromise() matches sub-classes, but we need to match only direct
585+
// instances of the native Promise type to safely use PromisePrototypeThen.
586+
if (isPromise(result) && ObjectGetPrototypeOf(result) === PromisePrototype) {
587+
return PromisePrototypeThen(result, onResolve, onRejectWithRethrow);
582588
}
583-
// For custom thenables, call .then() directly to preserve the thenable type.
584-
return result.then(resolve, reject);
589+
// For non-native thenables, subscribe to the result but return the
590+
// original thenable so the consumer can continue handling it directly.
591+
// Non-native thenables don't have unhandledRejection tracking, so
592+
// swallowing the rejection here doesn't change existing behaviour.
593+
result.then(onResolve, onReject);
594+
return result;
585595
} catch (err) {
586596
context.error = err;
587597
error.publish(context);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const dc = require('diagnostics_channel');
5+
const assert = require('assert');
6+
7+
class SpoofedPromise extends Promise {
8+
customMethod() {
9+
return 'works';
10+
}
11+
}
12+
13+
const channel = dc.tracingChannel('test');
14+
15+
const expectedResult = { foo: 'bar' };
16+
const input = { foo: 'bar' };
17+
const thisArg = { baz: 'buz' };
18+
19+
function check(found) {
20+
assert.strictEqual(found, input);
21+
}
22+
23+
function checkAsync(found) {
24+
check(found);
25+
assert.strictEqual(found.error, undefined);
26+
assert.deepStrictEqual(found.result, expectedResult);
27+
}
28+
29+
const handlers = {
30+
start: common.mustCall(check),
31+
end: common.mustCall(check),
32+
asyncStart: common.mustCall(checkAsync),
33+
asyncEnd: common.mustCall(checkAsync),
34+
error: common.mustNotCall()
35+
};
36+
37+
channel.subscribe(handlers);
38+
39+
let innerPromise;
40+
41+
const result = channel.tracePromise(common.mustCall(function() {
42+
innerPromise = SpoofedPromise.resolve(expectedResult);
43+
// Spoof the constructor to try to trick the brand check
44+
innerPromise.constructor = Promise;
45+
return innerPromise;
46+
}), input, thisArg);
47+
48+
// Despite the spoofed constructor, the original subclass instance should be
49+
// returned directly so that custom methods remain accessible.
50+
assert(result instanceof SpoofedPromise);
51+
assert.strictEqual(result, innerPromise);
52+
assert.strictEqual(result.customMethod(), 'works');

test/parallel/test-diagnostics-channel-tracing-channel-promise-thenable.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class ResolvedThenable {
1212
then(resolve) {
1313
return new ResolvedThenable(resolve(this.#result));
1414
}
15+
customMethod() {
16+
return this.#result;
17+
}
1518
}
1619

1720
const channel = dc.tracingChannel('test');
@@ -49,7 +52,10 @@ const result = channel.tracePromise(common.mustCall(function(value) {
4952
}), input, thisArg, expectedResult);
5053

5154
assert(result instanceof ResolvedThenable);
52-
assert.notStrictEqual(result, innerThenable);
55+
// With branching then, the original thenable is returned directly so that
56+
// extra methods defined on it remain accessible to the caller.
57+
assert.strictEqual(result, innerThenable);
58+
assert.deepStrictEqual(result.customMethod(), expectedResult);
5359
result.then(common.mustCall((value) => {
5460
assert.deepStrictEqual(value, expectedResult);
5561
}));

0 commit comments

Comments
 (0)