Commit ee7462aa37b for nodejs

commit ee7462aa37b7fd00e5812349ba6bc6484c002d55
Author: James M Snell <jasnell@gmail.com>
Date:   Sat Oct 3 14:57:51 2026 +0000

    stream: align pull() abort handling with the spec

    pull() threw synchronously when given an already-aborted signal. The
    spec (Iterable Streams, Stream.pull() step 5) requires it to return an
    iterable that throws the abort reason when read. This also matches how
    broadcast.push() and share.pull() already handle a pre-aborted signal.

    Once the signal aborted, the pull that observed the abort rejected,
    but later pulls resolved { done: true } because the pipeline is an
    async generator, which completes after throwing. The spec (step 7)
    requires future pulls to reject with the abort reason as well, so
    that a stream that was cancelled is never reported as having ended
    cleanly.

    Return an iterator that rejects every read with the abort reason once
    the signal has aborted the pipeline, without starting the pipeline if
    the signal was already aborted. Pipelines without a signal are not
    affected. The two tests that asserted the synchronous throw now check
    the rejection instead, and the documentation is updated.

    Assisted-by: OpenCode
    Signed-off-by: James M Snell <jasnell@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/66483
    Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>

diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md
index eeff3ed0d41..ad52645bfcb 100644
--- a/doc/api/stream_iter.md
+++ b/doc/api/stream_iter.md
@@ -723,8 +723,12 @@ added:

 Create a lazy async pipeline. Source conversion and streamable protocol
 dispatch occur when `pull()` is called, but data is not read from `source`
-until the returned iterable is consumed. A signal that is already aborted is
-thrown synchronously after source conversion. Transforms are applied in order.
+until the returned iterable is consumed. Transforms are applied in order.
+
+When `signal` aborts, the pending read (or the next one) rejects with
+`signal.reason`, and so does every later read. If `signal` is already aborted,
+`pull()` still returns an iterable; reading from it rejects with
+`signal.reason` without reading from `source`.

 ```mjs
 import { from, pull, text } from 'node:stream/iter';
diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js
index 70354edb10b..792e8d33e9c 100644
--- a/lib/internal/streams/iter/pull.js
+++ b/lib/internal/streams/iter/pull.js
@@ -12,6 +12,7 @@ const {
   ArrayPrototypeSlice,
   FunctionPrototypeCall,
   PromisePrototypeThen,
+  PromiseReject,
   PromiseResolve,
   SymbolAsyncIterator,
   SymbolIterator,
@@ -850,37 +851,83 @@ function pull(source, ...args) {
   });
   const { signal } = options;
   const normalized = from(source);
-  signal?.throwIfAborted();

   return {
     __proto__: null,
     [SymbolAsyncIterator]() {
-      const controller = new AbortController();
-      const iteratorSignal = signal === undefined ?
-        controller.signal : AbortSignal.any([signal, controller.signal]);
-
-      async function* pipeline() {
-        yield* createAsyncPipeline(normalized, transforms, iteratorSignal);
+      if (signal === undefined) {
+        const controller = new AbortController();
+        async function* pipeline() {
+          yield* createAsyncPipeline(normalized, transforms, controller.signal);
+        }
+        const iterator = pipeline();
+        return {
+          __proto__: null,
+          next(value) {
+            return iterator.next(value);
+          },
+          return(value) {
+            controller.abort(lazyDOMException('Aborted', 'AbortError'));
+            return iterator.return(value);
+          },
+          throw(error) {
+            abortSignal(controller.signal, error);
+            return iterator.throw(error);
+          },
+          [SymbolAsyncIterator]() {
+            return this;
+          },
+        };
       }
-      const iterator = pipeline();
+      return createAbortablePullIterator(normalized, transforms, signal);
+    },
+  };
+}

-      return {
-        __proto__: null,
-        next(value) {
-          return iterator.next(value);
-        },
-        return(value) {
-          controller.abort(lazyDOMException('Aborted', 'AbortError'));
-          return iterator.return(value);
-        },
-        throw(error) {
-          abortSignal(controller.signal, error);
-          return iterator.throw(error);
-        },
-        [SymbolAsyncIterator]() {
-          return this;
-        },
-      };
+// Once `signal` aborts the pipeline, the pull that observed it rejects and
+// so does every later pull, with the abort reason. That includes the case of
+// an already-aborted signal, where the pipeline is never started. A plain
+// async generator would instead complete after throwing, so later pulls
+// would report a clean end of the stream.
+function createAbortablePullIterator(source, transforms, signal) {
+  let aborted = signal.aborted;
+  let controller;
+  let iterator;
+  if (!aborted) {
+    controller = new AbortController();
+    const iteratorSignal = AbortSignal.any([signal, controller.signal]);
+    async function* pipeline() {
+      yield* createAsyncPipeline(source, transforms, iteratorSignal);
+    }
+    iterator = pipeline();
+  }
+
+  function onRejected(error) {
+    if (signal.aborted) aborted = true;
+    throw error;
+  }
+
+  return {
+    __proto__: null,
+    next(value) {
+      if (aborted) return PromiseReject(signal.reason);
+      return PromisePrototypeThen(iterator.next(value), undefined, onRejected);
+    },
+    return(value) {
+      if (aborted) {
+        return PromiseResolve({ __proto__: null, done: true, value });
+      }
+      controller.abort(lazyDOMException('Aborted', 'AbortError'));
+      return iterator.return(value);
+    },
+    throw(error) {
+      if (aborted) return PromiseReject(error);
+      abortSignal(controller.signal, error);
+      return PromisePrototypeThen(iterator.throw(error), undefined,
+                                  onRejected);
+    },
+    [SymbolAsyncIterator]() {
+      return this;
     },
   };
 }
diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js
index 1a5d1eb2873..632306fa589 100644
--- a/test/parallel/test-stream-iter-pull-async.js
+++ b/test/parallel/test-stream-iter-pull-async.js
@@ -66,14 +66,72 @@ async function testPullStatefulTransformReceiver() {
 }

 async function testPullWithAbortSignal() {
+  let started = false;
   async function* gen() {
+    started = true;
     yield [new Uint8Array([1])];
   }

-  assert.throws(
-    () => pull(gen(), { signal: AbortSignal.abort() }),
-    { name: 'AbortError' },
-  );
+  // An already-aborted signal does not make pull() throw; the returned
+  // iterable rejects instead, without reading from the source.
+  const signal = AbortSignal.abort();
+  const iterator = pull(gen(), { signal })[Symbol.asyncIterator]();
+  await assert.rejects(iterator.next(), (error) => error === signal.reason);
+  await assert.rejects(iterator.next(), (error) => error === signal.reason);
+  assert.strictEqual(started, false);
+  assert.deepStrictEqual(await iterator.return(),
+                         { __proto__: null, done: true, value: undefined });
+
+  await assert.rejects(text(pull(gen(), (chunks) => chunks, { signal })),
+                       (error) => error === signal.reason);
+  assert.strictEqual(started, false);
+}
+
+async function testPullKeepsRejectingAfterAbort() {
+  for (const transforms of [[], [(chunks) => chunks]]) {
+    // Abort while a read is pending.
+    {
+      const ac = new AbortController();
+      const reason = new Error('stop');
+      async function* gen() {
+        yield [new Uint8Array([1])];
+        await new Promise(() => {});
+      }
+      const iterator =
+        pull(gen(), ...transforms, { signal: ac.signal })[Symbol.asyncIterator]();
+      assert.strictEqual((await iterator.next()).done, false);
+      const pending = iterator.next();
+      ac.abort(reason);
+      await assert.rejects(pending, (error) => error === reason);
+      await assert.rejects(iterator.next(), (error) => error === reason);
+      await assert.rejects(iterator.next(), (error) => error === reason);
+    }
+    // Abort between reads.
+    {
+      const ac = new AbortController();
+      const reason = new Error('stop');
+      async function* gen() {
+        yield [new Uint8Array([1])];
+        yield [new Uint8Array([2])];
+      }
+      const iterator =
+        pull(gen(), ...transforms, { signal: ac.signal })[Symbol.asyncIterator]();
+      assert.strictEqual((await iterator.next()).done, false);
+      ac.abort(reason);
+      await assert.rejects(iterator.next(), (error) => error === reason);
+      await assert.rejects(iterator.next(), (error) => error === reason);
+    }
+    // Aborting after the pipeline completed does not change the result.
+    {
+      const ac = new AbortController();
+      const iterator =
+        pull(from('x'), ...transforms, { signal: ac.signal })[Symbol.asyncIterator]();
+      assert.strictEqual((await iterator.next()).done, false);
+      assert.strictEqual((await iterator.next()).done, true);
+      ac.abort();
+      assert.strictEqual((await iterator.next()).done, true);
+    }
+  }
 }

 async function testPullNormalizesSourceAtCallTime() {
@@ -99,7 +157,7 @@ async function testPullNormalizesSourceAtCallTime() {
   assert.strictEqual(iteratorCalls, 1);
 }

-function testPullPreAbortOrdering() {
+async function testPullPreAbortOrdering() {
   const reason = new Error('already aborted');
   let protocolCalls = 0;
   const source = {
@@ -110,8 +168,11 @@ function testPullPreAbortOrdering() {
   };
   const signal = AbortSignal.abort(reason);

-  assert.throws(() => pull(source, { signal }), (error) => error === reason);
+  // Source conversion still happens when pull() is called; the abort is
+  // reported when the result is read.
+  const result = pull(source, { signal });
   assert.strictEqual(protocolCalls, 1);
+  await assert.rejects(text(result), (error) => error === reason);
   assert.throws(
     () => pull(null, { signal }),
     { code: 'ERR_INVALID_ARG_TYPE' },
@@ -521,6 +582,7 @@ async function testTransformOptionsNotShared() {
     testPullStatefulTransform(),
     testPullStatefulTransformReceiver(),
     testPullWithAbortSignal(),
+    testPullKeepsRejectingAfterAbort(),
     testPullNormalizesSourceAtCallTime(),
     testPullPreAbortOrdering(),
     testPullChainedTransforms(),