Commit e2acffa6cf2 for nodejs
commit e2acffa6cf27fcc2b3602d66ac441fd7a5de3ae1
Author: James M Snell <jasnell@gmail.com>
Date: Sun Aug 30 17:03:00 2026 +0000
stream: ensure protocol and transform accessors are called once
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66030
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js
index 17edecf84ce..7b3578d3a9c 100644
--- a/lib/internal/streams/iter/broadcast.js
+++ b/lib/internal/streams/iter/broadcast.js
@@ -10,6 +10,7 @@ const {
ArrayIsArray,
ArrayPrototypePush,
ArrayPrototypeShift,
+ FunctionPrototypeCall,
PromisePrototypeThen,
PromiseReject,
PromiseResolve,
@@ -60,9 +61,9 @@ const {
kResolvedPromise,
convertChunks,
createBatchEntry,
+ getProtocolMethod,
getWriterSignal,
getMinCursor,
- hasProtocol,
onSignalAbort,
parsePullArgs,
toWriterUint8Array,
@@ -899,15 +900,12 @@ function broadcast(options = { __proto__: null }) {
return { __proto__: null, writer, broadcast: broadcastImpl };
}
-function isBroadcastable(value) {
- return hasProtocol(value, broadcastProtocol);
-}
-
const Broadcast = {
__proto__: null,
from(input, options) {
- if (isBroadcastable(input)) {
- const bc = input[broadcastProtocol](options);
+ const protocol = getProtocolMethod(input, broadcastProtocol);
+ if (protocol !== undefined) {
+ const bc = FunctionPrototypeCall(protocol, input, options);
if (bc === null || typeof bc !== 'object') {
throw new ERR_INVALID_RETURN_VALUE(
'an object', '[Symbol.for(\'Stream.broadcastProtocol\')]', bc);
diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js
index 75ad4026ad7..9e9a8fde4ec 100644
--- a/lib/internal/streams/iter/consumers.js
+++ b/lib/internal/streams/iter/consumers.js
@@ -15,6 +15,7 @@ const {
ArrayPrototypePush,
ArrayPrototypeShift,
ArrayPrototypeSlice,
+ FunctionPrototypeCall,
Promise,
PromisePrototypeThen,
SafePromiseAllReturnVoid,
@@ -53,6 +54,7 @@ const {
const {
concatBytes,
createBatchEntry,
+ getProtocolMethod,
validateBatchEntry,
yieldAbortable,
} = require('internal/streams/iter/utils');
@@ -391,14 +393,9 @@ function ondrain(drainable) {
return null;
}
- if (
- !(drainableProtocol in drainable) ||
- typeof drainable[drainableProtocol] !== 'function'
- ) {
- return null;
- }
-
- return drainable[drainableProtocol]();
+ const protocol = getProtocolMethod(drainable, drainableProtocol);
+ return protocol === undefined ?
+ null : FunctionPrototypeCall(protocol, drainable);
}
// =============================================================================
diff --git a/lib/internal/streams/iter/from.js b/lib/internal/streams/iter/from.js
index cb0fd5358a0..0d95b813e01 100644
--- a/lib/internal/streams/iter/from.js
+++ b/lib/internal/streams/iter/from.js
@@ -52,7 +52,7 @@ const {
} = require('internal/streams/iter/types');
const {
- hasProtocol,
+ getProtocolMethod,
toUint8Array,
} = require('internal/streams/iter/utils');
@@ -225,8 +225,9 @@ function* normalizeSyncValue(value) {
}
// Handle ToStreamable protocol
- if (hasProtocol(value, toStreamable)) {
- const result = FunctionPrototypeCall(value[toStreamable], value);
+ const streamableMethod = getProtocolMethod(value, toStreamable);
+ if (streamableMethod !== undefined) {
+ const result = FunctionPrototypeCall(streamableMethod, value);
yield* normalizeSyncValue(result);
return;
}
@@ -460,8 +461,12 @@ async function* normalizeAsyncValue(
return;
}
- if (!allowNestedAsyncStreamables &&
- (isAsyncIterable(value) || hasProtocol(value, toAsyncStreamable))) {
+ const hasDisallowedAsyncIterator =
+ !allowNestedAsyncStreamables && isAsyncIterable(value);
+ const asyncStreamableMethod = hasDisallowedAsyncIterator ?
+ undefined : getProtocolMethod(value, toAsyncStreamable);
+ if (hasDisallowedAsyncIterator ||
+ (!allowNestedAsyncStreamables && asyncStreamableMethod !== undefined)) {
throw new ERR_INVALID_ARG_TYPE(
'value',
['string', 'ArrayBuffer', 'ArrayBufferView', 'Iterable', 'toStreamable'],
@@ -470,8 +475,8 @@ async function* normalizeAsyncValue(
}
// Handle ToAsyncStreamable protocol (check before ToStreamable)
- if (hasProtocol(value, toAsyncStreamable)) {
- const result = FunctionPrototypeCall(value[toAsyncStreamable], value);
+ if (asyncStreamableMethod !== undefined) {
+ const result = FunctionPrototypeCall(asyncStreamableMethod, value);
if (isPromise(result)) {
yield* normalizeAsyncValue(
await waitForNormalization(result, context),
@@ -485,8 +490,9 @@ async function* normalizeAsyncValue(
}
// Handle ToStreamable protocol
- if (hasProtocol(value, toStreamable)) {
- const result = FunctionPrototypeCall(value[toStreamable], value);
+ const streamableMethod = getProtocolMethod(value, toStreamable);
+ if (streamableMethod !== undefined) {
+ const result = FunctionPrototypeCall(streamableMethod, value);
yield* normalizeAsyncValue(result, allowNestedAsyncStreamables, context);
return;
}
@@ -692,8 +698,9 @@ function fromSync(input) {
// Check toStreamable protocol (takes precedence over iteration protocols).
// toAsyncStreamable is ignored entirely in fromSync.
- if (typeof input[toStreamable] === 'function') {
- return fromSync(input[toStreamable]());
+ const streamableMethod = getProtocolMethod(input, toStreamable);
+ if (streamableMethod !== undefined) {
+ return fromSync(FunctionPrototypeCall(streamableMethod, input));
}
const isIterable = isSyncIterable(input);
@@ -796,8 +803,9 @@ function from(input) {
// Check toAsyncStreamable protocol (takes precedence over toStreamable and
// iteration protocols)
- if (typeof input[toAsyncStreamable] === 'function') {
- let result = input[toAsyncStreamable]();
+ const asyncStreamableMethod = getProtocolMethod(input, toAsyncStreamable);
+ if (asyncStreamableMethod !== undefined) {
+ let result = FunctionPrototypeCall(asyncStreamableMethod, input);
if (isPromise(result)) {
result = PromisePrototypeThen(result, undefined, undefined);
markPromiseAsHandled(result);
@@ -811,8 +819,9 @@ function from(input) {
}
// Check toStreamable protocol (takes precedence over iteration protocols)
- if (typeof input[toStreamable] === 'function') {
- return from(input[toStreamable]());
+ const streamableMethod = getProtocolMethod(input, toStreamable);
+ if (streamableMethod !== undefined) {
+ return from(FunctionPrototypeCall(streamableMethod, input));
}
// Must be a Streamable (sync or async iterable)
diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js
index 01a9504dc87..9b4d7fb884d 100644
--- a/lib/internal/streams/iter/pull.js
+++ b/lib/internal/streams/iter/pull.js
@@ -49,9 +49,9 @@ const {
const {
createBatchEntry,
- isTransform,
isTransformObject,
parsePullArgs,
+ snapshotTransform,
toUint8Array,
validateBatchEntry,
validateByteView,
@@ -93,7 +93,8 @@ function parsePipeToArgs(args, requiredMethod) {
// Check if last arg is options
const last = args[args.length - 1];
- if (!isTransform(last) && !hasMethod(last, requiredMethod)) {
+ if (snapshotTransform(last) === undefined &&
+ !hasMethod(last, requiredMethod)) {
options = last;
writerIndex = args.length - 2;
}
@@ -110,11 +111,13 @@ function parsePipeToArgs(args, requiredMethod) {
const transforms = ArrayPrototypeSlice(args, 0, writerIndex);
for (let i = 0; i < transforms.length; i++) {
- if (!isTransform(transforms[i])) {
+ const transform = snapshotTransform(transforms[i]);
+ if (transform === undefined) {
throw new ERR_INVALID_ARG_TYPE(
`transforms[${i}]`, ['Function', 'Object with transform()'],
transforms[i]);
}
+ transforms[i] = transform;
}
return {
@@ -540,7 +543,7 @@ function* createSyncPipeline(source, transforms) {
statelessRun = [];
}
current = applyStatefulSyncTransform(
- current, transform.transform, transform);
+ current, transform.transform, transform.receiver);
} else {
ArrayPrototypePush(statelessRun, transform);
}
@@ -728,51 +731,51 @@ async function* createAsyncPipeline(source, transforms, signal) {
signal.addEventListener('abort', abortHandler, { __proto__: null, once: true });
}
- // Apply transforms - fuse consecutive stateless transforms into a single
- // generator layer to avoid unnecessary async generator ticks.
- //
- // INVARIANT: Each transform invocation MUST receive its own fresh options
- // object ({ __proto__: null, signal }). Transforms may mutate the options
- // object, so sharing a single object across invocations would allow one
- // transform to corrupt the options seen by another. The signal is shared
- // across calls (mutations to it are acceptable), but the containing options
- // object must be unique per call. This is enforced inside
- // applyFusedStatelessAsyncTransforms and applyStatefulAsyncTransform, which
- // accept the signal directly and create the options object per invocation.
- // DO NOT pass a pre-built options object.
- let current = normalized;
- const transformSignal = controller.signal;
- let statelessRun = [];
-
- for (let i = 0; i < transforms.length; i++) {
- const transform = transforms[i];
- if (isTransformObject(transform)) {
- // Flush any accumulated stateless run before the stateful transform
- if (statelessRun.length > 0) {
- current = applyFusedStatelessAsyncTransforms(current, statelessRun,
- transformSignal);
- statelessRun = [];
- }
- const opts = { __proto__: null, signal: transformSignal };
- if (transform[kValidatedTransform]) {
- current = applyValidatedStatefulAsyncTransform(
- current, transform.transform, transform, opts);
+ let completed = false;
+ try {
+ // Apply transforms - fuse consecutive stateless transforms into a single
+ // generator layer to avoid unnecessary async generator ticks.
+ //
+ // INVARIANT: Each transform invocation MUST receive its own fresh options
+ // object ({ __proto__: null, signal }). Transforms may mutate the options
+ // object, so sharing a single object across invocations would allow one
+ // transform to corrupt the options seen by another. The signal is shared
+ // across calls (mutations to it are acceptable), but the containing options
+ // object must be unique per call. This is enforced inside
+ // applyFusedStatelessAsyncTransforms and applyStatefulAsyncTransform, which
+ // accept the signal directly and create the options object per invocation.
+ // DO NOT pass a pre-built options object.
+ let current = normalized;
+ const transformSignal = controller.signal;
+ let statelessRun = [];
+
+ for (let i = 0; i < transforms.length; i++) {
+ const transform = transforms[i];
+ if (isTransformObject(transform)) {
+ // Flush any accumulated stateless run before the stateful transform
+ if (statelessRun.length > 0) {
+ current = applyFusedStatelessAsyncTransforms(current, statelessRun,
+ transformSignal);
+ statelessRun = [];
+ }
+ const opts = { __proto__: null, signal: transformSignal };
+ if (transform[kValidatedTransform]) {
+ current = applyValidatedStatefulAsyncTransform(
+ current, transform.transform, transform.receiver, opts);
+ } else {
+ current = applyStatefulAsyncTransform(
+ current, transform.transform, transform.receiver, opts);
+ }
} else {
- current = applyStatefulAsyncTransform(
- current, transform.transform, transform, opts);
+ ArrayPrototypePush(statelessRun, transform);
}
- } else {
- ArrayPrototypePush(statelessRun, transform);
}
- }
- // Flush remaining stateless run
- if (statelessRun.length > 0) {
- current = applyFusedStatelessAsyncTransforms(current, statelessRun,
- transformSignal);
- }
+ // Flush remaining stateless run
+ if (statelessRun.length > 0) {
+ current = applyFusedStatelessAsyncTransforms(current, statelessRun,
+ transformSignal);
+ }
- let completed = false;
- try {
for await (const batch of current) {
controller.signal.throwIfAborted();
yield batch;
@@ -813,11 +816,13 @@ async function* createAsyncPipeline(source, transforms, signal) {
function pullSync(source, ...transforms) {
const normalized = fromSync(source);
for (let i = 0; i < transforms.length; i++) {
- if (!isTransform(transforms[i])) {
+ const transform = snapshotTransform(transforms[i]);
+ if (transform === undefined) {
throw new ERR_INVALID_ARG_TYPE(
`transforms[${i}]`, ['Function', 'Object with transform()'],
transforms[i]);
}
+ transforms[i] = transform;
}
return {
__proto__: null,
diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js
index 5e813a8de51..e860b7119cb 100644
--- a/lib/internal/streams/iter/share.js
+++ b/lib/internal/streams/iter/share.js
@@ -39,8 +39,8 @@ const {
const {
kMultiConsumerDefaultBudget,
createBatchEntry,
+ getProtocolMethod,
getMinCursor,
- hasProtocol,
onSignalAbort,
parsePullArgs,
validateBatchEntry,
@@ -833,19 +833,12 @@ function shareSync(source, options = { __proto__: null }) {
return new SyncShareImpl(normalized, opts);
}
-function isShareable(value) {
- return hasProtocol(value, shareProtocol);
-}
-
-function isSyncShareable(value) {
- return hasProtocol(value, shareSyncProtocol);
-}
-
const Share = {
__proto__: null,
from(input, options) {
- if (isShareable(input)) {
- const result = input[shareProtocol](options);
+ const protocol = getProtocolMethod(input, shareProtocol);
+ if (protocol !== undefined) {
+ const result = FunctionPrototypeCall(protocol, input, options);
if (result === null || typeof result !== 'object') {
throw new ERR_INVALID_RETURN_VALUE(
'an object', '[Symbol.for(\'Stream.shareProtocol\')]', result);
@@ -863,8 +856,9 @@ const Share = {
const SyncShare = {
__proto__: null,
fromSync(input, options) {
- if (isSyncShareable(input)) {
- const result = input[shareSyncProtocol](options);
+ const protocol = getProtocolMethod(input, shareSyncProtocol);
+ if (protocol !== undefined) {
+ const result = FunctionPrototypeCall(protocol, input, options);
if (result === null || typeof result !== 'object') {
throw new ERR_INVALID_RETURN_VALUE(
'an object', '[Symbol.for(\'Stream.shareSyncProtocol\')]', result);
diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js
index 95d2d914eb1..a6069c9ed3b 100644
--- a/lib/internal/streams/iter/utils.js
+++ b/lib/internal/streams/iter/utils.js
@@ -9,6 +9,7 @@ const {
PromiseWithResolvers,
SafePromisePrototypeFinally,
SafePromiseRace,
+ SafeWeakSet,
SymbolAsyncIterator,
TypedArrayPrototypeGetBuffer,
TypedArrayPrototypeGetByteLength,
@@ -37,6 +38,9 @@ const {
const {
converters,
} = require('internal/streams/iter/webidl');
+const {
+ kValidatedTransform,
+} = require('internal/streams/iter/types');
// Cached resolved promise to avoid allocating a new one on every sync fast-path.
const kResolvedPromise = PromiseResolve();
@@ -341,13 +345,40 @@ function toWriterUint8Array(chunk) {
* @param {symbol} symbol
* @returns {boolean}
*/
+function getProtocolMethod(value, symbol) {
+ if (value === null || typeof value !== 'object' || !(symbol in value)) {
+ return undefined;
+ }
+ const method = value[symbol];
+ return typeof method === 'function' ? method : undefined;
+}
+
function hasProtocol(value, symbol) {
- return (
- value !== null &&
- typeof value === 'object' &&
- symbol in value &&
- typeof value[symbol] === 'function'
- );
+ return getProtocolMethod(value, symbol) !== undefined;
+}
+
+const transformRecords = new SafeWeakSet();
+
+/**
+ * Read and retain a stateful transform's callable exactly once.
+ * @param {unknown} value
+ * @returns {Function|object|undefined}
+ */
+function snapshotTransform(value) {
+ if (typeof value === 'function') return value;
+ if (transformRecords.has(value)) return value;
+
+ const transform = value?.transform;
+ if (typeof transform !== 'function') return undefined;
+
+ const record = {
+ __proto__: null,
+ transform,
+ receiver: value,
+ [kValidatedTransform]: value[kValidatedTransform],
+ };
+ transformRecords.add(record);
+ return record;
}
/**
@@ -356,7 +387,7 @@ function hasProtocol(value, symbol) {
* @returns {boolean}
*/
function isTransformObject(value) {
- return typeof value?.transform === 'function';
+ return transformRecords.has(value) || typeof value?.transform === 'function';
}
/**
@@ -382,20 +413,24 @@ function parsePullArgs(args) {
let transforms;
let options;
const last = args[args.length - 1];
- if (!isTransform(last)) {
+ const lastTransform = snapshotTransform(last);
+ if (lastTransform === undefined) {
transforms = ArrayPrototypeSlice(args, 0, -1);
options = last;
} else {
- transforms = args;
+ transforms = ArrayPrototypeSlice(args);
+ transforms[transforms.length - 1] = lastTransform;
options = undefined;
}
for (let i = 0; i < transforms.length; i++) {
- if (!isTransform(transforms[i])) {
+ const transform = snapshotTransform(transforms[i]);
+ if (transform === undefined) {
throw new ERR_INVALID_ARG_TYPE(
`transforms[${i}]`, ['Function', 'Object with transform()'],
transforms[i]);
}
+ transforms[i] = transform;
}
return { __proto__: null, transforms, options };
@@ -421,6 +456,7 @@ module.exports = {
concatBytes,
convertChunks,
createBatchEntry,
+ getProtocolMethod,
getWriterSignal,
getMinCursor,
hasProtocol,
@@ -428,6 +464,7 @@ module.exports = {
isTransformObject,
onSignalAbort,
parsePullArgs,
+ snapshotTransform,
toUint8Array,
toWriterUint8Array,
validateBackpressure,
diff --git a/test/parallel/test-stream-iter-property-access.js b/test/parallel/test-stream-iter-property-access.js
new file mode 100644
index 00000000000..344a481b081
--- /dev/null
+++ b/test/parallel/test-stream-iter-property-access.js
@@ -0,0 +1,143 @@
+// Flags: --experimental-stream-iter
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const {
+ Broadcast,
+ Share,
+ SyncShare,
+ broadcast,
+ broadcastProtocol,
+ bytes,
+ bytesSync,
+ drainableProtocol,
+ from,
+ fromSync,
+ ondrain,
+ pull,
+ pullSync,
+ share,
+ shareProtocol,
+ shareSync,
+ shareSyncProtocol,
+ toAsyncStreamable,
+ toStreamable,
+} = require('stream/iter');
+
+function protocolFixture(symbol, result) {
+ let accesses = 0;
+ const input = {};
+ const method = common.mustCall(function() {
+ assert.strictEqual(this, input);
+ return result;
+ });
+ Object.defineProperty(input, symbol, {
+ get() {
+ accesses++;
+ if (accesses > 1) throw new Error('protocol method read twice');
+ return method;
+ },
+ });
+ return { input, get accesses() { return accesses; } };
+}
+
+function statefulTransformFixture() {
+ let accesses = 0;
+ const transform = {};
+ const method = common.mustCall(function(source) {
+ assert.strictEqual(this, transform);
+ return source;
+ });
+ Object.defineProperty(transform, 'transform', {
+ get() {
+ accesses++;
+ if (accesses > 1) throw new Error('transform method read twice');
+ return method;
+ },
+ });
+ return { transform, get accesses() { return accesses; } };
+}
+
+async function testFromSnapshotsProtocolMethods() {
+ for (const symbol of [toAsyncStreamable, toStreamable]) {
+ const fixture = protocolFixture(symbol, 'abc');
+ assert.deepStrictEqual(await bytes(from(fixture.input)),
+ new Uint8Array([97, 98, 99]));
+ assert.strictEqual(fixture.accesses, 1);
+ }
+
+ const fixture = protocolFixture(toAsyncStreamable, 'nested');
+ async function* source() {
+ yield fixture.input;
+ }
+ assert.deepStrictEqual(await bytes(from(source())),
+ new Uint8Array([110, 101, 115, 116, 101, 100]));
+ assert.strictEqual(fixture.accesses, 1);
+}
+
+async function testFromSyncSnapshotsProtocolMethods() {
+ const fixture = protocolFixture(toStreamable, 'abc');
+ assert.deepStrictEqual(bytesSync(fromSync(fixture.input)),
+ new Uint8Array([97, 98, 99]));
+ assert.strictEqual(fixture.accesses, 1);
+}
+
+async function testPullSnapshotsStatefulTransform() {
+ const fixture = statefulTransformFixture();
+ const controller = new AbortController();
+ const readable = pull('abc', fixture.transform, {
+ signal: controller.signal,
+ });
+
+ assert.deepStrictEqual(await bytes(readable),
+ new Uint8Array([97, 98, 99]));
+ assert.strictEqual(fixture.accesses, 1);
+}
+
+async function testPullSyncSnapshotsStatefulTransform() {
+ const fixture = statefulTransformFixture();
+ assert.deepStrictEqual(bytesSync(pullSync('abc', fixture.transform)),
+ new Uint8Array([97, 98, 99]));
+ assert.strictEqual(fixture.accesses, 1);
+}
+
+async function testMultiConsumerProtocolsSnapshotMethods() {
+ const broadcastTarget = broadcast().broadcast;
+ const broadcastFixture = protocolFixture(
+ broadcastProtocol, broadcastTarget);
+ assert.strictEqual(
+ Broadcast.from(broadcastFixture.input).broadcast, broadcastTarget);
+ assert.strictEqual(broadcastFixture.accesses, 1);
+
+ const shareTarget = share('abc');
+ const shareFixture = protocolFixture(shareProtocol, shareTarget);
+ assert.strictEqual(Share.from(shareFixture.input), shareTarget);
+ assert.strictEqual(shareFixture.accesses, 1);
+
+ const syncShareTarget = shareSync('abc');
+ const syncShareFixture = protocolFixture(
+ shareSyncProtocol, syncShareTarget);
+ assert.strictEqual(SyncShare.fromSync(syncShareFixture.input),
+ syncShareTarget);
+ assert.strictEqual(syncShareFixture.accesses, 1);
+
+ broadcastTarget.cancel();
+ shareTarget.cancel();
+ syncShareTarget.cancel();
+}
+
+async function testDrainableProtocolSnapshotMethod() {
+ const fixture = protocolFixture(drainableProtocol, true);
+ assert.strictEqual(await ondrain(fixture.input), true);
+ assert.strictEqual(fixture.accesses, 1);
+}
+
+Promise.all([
+ testFromSnapshotsProtocolMethods(),
+ testFromSyncSnapshotsProtocolMethods(),
+ testPullSnapshotsStatefulTransform(),
+ testPullSyncSnapshotsStatefulTransform(),
+ testMultiConsumerProtocolsSnapshotMethods(),
+ testDrainableProtocolSnapshotMethod(),
+]).then(common.mustCall());