Commit 7ae68bd0f41 for nodejs

commit 7ae68bd0f41ee8e18966fbdc6312405a292a241c
Author: Matteo Collina <hello@matteocollina.com>
Date:   Mon Nov 9 07:27:34 2026 +0100

    stream: trim webstreams sink wrappers and BYOB transfers

    The close and abort methods of an underlying sink and the flush and
    cancel methods of a transformer were each wrapped at construction in an
    async closure that the controller kept for the stream's lifetime: two
    per WritableStream and two per TransformStream, each with its context.
    Store these one-shot methods as-is in the controller state next to the
    object they belong to, and call them as that object's methods with the
    spec's argument list through a promiseCall helper that keeps the
    reference implementation's adoption ticks. The per-chunk methods (write,
    transform) keep their closures, which make the method a constant for the
    optimizing compiler on the hot paths; so does the source's cancel
    wrapper, since the readable stream's construction path is sensitive to
    any change in its inlining budget.

    In the byte controller, the transfer of a pull-into descriptor's buffer
    when it is committed is dropped: nothing can reach that buffer any more,
    since it was transferred when the descriptor was created and the BYOB
    request that exposed it is only ever invalidated together with a
    transfer. The transfer of the first pending descriptor's buffer on
    enqueue is likewise only needed while a BYOB request exposes it.
    Allocating an ArrayBuffer costs about half a microsecond, so those two
    transfers were a large part of a BYOB read.

    Bytes allocated per stream with user methods: WritableStream 1040 ->
    844, TransformStream 1816 -> 1720. The microtask ordering of every user
    method call is unchanged.

    Benchmark results (20 runs, vs. the previous commit):

        webstreams/readable-read.js type='byob' n=100000           ***   17.14 %  ±1.80%
        webstreams/readable-async-iterator.js type='normal' n=100000        3.79 %  ±4.49%
        webstreams/from.js kind='sync' n=1000000                     *   -2.18 %  ±2.02%
        webstreams/js_transfer.js n=10000 payload='TransformStream'   *   -1.85 %  ±1.58%

    The other 42 rows are within their confidence intervals; the two rows at
    the 5% level are the expected number of false positives for 46
    comparisons.

    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs/node/pull/66702
    Reviewed-By: Paolo Insogna <paolo@cowtech.it>

diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js
index 97bdf261377..3181355959b 100644
--- a/lib/internal/webstreams/readablestream.js
+++ b/lib/internal/webstreams/readablestream.js
@@ -2400,13 +2400,16 @@ function readableByteStreamControllerConvertPullIntoDescriptor(desc) {
   if (bytesFilled > byteLength)
     throw new ERR_INVALID_STATE.RangeError('The buffer size is invalid');
   assert(!(bytesFilled % elementSize));
-  const transferredBuffer = ArrayBufferPrototypeTransfer(buffer);
+  // The spec transfers the buffer once more here. Nothing else can reach
+  // it by now: it was transferred when the descriptor was created, and
+  // the BYOB request that exposes it has been invalidated, which always
+  // goes together with a transfer, so the view is built on it as-is.

   if (ctor === Buffer) {
-    return Buffer.from(transferredBuffer, byteOffset, bytesFilled / elementSize);
+    return Buffer.from(buffer, byteOffset, bytesFilled / elementSize);
   }

-  return new ctor(transferredBuffer, byteOffset, bytesFilled / elementSize);
+  return new ctor(buffer, byteOffset, bytesFilled / elementSize);
 }

 function isReadableStreamLocked(stream) {
@@ -3439,11 +3442,15 @@ function readableByteStreamControllerEnqueue(controller, chunk) {
       );
     }

-    readableByteStreamControllerInvalidateBYOBRequest(controller);
-
-    firstPendingPullInto.buffer = ArrayBufferPrototypeTransfer(
-      firstPendingPullInto.buffer,
-    );
+    // The transfer detaches the view the BYOB request exposed; without
+    // a request the buffer is unreachable and there is nothing to
+    // detach.
+    if (controller[kState].byobRequest !== null) {
+      readableByteStreamControllerInvalidateBYOBRequest(controller);
+      firstPendingPullInto.buffer = ArrayBufferPrototypeTransfer(
+        firstPendingPullInto.buffer,
+      );
+    }

     if (firstPendingPullInto.type === 'none') {
       readableByteStreamControllerEnqueueDetachedPullIntoToQueue(
diff --git a/lib/internal/webstreams/transformstream.js b/lib/internal/webstreams/transformstream.js
index bb1d1aa8396..ba6d51f813f 100644
--- a/lib/internal/webstreams/transformstream.js
+++ b/lib/internal/webstreams/transformstream.js
@@ -35,6 +35,7 @@ const {
 } = require('internal/util');

 const {
+  validateFunction,
   validateObject,
   kValidateObjectAllowObjects,
   kValidateObjectAllowObjectsAndNull,
@@ -48,7 +49,6 @@ const {
 } = require('internal/worker/js_transferable');

 const {
-  createPromiseCallback1Param,
   createRawCallback2Params,
   customInspect,
   deferredStartAlgorithm,
@@ -60,8 +60,8 @@ const {
   kResolvedPromise,
   kState,
   kType,
-  nonOpCancel,
-  nonOpFlush,
+  nonOpCallback,
+  promiseCall1Param,
 } = require('internal/webstreams/util');

 const {
@@ -273,6 +273,9 @@ ObjectSetPrototypeOf(TransformStreamState.prototype, null);

 class TransformStreamControllerState {
   stream = undefined;
+  // The user-provided transformer the flush and cancel algorithms are
+  // methods of.
+  transformer = undefined;
   transformAlgorithm = undefined;
   flushAlgorithm = undefined;
   cancelAlgorithm = undefined;
@@ -517,11 +520,13 @@ function setupTransformStreamDefaultController(
   controller,
   transformAlgorithm,
   flushAlgorithm,
-  cancelAlgorithm) {
+  cancelAlgorithm,
+  transformer) {
   assert(isTransformStream(stream));
   assert(stream[kState].controller === undefined);
   const controllerState = new TransformStreamControllerState();
   controllerState.stream = stream;
+  controllerState.transformer = transformer;
   controllerState.transformAlgorithm = transformAlgorithm;
   controllerState.flushAlgorithm = flushAlgorithm;
   controllerState.cancelAlgorithm = cancelAlgorithm;
@@ -539,25 +544,25 @@ function setupTransformStreamDefaultControllerFromTransformer(
   const transformAlgorithm = transform ?
     createRawCallback2Params('transformer.transform', transform, transformer) :
     defaultTransformAlgorithm;
-  const flushAlgorithm = flush ?
-    createPromiseCallback1Param('transformer.flush', flush, transformer) :
-    nonOpFlush;
-  const cancelAlgorithm = cancel ?
-    createPromiseCallback1Param('transformer.cancel', cancel, transformer) :
-    nonOpCancel;
+  if (flush)
+    validateFunction(flush, 'transformer.flush');
+  if (cancel)
+    validateFunction(cancel, 'transformer.cancel');

   setupTransformStreamDefaultController(
     stream,
     controller,
     transformAlgorithm,
-    flushAlgorithm,
-    cancelAlgorithm);
+    flush || nonOpCallback,
+    cancel || nonOpCallback,
+    transformer);
 }

 function transformStreamDefaultControllerClearAlgorithms(controller) {
   controller[kState].transformAlgorithm = undefined;
   controller[kState].flushAlgorithm = undefined;
   controller[kState].cancelAlgorithm = undefined;
+  controller[kState].transformer = undefined;
 }

 function transformStreamDefaultControllerEnqueue(controller, chunk) {
@@ -715,7 +720,9 @@ async function transformStreamDefaultSinkAbortAlgorithm(

   const { promise, resolve, reject } = PromiseWithResolvers();
   controller[kState].finishPromise = promise;
-  const cancelPromise = controller[kState].cancelAlgorithm(reason);
+  const cancelPromise = promiseCall1Param(
+    controller[kState].cancelAlgorithm, controller[kState].transformer,
+    reason);
   transformStreamDefaultControllerClearAlgorithms(controller);

   PromisePrototypeThen(
@@ -749,7 +756,9 @@ function transformStreamDefaultSinkCloseAlgorithm(writableController) {
   }
   const { promise, resolve, reject } = PromiseWithResolvers();
   controller[kState].finishPromise = promise;
-  const flushPromise = controller[kState].flushAlgorithm(controller);
+  const flushPromise = promiseCall1Param(
+    controller[kState].flushAlgorithm, controller[kState].transformer,
+    controller);
   transformStreamDefaultControllerClearAlgorithms(controller);
   PromisePrototypeThen(
     flushPromise,
@@ -796,7 +805,9 @@ function transformStreamDefaultSourceCancelAlgorithm(

   const { promise, resolve, reject } = PromiseWithResolvers();
   controller[kState].finishPromise = promise;
-  const cancelPromise = controller[kState].cancelAlgorithm(reason);
+  const cancelPromise = promiseCall1Param(
+    controller[kState].cancelAlgorithm, controller[kState].transformer,
+    reason);
   transformStreamDefaultControllerClearAlgorithms(controller);

   PromisePrototypeThen(
diff --git a/lib/internal/webstreams/util.js b/lib/internal/webstreams/util.js
index bc6818c11ac..2d9ba0ae403 100644
--- a/lib/internal/webstreams/util.js
+++ b/lib/internal/webstreams/util.js
@@ -323,20 +323,14 @@ function enqueueValueWithSize(controller, value, size) {
   state.queueTotalSize += coercedSize;
 }

-// Arity-specialized variants of the promise-callback wrapper. The generic
-// rest-parameter + ReflectApply form allocated an arguments array on every
-// invocation; these run on per-chunk hot paths (pull/write/transform), so
-// each known call-site arity gets its own wrapper. The exact number of
-// arguments passed through to the user callback is observable and must be
-// preserved.
-function createPromiseCallbackNoParams(name, fn, thisArg) {
-  validateFunction(fn, name);
-  return async () => FunctionPrototypeCall(fn, thisArg);
-}
-
-// Raw variants that skip the async wrapper's implicit result promise.
-// Consumers of a raw callback invoke it inside try/catch and route the
-// result through thenAlgorithmResult() below.
+// The per-chunk callbacks of a user-provided source, sink or transformer
+// (pull, write, transform) are wrapped in a closure whose captured method
+// stays a constant for the optimizing compiler. Each known call-site
+// arity gets its own wrapper, since a rest-parameter form would allocate
+// an arguments array per invocation and the exact number of arguments
+// passed to the user callback is observable. Consumers of a raw callback
+// invoke it inside try/catch and route the result through
+// thenAlgorithmResult() below.
 function createRawCallback1Param(name, fn, thisArg) {
   validateFunction(fn, name);
   return (arg) => FunctionPrototypeCall(fn, thisArg, arg);
@@ -347,6 +341,46 @@ function createRawCallback2Params(name, fn, thisArg) {
   return (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
 }

+// Async wrapper of a one-shot callback (the source cancel method).
+function createPromiseCallback1Param(name, fn, thisArg) {
+  validateFunction(fn, name);
+  return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
+}
+
+// The one-shot methods of a sink or transformer (close, abort, flush,
+// cancel) are instead stored as-is in the controller state next to the
+// object they belong to and called the way the reference implementation's
+// promiseCall() does: a synchronous throw becomes a rejection and the
+// result is adopted through a fresh promise, so a returned promise settles
+// the caller two microtasks later and a plain value settles it at once.
+function promiseCall1Param(fn, thisArg, arg) {
+  let result;
+  try {
+    result = FunctionPrototypeCall(fn, thisArg, arg);
+  } catch (error) {
+    return PromiseReject(error);
+  }
+  return promiseResolvedWith(result);
+}
+
+function promiseCallNoParams(fn, thisArg) {
+  let result;
+  try {
+    result = FunctionPrototypeCall(fn, thisArg);
+  } catch (error) {
+    return PromiseReject(error);
+  }
+  return promiseResolvedWith(result);
+}
+
+function promiseResolvedWith(value) {
+  if (value === null ||
+      (typeof value !== 'object' && typeof value !== 'function')) {
+    return PromiseResolve(value);
+  }
+  return new Promise((resolve) => resolve(value));
+}
+
 // A single shared, forever-resolved promise used to enqueue a reaction at
 // the next microtask checkpoint without allocating a fresh promise.
 const kResolvedPromise = PromiseResolve();
@@ -385,16 +419,6 @@ function thenAlgorithmResult(result, onFulfilled, onRejected) {
   }
 }

-function createPromiseCallback1Param(name, fn, thisArg) {
-  validateFunction(fn, name);
-  return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
-}
-
-function createPromiseCallback2Params(name, fn, thisArg) {
-  validateFunction(fn, name);
-  return async (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
-}
-
 function isPromisePending(promise) {
   if (promise === undefined) return false;
   const details = getPromiseDetails(promise);
@@ -438,12 +462,11 @@ function setPromiseHandled(promise) {
   PromisePrototypeThen(promise, undefined, () => {});
 }

-async function nonOpFlush() {}
-
 // Shared non-op for the start/pull/write algorithm callbacks, which all
-// follow the raw-callback contract (see createRawCallback*): the
+// follow the raw-callback contract (see createRawCallback*), and for the
+// missing one-shot methods of a user-provided sink or transformer: the
 // non-thenable return takes the allocation-free fast path in
-// thenAlgorithmResult().
+// thenAlgorithmResult() and settles promiseCall*() at once.
 function nonOpCallback() {}

 async function nonOpCancel() {}
@@ -463,9 +486,7 @@ module.exports = {
   canCopyArrayBuffer,
   cloneAsUint8Array,
   copyArrayBuffer,
-  createPromiseCallbackNoParams,
   createPromiseCallback1Param,
-  createPromiseCallback2Params,
   createRawCallback1Param,
   createRawCallback2Params,
   customInspect,
@@ -489,9 +510,9 @@ module.exports = {
   materializeQueue,
   nonOpCallback,
   nonOpCancel,
-  nonOpFlush,
-
   peekQueueValue,
+  promiseCall1Param,
+  promiseCallNoParams,
   rejectedHandledRecord,
   resetQueue,
   resolvedRecord,
diff --git a/lib/internal/webstreams/writablestream.js b/lib/internal/webstreams/writablestream.js
index d790264d000..f332fd19fd0 100644
--- a/lib/internal/webstreams/writablestream.js
+++ b/lib/internal/webstreams/writablestream.js
@@ -36,6 +36,7 @@ const {
 } = require('internal/util');

 const {
+  validateFunction,
   validateObject,
   kValidateObjectAllowObjects,
   kValidateObjectAllowObjectsAndNull,
@@ -54,8 +55,6 @@ const {

 const {
   Queue,
-  createPromiseCallbackNoParams,
-  createPromiseCallback1Param,
   createRawCallback2Params,
   customInspect,
   defaultSizeAlgorithm,
@@ -72,8 +71,9 @@ const {
   kType,
   lazyTransfer,
   nonOpCallback,
-  nonOpCancel,
   peekQueueValue,
+  promiseCall1Param,
+  promiseCallNoParams,
   rejectedHandledRecord,
   resetQueue,
   resolvedRecord,
@@ -512,7 +512,10 @@ class WritableStreamDefaultController {
   }

   [kAbort](reason) {
-    const result = this[kState].abortAlgorithm(reason, this);
+    const { abortAlgorithm, underlyingSink } = this[kState];
+    const result = underlyingSink === undefined ?
+      abortAlgorithm(reason, this) :
+      promiseCall1Param(abortAlgorithm, underlyingSink, reason);
     writableStreamDefaultControllerClearAlgorithms(this);
     return result;
   }
@@ -565,6 +568,7 @@ function createWritableStream(start, write, close, abort, highWaterMark = 1, siz
     abort,
     highWaterMark,
     size,
+    undefined,
   );
   return stream;
 }
@@ -1238,11 +1242,14 @@ function writableStreamDefaultControllerProcessClose(controller) {
     closeAlgorithm,
     queue,
     stream,
+    underlyingSink,
   } = controller[kState];
   writableStreamMarkCloseRequestInFlight(stream);
   dequeueValue(controller);
   assert(!queue.length);
-  const sinkClosePromise = closeAlgorithm(controller);
+  const sinkClosePromise = underlyingSink === undefined ?
+    closeAlgorithm(controller) :
+    promiseCallNoParams(closeAlgorithm, underlyingSink);
   writableStreamDefaultControllerClearAlgorithms(controller);
   PromisePrototypeThen(
     sinkClosePromise,
@@ -1315,6 +1322,7 @@ function writableStreamDefaultControllerClearAlgorithms(controller) {
   controller[kState].closeAlgorithm = undefined;
   controller[kState].abortAlgorithm = undefined;
   controller[kState].sizeAlgorithm = undefined;
+  controller[kState].underlyingSink = undefined;
 }

 function writableStreamDefaultControllerAdvanceQueueIfNeeded(controller) {
@@ -1357,21 +1365,20 @@ function setupWritableStreamDefaultControllerFromSink(
   const writeAlgorithm = write ?
     createRawCallback2Params('sink.write', write, sink) :
     nonOpCallback;
-  const closeAlgorithm = close ?
-    createPromiseCallbackNoParams('sink.close', close, sink) :
-    nonOpCancel;
-  const abortAlgorithm = abort ?
-    createPromiseCallback1Param('sink.abort', abort, sink) :
-    nonOpCancel;
+  if (close)
+    validateFunction(close, 'sink.close');
+  if (abort)
+    validateFunction(abort, 'sink.abort');
   setupWritableStreamDefaultController(
     stream,
     controller,
     startAlgorithm,
     writeAlgorithm,
-    closeAlgorithm,
-    abortAlgorithm,
+    close || nonOpCallback,
+    abort || nonOpCallback,
     highWaterMark,
-    sizeAlgorithm);
+    sizeAlgorithm,
+    sink ?? null);
 }

 function setupWritableStreamDefaultController(
@@ -1382,7 +1389,8 @@ function setupWritableStreamDefaultController(
   closeAlgorithm,
   abortAlgorithm,
   highWaterMark,
-  sizeAlgorithm) {
+  sizeAlgorithm,
+  underlyingSink) {
   assert(isWritableStream(stream));
   assert(stream[kState].controller === undefined);
   controller[kState] = {
@@ -1398,6 +1406,9 @@ function setupWritableStreamDefaultController(
     // The TransformStream this controller is the sink of, if any; its
     // shared sink algorithms reach the stream through it.
     transformStream: undefined,
+    // The object the close and abort algorithms are methods of; see
+    // setupReadableStreamDefaultController.
+    underlyingSink,
     writeAlgorithm,
     writeFulfilled: undefined,
     writeRejected: undefined,
diff --git a/test/parallel/test-whatwg-webstreams-underlying-methods.js b/test/parallel/test-whatwg-webstreams-underlying-methods.js
new file mode 100644
index 00000000000..1fb437fec05
--- /dev/null
+++ b/test/parallel/test-whatwg-webstreams-underlying-methods.js
@@ -0,0 +1,137 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const {
+  ReadableStream,
+  WritableStream,
+  TransformStream,
+} = require('stream/web');
+
+// Underlying source, sink and transformer methods are read once at
+// construction, called as methods of the object they were read from with
+// exactly the spec's arguments, and validated up front.
+
+{
+  const source = {
+    start: common.mustCall(function(...args) {
+      assert.strictEqual(this, source);
+      assert.strictEqual(args.length, 1);
+    }),
+    pull: common.mustCall(function(...args) {
+      assert.strictEqual(this, source);
+      assert.strictEqual(args.length, 1);
+      args[0].enqueue('a');
+    }),
+    cancel: common.mustCall(function(...args) {
+      assert.strictEqual(this, source);
+      assert.deepStrictEqual(args, ['why']);
+    }),
+  };
+  const rs = new ReadableStream(source);
+  source.pull = common.mustNotCall();
+  source.cancel = common.mustNotCall();
+  const reader = rs.getReader();
+  (async () => {
+    const { value } = await reader.read();
+    assert.strictEqual(value, 'a');
+    await reader.cancel('why');
+  })().then(common.mustCall());
+}
+
+{
+  const sink = {
+    start: common.mustCall(function(...args) {
+      assert.strictEqual(this, sink);
+      assert.strictEqual(args.length, 1);
+    }),
+    write: common.mustCall(function(...args) {
+      assert.strictEqual(this, sink);
+      assert.strictEqual(args.length, 2);
+      assert.strictEqual(args[0], 'a');
+    }),
+    close: common.mustCall(function(...args) {
+      assert.strictEqual(this, sink);
+      assert.strictEqual(args.length, 0);
+    }),
+  };
+  const ws = new WritableStream(sink);
+  sink.write = common.mustNotCall();
+  sink.close = common.mustNotCall();
+  const writer = ws.getWriter();
+  (async () => {
+    await writer.write('a');
+    await writer.close();
+  })().then(common.mustCall());
+}
+
+{
+  const sink = {
+    abort: common.mustCall(function(...args) {
+      assert.strictEqual(this, sink);
+      assert.deepStrictEqual(args, ['why']);
+    }),
+  };
+  const ws = new WritableStream(sink);
+  sink.abort = common.mustNotCall();
+  ws.abort('why').then(common.mustCall());
+}
+
+{
+  const transformer = {
+    transform: common.mustCall(function(...args) {
+      assert.strictEqual(this, transformer);
+      assert.strictEqual(args.length, 2);
+      args[1].enqueue(args[0]);
+    }),
+    flush: common.mustCall(function(...args) {
+      assert.strictEqual(this, transformer);
+      assert.strictEqual(args.length, 1);
+    }),
+  };
+  const ts = new TransformStream(transformer);
+  transformer.transform = common.mustNotCall();
+  transformer.flush = common.mustNotCall();
+  const writer = ts.writable.getWriter();
+  const reader = ts.readable.getReader();
+  writer.write('a');
+  writer.close();
+  (async () => {
+    const { value } = await reader.read();
+    assert.strictEqual(value, 'a');
+    const { done } = await reader.read();
+    assert.strictEqual(done, true);
+  })().then(common.mustCall());
+}
+
+{
+  const transformer = {
+    cancel: common.mustCall(function(...args) {
+      assert.strictEqual(this, transformer);
+      assert.deepStrictEqual(args, ['why']);
+    }),
+  };
+  const ts = new TransformStream(transformer);
+  transformer.cancel = common.mustNotCall();
+  ts.readable.cancel('why').then(common.mustCall());
+}
+
+// A method that is present but not callable is rejected by the constructor.
+for (const key of ['pull', 'cancel']) {
+  assert.throws(() => new ReadableStream({ [key]: 1 }), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+  assert.throws(() => new ReadableStream({ type: 'bytes', [key]: 1 }), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+}
+for (const key of ['write', 'close', 'abort']) {
+  assert.throws(() => new WritableStream({ [key]: 1 }), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+}
+for (const key of ['transform', 'flush', 'cancel']) {
+  assert.throws(() => new TransformStream({ [key]: 1 }), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+}