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());