Skip to content

Readable._construct behaviour contradicts docs #39992

Closed

Description

@mikuso

Version

v16.8.0

Platform

Microsoft Windows NT 10.0.19043.0 x64

Subsystem

stream

What steps will reproduce the bug?

Returning a promise from Readable._construct has the same effect as calling the callback. This is not documented and may be a bug?

This leads to the following strange behaviour:

const {Readable} = require('stream');

const stream = new Readable({
    async construct(callback) {
        callback();
    },
    read(size) {
        console.log('read', size);
    },
    destroy(err, callback) {
        console.log('destroy', err);
    }
});

Output:

destroy Error [ERR_MULTIPLE_CALLBACK]: Callback called multiple times
    at new NodeError (node:internal/errors:371:5)
    at onConstruct (node:internal/streams/destroy:255:37)
    at processTicksAndRejections (node:internal/process/task_queues:82:21) {
  code: 'ERR_MULTIPLE_CALLBACK'
}

Similarly:

const {Readable} = require('stream');

const stream = new Readable({
    async construct(callback) {
        // don't call the callback
    },
    read(size) {
        console.log('read');
    }
});

stream.read();

Output:

read

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

Always

What is the expected behavior?

Flow should not proceed to Readable._read until after the callback passed to Readble._construct has been called.

The docs state:

This optional function will be scheduled in the next tick by the stream constructor, delaying any _read() and _destroy() calls until callback is called.

What do you see instead?

  • In the first example, an error is thrown for calling the callback twice (despite only calling it once)
  • In the second example, flow passes to Readable._read despite the callback not being called yet.

Additional information

It is desirable to be able to be able to use await within Readable._construct, but labelling this function as async (and therefore returning a Promise) appears to cause the callback function to be implicitly called (which is sometimes unwanted).

As a workaround, I can return my own Promise from Readable._construct and use its resolve/reject functions in lieu of the callback.

In summary, either this is expected behaviour and the docs are incorrect - or vice versa. It's hard to tell.

Activity

  1. lpinca commented on Sep 4, 2021

    @lpinca
    Member

    It is not made explicit but you either return a promise or call the callback, not both.

  2. added
    streamIssues and PRs related to Node.js streams.
    on Sep 4, 2021
  3. added
    docIssues and PRs related to Node.js documentation.
    on Sep 9, 2021
  4. ronag commented on Sep 9, 2021

    @ronag
    Member

    Maybe docs could be improved to be more explicit about this.

  5. omniadonum commented on Jan 30, 2022

    @omniadonum

    This "feature" was introduced by #34416 (technically via 744a284). It includes both _construct, _transform, _flush, _final, and _destroy. (Lots of duplication in the impl, bump #34723.) So this is generally applicable for Readable, Writable, and Duplex/Transform interfaces.

    They all check if the method is a thenable, then loosely callbackifies it. For example, for _construct, this is by means of the internal destroy implementation:

    const result = stream._construct(onConstruct);
    if (result != null) {
    const then = result.then;
    if (typeof then === 'function') {
    then.call(
    result,
    function() {
    if (!called) {
    process.nextTick(onConstruct, null);
    }
    },
    function(err) {
    if (!called) {
    process.nextTick(onConstruct, err);
    }
    });
    }
    }

    Unless I'm reading this wrong, I wonder how the Duplex tests are passing (are they?) since they use both async _construct and callback without hitting ERR_MULTIPLE_CALLBACK, e.g.:

    async _construct(callback) {
    // eslint-disable-next-line no-restricted-syntax
    await setTimeout(common.platformTimeout(1));
    callback();
    }

    Aside, I also noticed that @types/node@16 shows _construct():void for both Readable and Writable:

    I'm having trouble figuring out of this is a supported feature / public API, or us stream implementers just got lucky. It is not documented anywhere, yet all the issues/commits/PRs I'm finding discuss it like it is an existing/known feature and there is a test suite available.

    Maybe docs could be improved to be more explicit about this.

    Do you mean it is a signature with a stability index of 2 ("stable")?

  6. github-actions commented on Jun 26, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 210 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  7. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jun 26, 2026
  8. luanmuniz commented on Jun 27, 2026

    @luanmuniz
    Contributor

    I tried reproducing this on a recent Node.js version, and I’m not seeing the behavior described in the original report anymore.

    I tried both codes in the original post and some modified versions of them, and based on that, I think the original behavior may no longer be reproducible as filed.

    The current behavior also seems more consistent with the current docs for readable._construct(callback) that say that _construct() delays any _read() and _destroy() calls until the callback is called. So if an async _construct() returns/resolves a Promise but never calls the callback, I would not expect _read() to run under the documented contract.

    It is already stale, but we might be able to close this issue

  9. mikuso commented on Jun 27, 2026

    @mikuso
    Author

    @luanmuniz You are correct. This issue is seemingly resolved now.
    I'll close the issue.
    Thanks!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    docIssues and PRs related to Node.js documentation.staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.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