Skip to content

stream/iter: falsy cancellation reasons become clean EOF in broadcast and share #64704

Description

@trivikr

Version

main

Platform

macOS 26.5.2

Subsystem

stream

What steps will reproduce the bug?

import { broadcast, share, shareSync } from 'node:stream/iter';

const reasons = [0, '', false, null];

async function run(name, make) {
  for (const reason of reasons) {
    const { stream, iterator } = make();
    stream.cancel(reason);

    try {
      console.log(name, JSON.stringify(reason), await iterator.next());
    } catch (error) {
      console.log(name, JSON.stringify(reason), 'rejected:', error);
    }
  }
}

await run('broadcast', () => {
  const { broadcast: stream } = broadcast();
  return {
    stream,
    iterator: stream.push()[Symbol.asyncIterator](),
  };
});

await run('share', () => {
  const stream = share([]);
  return {
    stream,
    iterator: stream.pull()[Symbol.asyncIterator](),
  };
});

await run('shareSync', () => {
  const stream = shareSync([]);
  return {
    stream,
    iterator: stream.pull()[Symbol.iterator](),
  };
});

How often does it reproduce? Is there a required condition?

Always

What is the expected behavior? Why is that the expected behavior?

async next() should reject—and sync next() should throw—with the exact supplied reason, consistent with §13.1.4 Broadcast.cancel() and the corresponding §13.2.4 Share.cancel().

If reason is provided, each consumer sees it as an error; otherwise they see clean completion.

What do you see instead?

broadcast 0 { done: true, value: undefined }
broadcast "" { done: true, value: undefined }
broadcast false { done: true, value: undefined }
broadcast null { done: true, value: undefined }
share 0 { done: true, value: undefined }
share "" { done: true, value: undefined }
share false { done: true, value: undefined }
share null { done: true, value: undefined }
shareSync 0 { done: true, value: undefined }
shareSync "" { done: true, value: undefined }
shareSync false { done: true, value: undefined }
shareSync null { done: true, value: undefined }

every falsy reason is converted into clean completion for consumers that had not called next() before cancellation.

Additional information

No response

Activity

  1. self-assigned this
    on Jul 24, 2026
  2. added
    streamIssues and PRs related to Node.js streams.
    on Jul 24, 2026
  3. theSnackOverflow commented on Jul 31, 2026

    @theSnackOverflow
    Contributor

    I'd like to work on this. Here's what I found while tracing it:

    cancel() stores falsy reasons correctly — it guards on reason !== undefined:

    if (reason !== undefined) {
    this.#error = reason;
    }

    But every read site tests the stored value for truthiness, so a falsy reason is invisible and the consumer falls through to the clean-completion path instead:

    • broadcast.js: L179, L196, L341
    • share.js (Share): L132, L144, L170, L179, L263
    • share.js (SyncShare): L473, L538

    The obvious fix (#error !== null) doesn't work, because those fields initialize to null — so cancel(null) would still be indistinguishable from "never cancelled".

    push.js already solves exactly this problem with a symbol sentinel:

    const kNoFailReason = Symbol('kNoFailReason');
    
    fail(reason = kNoFailReason) {
      // ...
      this.#error = reason === kNoFailReason ?
        new ERR_INVALID_STATE('Failed') :
        reason;
    }

    Would applying that same sentinel pattern to Broadcast, Share, and SyncShare be the direction you'd want here? Happy to open a PR if so.

  4. trivikr commented on Jul 31, 2026

    @trivikr
    MemberAuthor

    A fix has been up at #64705 for a week

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

streamIssues and PRs related to Node.js streams.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions