Commit 8fc98383720 for nodejs

commit 8fc983837203724c8e0f6caf67ee5a969d98a32c
Author: James M Snell <jasnell@gmail.com>
Date:   Sun Aug 30 18:14:45 2026 +0000

    stream: make classic reads wake on return/throw/abort

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

diff --git a/lib/internal/streams/iter/classic.js b/lib/internal/streams/iter/classic.js
index 6b5014291f3..ac6e6976657 100644
--- a/lib/internal/streams/iter/classic.js
+++ b/lib/internal/streams/iter/classic.js
@@ -186,8 +186,10 @@ async function normalizeBatch(raw) {
 // (Uint8Array subclass) and are yielded directly.
 const nop = () => {};

-async function* createBatchedAsyncIterator(stream, normalize) {
+function createBatchedAsyncIterator(stream, normalize) {
   let callback = nop;
+  let canceled = false;
+  let started = false;

   function next(resolve) {
     if (this === stream) {
@@ -198,54 +200,91 @@ async function* createBatchedAsyncIterator(stream, normalize) {
     }
   }

-  stream.on('readable', next);
-
-  let error;
-  const cleanup = eos(stream, { writable: false }, (err) => {
-    error = err ? aggregateTwoErrors(error, err) : null;
+  function wake() {
     callback();
     callback = nop;
-  });
+  }

-  try {
-    while (true) {
-      const chunk = stream.destroyed ? null : stream.read();
-      if (chunk !== null) {
-        const batch = [chunk];
-        while (batch.length < MAX_DRAIN_BATCH &&
-               stream._readableState?.length > 0) {
-          const c = stream.read();
-          if (c === null) break;
-          ArrayPrototypePush(batch, c);
-        }
-        if (normalize !== null) {
-          const result = await normalize(batch);
-          if (result !== null) {
-            yield result;
+  async function* generate() {
+    stream.on('readable', next);
+
+    let error;
+    const cleanup = eos(stream, { writable: false }, (err) => {
+      error = err ? aggregateTwoErrors(error, err) : null;
+      stream.removeListener('readable', next);
+      wake();
+    });
+    const cleanupAll = () => {
+      stream.removeListener('close', cleanupAll);
+      cleanup();
+    };
+    stream.on('close', cleanupAll);
+
+    try {
+      while (!canceled) {
+        const chunk = stream.destroyed ? null : stream.read();
+        if (chunk !== null) {
+          const batch = [chunk];
+          while (batch.length < MAX_DRAIN_BATCH &&
+                 stream._readableState?.length > 0) {
+            const c = stream.read();
+            if (c === null) break;
+            ArrayPrototypePush(batch, c);
+          }
+          if (normalize !== null) {
+            const result = await normalize(batch);
+            if (result !== null) {
+              yield result;
+            }
+          } else {
+            yield batch;
           }
+        } else if (error) {
+          throw error;
+        } else if (error === null) {
+          return;
         } else {
-          yield batch;
+          await new Promise(next);
         }
-      } else if (error) {
-        throw error;
-      } else if (error === null) {
-        return;
-      } else {
-        await new Promise(next);
       }
-    }
-  } catch (err) {
-    error = aggregateTwoErrors(error, err);
-    throw error;
-  } finally {
-    if (error === undefined ||
-        (stream._readableState?.autoDestroy)) {
-      destroyImpl.destroyer(stream, null);
-    } else {
+    } catch (err) {
+      error = aggregateTwoErrors(error, err);
+      throw error;
+    } finally {
       stream.removeListener('readable', next);
-      cleanup();
+      if (error === undefined ||
+          (stream._readableState?.autoDestroy)) {
+        destroyImpl.destroyer(stream, null);
+        if (stream._readableState?.closeEmitted) cleanupAll();
+      } else {
+        cleanupAll();
+      }
     }
   }
+
+  const iterator = generate();
+  const iteratorNext = iterator.next;
+  const iteratorReturn = iterator.return;
+  const iteratorThrow = iterator.throw;
+
+  iterator.next = function(value) {
+    started = true;
+    return FunctionPrototypeCall(iteratorNext, iterator, value);
+  };
+  iterator.return = function(value) {
+    canceled = true;
+    wake();
+    if (!started) stream.destroy();
+    return FunctionPrototypeCall(iteratorReturn, iterator, value);
+  };
+  iterator.throw = function(reason) {
+    canceled = true;
+    wake();
+    if (!started) stream.destroy();
+    return FunctionPrototypeCall(iteratorThrow, iterator, reason);
+  };
+
+  return iterator;
 }

 /**
diff --git a/test/parallel/test-stream-iter-readable-interop.js b/test/parallel/test-stream-iter-readable-interop.js
index 8100b54168b..7cb136ba86b 100644
--- a/test/parallel/test-stream-iter-readable-interop.js
+++ b/test/parallel/test-stream-iter-readable-interop.js
@@ -7,6 +7,7 @@
 const common = require('../common');
 const assert = require('assert');
 const { Readable } = require('stream');
+const { setImmediate } = require('timers/promises');
 const {
   from,
   pull,
@@ -588,6 +589,38 @@ async function testAbortSignal() {
   assert.ok(chunks.length >= 2);
 }

+async function testReturnWhileReadIsPending() {
+  const readable = new Readable({ read() {} });
+  const iterator = from(readable)[Symbol.asyncIterator]();
+  const pending = iterator.next();
+  await setImmediate();
+
+  assert.deepStrictEqual(await iterator.return('stopped'), {
+    value: 'stopped',
+    done: true,
+  });
+  assert.deepStrictEqual(await pending, { value: undefined, done: true });
+  await setImmediate();
+  assert.strictEqual(readable.destroyed, true);
+  assert.strictEqual(readable.listenerCount('readable'), 0);
+}
+
+async function testAbortWhileReadIsPending() {
+  const readable = new Readable({ read() {} });
+  const controller = new AbortController();
+  const iterator = pull(readable, {
+    signal: controller.signal,
+  })[Symbol.asyncIterator]();
+  const pending = iterator.next();
+  await setImmediate();
+
+  controller.abort();
+  await assert.rejects(pending, { name: 'AbortError' });
+  await setImmediate();
+  assert.strictEqual(readable.destroyed, true);
+  assert.strictEqual(readable.listenerCount('readable'), 0);
+}
+
 // =============================================================================
 // kValidatedSource identity - from() returns same object for validated sources
 // =============================================================================
@@ -637,4 +670,6 @@ Promise.all([
   testDuplexStream(),
   testSetEncodingDynamic(),
   testAbortSignal(),
+  testReturnWhileReadIsPending(),
+  testAbortWhileReadIsPending(),
 ]).then(common.mustCall());
diff --git a/test/parallel/test-stream-iter-to-readable.js b/test/parallel/test-stream-iter-to-readable.js
index a58bf0087df..c96b0c5c12e 100644
--- a/test/parallel/test-stream-iter-to-readable.js
+++ b/test/parallel/test-stream-iter-to-readable.js
@@ -157,6 +157,30 @@ async function testFalsyThenableCleanupError() {
   assert.strictEqual(result.reason, reason);
 }

+async function testSynchronousIteratorReturn() {
+  let returnCalled = false;
+  const source = {
+    __proto__: null,
+    [Symbol.asyncIterator]() {
+      return {
+        __proto__: null,
+        next() { return kNeverResolves; },
+        return() {
+          returnCalled = true;
+          return { __proto__: null, done: true };
+        },
+      };
+    },
+  };
+  const readable = toReadable(source);
+  const { promise, resolve } = Promise.withResolvers();
+  readable.once('close', resolve);
+
+  readable.destroy();
+  await promise;
+  assert.strictEqual(returnCalled, true);
+}
+
 async function testFalsyCleanupGetterErrors() {
   for (const [symbol, create] of [
     [Symbol.asyncIterator, toReadable],
@@ -722,6 +746,7 @@ Promise.all([
   testErrorAsync(),
   testFalsyErrorAsync(),
   testFalsyThenableCleanupError(),
+  testSynchronousIteratorReturn(),
   testFalsyCleanupGetterErrors(),
   testEmptyAsync(),
   testEmptyBatchAsync(),