nodejs/node · #66392

stream: share webstreams async iterator methods

mcollina · merged Oct 5, 20264 files · 239 + / 158 −
benchmark/webstreams/lifecycle.js16 + / 1 −
@@ -9,7 +9,7 @@ const {  const bench = common.createBenchmark(main, {   n: [5e4],-  kind: ['readable', 'pipe-to', 'pipe-through'],+  kind: ['readable', 'async-iterator', 'pipe-to', 'pipe-through'], });  const chunk = Buffer.alloc(1024);@@ -37,6 +37,18 @@ async function readable(n) {   assert.strictEqual(chunks, n * 4); } +async function asyncIterator(n) {+  let chunks = 0;+  bench.start();+  for (let i = 0; i < n; i++) {+    for await (const chunk of new ReadableStream(makeSource())) {+      if (chunk) chunks++;+    }+  }+  bench.end(n);+  assert.strictEqual(chunks, n * 4);+}+ async function pipeTo(n) {   let chunks = 0;   bench.start();@@ -66,6 +78,9 @@ function main({ n, kind }) {     case 'readable':       readable(n);       break;+    case 'async-iterator':+      asyncIterator(n);+      break;     case 'pipe-to':       pipeTo(n);       break;
lib/internal/webstreams/readablestream.js175 + / 149 −
@@ -7,6 +7,7 @@ const {   ArrayBufferPrototypeSlice,   ArrayBufferPrototypeTransfer,   ArrayPrototypePush,+  AsyncIteratorPrototype,   DataView,   FunctionPrototypeBind,   FunctionPrototypeCall,@@ -96,7 +97,6 @@ const {   ArrayBufferViewGetBuffer,   ArrayBufferViewGetByteLength,   ArrayBufferViewGetByteOffset,-  AsyncIterator,   Queue,   canCopyArrayBuffer,   cloneAsUint8Array,@@ -502,147 +502,10 @@ class ReadableStream {      // eslint-disable-next-line no-use-before-define     const reader = new ReadableStreamDefaultReader(this);--    // No __proto__ here to avoid the performance hit.-    const state = {-      done: false,-      current: undefined,-    };-    let started = false;-    // A single reusable read request: at most one read is ever in flight-    // (next() chains through state.current), and the request is consumed-    // before the next read starts, so only its promise record changes-    // per read.     // eslint-disable-next-line no-use-before-define-    const readRequest = new ReadableStreamAsyncIteratorReadRequest(reader, state, undefined);--    // The nextSteps function is not an async function in order-    // to make it more efficient. Because nextSteps explicitly-    // creates a Promise and returns it in the common case,-    // making it an async function just causes two additional-    // unnecessary Promise allocations to occur, which just add-    // cost.-    function nextSteps() {-      if (state.done)-        return PromiseResolve({ done: true, value: undefined });--      if (reader[kState].stream === undefined) {-        return PromiseReject(-          new ERR_INVALID_STATE.TypeError(-            'The reader is not bound to a ReadableStream'));-      }-      const promise = PromiseWithResolvers();--      readRequest.promise = promise;-      readableStreamDefaultReaderRead(reader, readRequest);-      return promise.promise;-    }--    async function returnSteps(value) {-      if (state.done)-        return { done: true, value }; // eslint-disable-line node-core/avoid-prototype-pollution-      state.done = true;--      if (reader[kState].stream === undefined) {-        throw new ERR_INVALID_STATE.TypeError(-          'The reader is not bound to a ReadableStream');-      }-      assert(!reader[kState].readRequests.length);-      if (!preventCancel) {-        const result = readableStreamReaderGenericCancel(reader, value);-        readableStreamReaderGenericRelease(reader);-        await result;-        return { done: true, value }; // eslint-disable-line node-core/avoid-prototype-pollution-      }--      readableStreamReaderGenericRelease(reader);-      return { done: true, value }; // eslint-disable-line node-core/avoid-prototype-pollution-    }--    // TODO(@jasnell): Explore whether an async generator-    // can be used here instead of a custom iterator object.-    return ObjectSetPrototypeOf({-      // Changing either of these functions (next or return)-      // to async functions causes a failure in the streams-      // Web Platform Tests that check for use of a modified-      // Promise.prototype.then. Since the await keyword-      // uses Promise.prototype.then, it is open to prototype-      // pollution, which causes the test to fail. The other-      // await uses here do not trigger that failure because-      // the test that fails does not trigger those code paths.-      next() {-        // If this is the first read, delay by one microtask-        // to ensure that the controller has had an opportunity-        // to properly start and perform the initial pull.-        // TODO(@jasnell): The spec doesn't call this out so-        // need to investigate if it's a bug in our impl or-        // the spec.-        if (!started) {-          state.current = PromiseResolve();-          started = true;-        }-        if (state.current !== undefined) {-          state.current =-            PromisePrototypeThen(state.current, nextSteps, nextSteps);-          return state.current;-        }-        // No read is in flight. Mirror the buffered fast path of-        // ReadableStreamDefaultReader.read(): when data is already queued-        // in the controller, resolve immediately without allocating a-        // read request. The result settles synchronously, so leaving-        // state.current undefined matches the state the slow path reaches-        // once its read request callbacks have settled.-        const stream = reader[kState].stream;-        if (!state.done && stream !== undefined &&-            stream[kState].state === 'readable') {-          const controller = stream[kState].controller;-          if (isReadableStreamDefaultController(controller)) {-            if (controller[kState].queue.length > 0) {-              stream[kState].disturbed = true;-              const chunk = dequeueValue(controller);--              if (controller[kState].closeRequested &&-                  !controller[kState].queue.length) {-                readableStreamDefaultControllerClearAlgorithms(controller);-                readableStreamClose(stream);-              } else if (!controller[kState].closeRequested &&-                         controller[kState].started &&-                         controller[kState].highWaterMark --                           controller[kState].queueTotalSize > 0) {-                // Reduced ShouldCallPull, as in the read() fast path.-                readableStreamDefaultControllerPull(controller);-              }--              return PromiseResolve({ done: false, value: chunk });-            }-          } else if (controller[kState].queueTotalSize > 0) {-            // Byte controller with buffered data: same shape as above via-            // the queue-filled arm of the byte controller's pull steps.-            stream[kState].disturbed = true;-            return PromiseResolve({-              done: false,--              value: readableByteStreamControllerDequeueChunk(controller),-            });-          }-        }-        state.current = nextSteps();-        return state.current;-      },--      return(error) {-        started = true;-        state.current = state.current !== undefined ?-          PromisePrototypeThen(-            state.current,-            () => returnSteps(error),-            () => returnSteps(error)) :-          returnSteps(error);-        return state.current;-      },--      [SymbolAsyncIterator]() { return this; },-    }, AsyncIterator);+    const state = new ReadableStreamAsyncIteratorReadRequest(reader, preventCancel);+    // eslint-disable-next-line no-use-before-define+    return new ReadableStreamAsyncIterator(state);   }    [kInspect](depth, options) {@@ -829,33 +692,196 @@ function createReadableStreamBYOBRequest(controller, view) {   return stream; } +// Per-iterator state. It doubles as the iterator's read request: at most+// one read is ever in flight (next() chains through `current`), and the+// request is consumed before the next read starts, so only its promise+// record changes per read. class ReadableStreamAsyncIteratorReadRequest {-  constructor(reader, state, promise) {+  constructor(reader, preventCancel) {     this.reader = reader;-    this.state = state;-    this.promise = promise;+    this.preventCancel = preventCancel;+    this.done = false;+    this.started = false;+    this.current = undefined;+    this.promise = undefined;+    this.chainedNextSteps = undefined;   }    [kChunk](chunk) {-    this.state.current = undefined;+    this.current = undefined;     this.promise.resolve({ done: false, value: chunk });   }    [kClose]() {-    this.state.current = undefined;-    this.state.done = true;+    this.current = undefined;+    this.done = true;     readableStreamReaderGenericRelease(this.reader);     this.promise.resolve({ done: true, value: undefined });   }    [kError](error) {-    this.state.current = undefined;-    this.state.done = true;+    this.current = undefined;+    this.done = true;     readableStreamReaderGenericRelease(this.reader);     this.promise.reject(error);   } } +// next() is not an async function: it explicitly creates and returns a+// promise in the common case, so an async function would only add two+// promise allocations.+function readableStreamAsyncIteratorNextSteps(state) {+  if (state.done)+    return PromiseResolve({ done: true, value: undefined });++  const reader = state.reader;+  if (reader[kState].stream === undefined) {+    return PromiseReject(+      new ERR_INVALID_STATE.TypeError(+        'The reader is not bound to a ReadableStream'));+  }+  const promise = PromiseWithResolvers();++  state.promise = promise;+  readableStreamDefaultReaderRead(reader, state);+  return promise.promise;+}++// Not an async function either: the cancel path settles one microtask+// after the cancel promise, exactly as `await` would.+function readableStreamAsyncIteratorReturnSteps(state, value) {+  const iterResult = { done: true, value };+  if (state.done)+    return PromiseResolve(iterResult);+  state.done = true;++  try {+    const reader = state.reader;+    if (reader[kState].stream === undefined) {+      throw new ERR_INVALID_STATE.TypeError(+        'The reader is not bound to a ReadableStream');+    }+    assert(!reader[kState].readRequests.length);+    if (!state.preventCancel) {+      const result = readableStreamReaderGenericCancel(reader, value);+      readableStreamReaderGenericRelease(reader);+      return PromisePrototypeThen(result, () => iterResult);+    }++    readableStreamReaderGenericRelease(reader);+    return PromiseResolve(iterResult);+  } catch (error) {+    return PromiseReject(error);+  }+}++// The methods live on a shared prototype, as for any WebIDL async iterator,+// rather than being created per iterator. Neither method may be an async+// function: `await` goes through Promise.prototype.then, which the streams+// WPTs patch.+class ReadableStreamAsyncIterator {+  #state;++  constructor(state) {+    this.#state = state;+  }++  next() {+    if (typeof this !== 'object' || this === null || !(#state in this)) {+      return PromiseReject(+        new ERR_INVALID_THIS('ReadableStreamAsyncIterator'));+    }+    const state = this.#state;+    // If this is the first read, delay by one microtask+    // to ensure that the controller has had an opportunity+    // to properly start and perform the initial pull.+    // TODO(@jasnell): The spec doesn't call this out so+    // need to investigate if it's a bug in our impl or+    // the spec.+    if (!state.started) {+      state.current = kResolvedPromise;+      state.started = true;+    }+    if (state.current !== undefined) {+      let steps = state.chainedNextSteps;+      if (steps === undefined) {+        steps = state.chainedNextSteps =+          () => readableStreamAsyncIteratorNextSteps(state);+      }+      state.current = PromisePrototypeThen(state.current, steps, steps);+      return state.current;+    }+    // No read is in flight. Mirror the buffered fast path of+    // ReadableStreamDefaultReader.read(): when data is already queued+    // in the controller, resolve immediately without allocating a+    // read request. The result settles synchronously, so leaving+    // state.current undefined matches the state the slow path reaches+    // once its read request callbacks have settled.+    const stream = state.reader[kState].stream;+    if (!state.done && stream !== undefined &&+        stream[kState].state === 'readable') {+      const controller = stream[kState].controller;+      if (isReadableStreamDefaultController(controller)) {+        if (controller[kState].queue.length > 0) {+          stream[kState].disturbed = true;+          const chunk = dequeueValue(controller);++          if (controller[kState].closeRequested &&+              !controller[kState].queue.length) {+            readableStreamDefaultControllerClearAlgorithms(controller);+            readableStreamClose(stream);+          } else if (!controller[kState].closeRequested &&+                     controller[kState].started &&+                     controller[kState].highWaterMark -+                       controller[kState].queueTotalSize > 0) {+            // Reduced ShouldCallPull, as in the read() fast path.+            readableStreamDefaultControllerPull(controller);+          }++          return PromiseResolve({ done: false, value: chunk });+        }+      } else if (controller[kState].queueTotalSize > 0) {+        // Byte controller with buffered data: same shape as above via+        // the queue-filled arm of the byte controller's pull steps.+        stream[kState].disturbed = true;+        return PromiseResolve({+          done: false,++          value: readableByteStreamControllerDequeueChunk(controller),+        });+      }+    }+    state.current = readableStreamAsyncIteratorNextSteps(state);+    return state.current;+  }++  return(value) {+    if (typeof this !== 'object' || this === null || !(#state in this)) {+      return PromiseReject(+        new ERR_INVALID_THIS('ReadableStreamAsyncIterator'));+    }+    const state = this.#state;+    state.started = true;+    state.current = state.current !== undefined ?+      PromisePrototypeThen(+        state.current,+        () => readableStreamAsyncIteratorReturnSteps(state, value),+        () => readableStreamAsyncIteratorReturnSteps(state, value)) :+      readableStreamAsyncIteratorReturnSteps(state, value);+    return state.current;+  }+}++delete ReadableStreamAsyncIterator.prototype.constructor;+ObjectSetPrototypeOf(ReadableStreamAsyncIterator.prototype,+                     AsyncIteratorPrototype);+ObjectDefineProperties(ReadableStreamAsyncIterator.prototype, {+  next: kEnumerableProperty,+  return: kEnumerableProperty,+  [SymbolToStringTag]:+    getNonWritablePropertyDescriptor('ReadableStream AsyncIterator'),+});+ class DefaultReadRequest {   constructor() {     this[kState] = PromiseWithResolvers();
lib/internal/webstreams/util.js0 + / 8 −
@@ -5,7 +5,6 @@ const {   ArrayBufferPrototypeGetByteLength,   ArrayBufferPrototypeGetDetached,   ArrayBufferPrototypeSlice,-  AsyncIteratorPrototype,   DataViewPrototypeGetBuffer,   DataViewPrototypeGetByteLength,   DataViewPrototypeGetByteOffset,@@ -57,12 +56,6 @@ const { const kState = Symbol('kState'); const kType = Symbol('kType'); -const AsyncIterator = {-  __proto__: AsyncIteratorPrototype,-  next: undefined,-  return: undefined,-};- const getNonWritablePropertyDescriptor = (value) => {   return {     __proto__: null,@@ -447,7 +440,6 @@ module.exports = {   ArrayBufferViewGetBuffer,   ArrayBufferViewGetByteLength,   ArrayBufferViewGetByteOffset,-  AsyncIterator,   Queue,   canCopyArrayBuffer,   cloneAsUint8Array,
test/parallel/test-whatwg-readablestream-async-iterator-shape.jsadded48 + / 0 −
@@ -0,0 +1,48 @@+'use strict';++const common = require('../common');+const assert = require('assert');++// The async iterator methods live on a shared prototype, as for any WebIDL+// async iterator, and check their receiver.++const a = new ReadableStream().values();+const b = new ReadableStream()[Symbol.asyncIterator]();+const proto = Object.getPrototypeOf(a);++assert.strictEqual(Object.getPrototypeOf(b), proto);+assert.deepStrictEqual(Reflect.ownKeys(a), []);+assert.deepStrictEqual(Reflect.ownKeys(proto),+                       ['next', 'return', Symbol.toStringTag]);+assert.strictEqual(Object.prototype.toString.call(a),+                   '[object ReadableStream AsyncIterator]');+assert.deepStrictEqual(+  Object.getOwnPropertyDescriptor(proto, Symbol.toStringTag),+  {+    value: 'ReadableStream AsyncIterator',+    writable: false,+    enumerable: false,+    configurable: true,+  });+assert.strictEqual(a[Symbol.asyncIterator](), a);++for (const method of ['next', 'return']) {+  for (const receiver of [undefined, null, 1, {}, new ReadableStream()]) {+    assert.rejects(proto[method].call(receiver), {+      code: 'ERR_INVALID_THIS',+    }).then(common.mustCall());+  }+}++(async () => {+  const rs = new ReadableStream({+    start(c) {+      c.enqueue(1);+      c.enqueue(2);+      c.close();+    },+  });+  const chunks = [];+  for await (const chunk of rs) chunks.push(chunk);+  assert.deepStrictEqual(chunks, [1, 2]);+})().then(common.mustCall());