Commit 665791f5f29 for nodejs

commit 665791f5f2996bcf5484f0ebc02e0a5f22d583f2
Author: James M Snell <jasnell@gmail.com>
Date:   Sun Aug 30 16:47:11 2026 +0000

    stream: make broadcast waiters use SafeSet

    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 673f21f1186..17edecf84ce 100644
--- a/lib/internal/streams/iter/broadcast.js
+++ b/lib/internal/streams/iter/broadcast.js
@@ -110,7 +110,7 @@ class BroadcastImpl {
   #buffer = new RingBuffer();
   #bufferStart = 0;
   #consumers = new SafeSet();
-  #waiters = [];  // Consumers with pending resolve (subset of #consumers)
+  #waiters = new SafeSet();  // Consumers with pending resolve
   #ended = false;
   #error;
   #errored = false;
@@ -203,6 +203,7 @@ class BroadcastImpl {

     function detach() {
       state.detached = true;
+      self.#waiters.delete(state);
       if (state.resolve) {
         state.resolve({ __proto__: null, done: true, value: undefined });
       }
@@ -262,7 +263,7 @@ class BroadcastImpl {
             const { promise, resolve, reject } = PromiseWithResolvers();
             state.resolve = resolve;
             state.reject = reject;
-            ArrayPrototypePush(self.#waiters, state);
+            self.#waiters.add(state);
             return promise;
           },

@@ -313,6 +314,7 @@ class BroadcastImpl {
       consumer.detached = true;
     }
     this.#consumers.clear();
+    this.#waiters.clear();
     this.#cachedMinCursorConsumers = 0;
     const onCancel = this[kOnCancel];
     this[kOnCancel] = null;
@@ -401,6 +403,7 @@ class BroadcastImpl {
         }
       }
     }
+    this.#waiters.clear();
     this.#notifyEndDrained();
   }

@@ -422,6 +425,7 @@ class BroadcastImpl {
       consumer.detached = true;
     }
     this.#consumers.clear();
+    this.#waiters.clear();
     this.#cachedMinCursorConsumers = 0;
   }

@@ -489,12 +493,11 @@ class BroadcastImpl {

   #notifyConsumers() {
     const waiters = this.#waiters;
-    if (waiters.length === 0) return;
+    if (waiters.size === 0) return;
     // Swap out the waiters list so consumers that re-wait during
     // resolve don't get processed twice in this cycle.
-    this.#waiters = [];
-    for (let i = 0; i < waiters.length; i++) {
-      const consumer = waiters[i];
+    this.#waiters = new SafeSet();
+    for (const consumer of waiters) {
       if (consumer.resolve) {
         const bufferIndex = consumer.cursor - this.#bufferStart;
         if (bufferIndex < this.#buffer.length) {
@@ -513,11 +516,11 @@ class BroadcastImpl {
           if (consumer.detached && this.#deleteConsumer(consumer)) {
             this.#tryTrimBuffer();
           } else if (this.#promotePending(consumer)) {
-            ArrayPrototypePush(this.#waiters, consumer);
+            this.#waiters.add(consumer);
           }
         } else {
           // Still waiting -- put back
-          ArrayPrototypePush(this.#waiters, consumer);
+          this.#waiters.add(consumer);
         }
       }
     }
diff --git a/test/parallel/test-stream-iter-broadcast-waiter-cleanup.js b/test/parallel/test-stream-iter-broadcast-waiter-cleanup.js
new file mode 100644
index 00000000000..4e0f6dd4d47
--- /dev/null
+++ b/test/parallel/test-stream-iter-broadcast-waiter-cleanup.js
@@ -0,0 +1,34 @@
+// Flags: --experimental-stream-iter --expose-gc
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { broadcast } = require('stream/iter');
+
+async function detachConsumers(shared, count) {
+  for (let i = 0; i < count; i++) {
+    const iterator = shared.push()[Symbol.asyncIterator]();
+    const pending = iterator.next();
+    await iterator.return();
+    await pending;
+  }
+}
+
+async function testDetachedWaitersAreReleased() {
+  const { broadcast: shared } = broadcast();
+
+  await detachConsumers(shared, 100);
+  global.gc();
+  const before = process.memoryUsage().heapUsed;
+
+  await detachConsumers(shared, 50_000);
+  global.gc();
+  const retained = process.memoryUsage().heapUsed - before;
+
+  assert.ok(retained < 8 * 1024 * 1024,
+            `Detached Broadcast waiters retained ${retained} bytes`);
+  assert.strictEqual(shared.consumerCount, 0);
+  shared.cancel();
+}
+
+testDetachedWaitersAreReleased().then(common.mustCall());