Skip to content

πŸ› Bug Report β€” Runtime APIs: node:stream on workerd drops a 'readable' event under a reentrancy-guarded reader with manual backpressure #7136

Description

@Karsten-Zhou

node:stream on workerd drops a 'readable' event under a reentrancy-guarded reader with manual backpressure

A node:stream reader pattern that is robust and deterministic on Node.js deterministically stalls on workerd. When an object-mode Transform stream pushes a second object after its consumer releases manual backpressure, the 'readable' event for that second object is missed.

This occurs because the event fires while a reentrancy guard (reading === true) is still held in the consumer's microtask queue. The object sits in the buffer unread, causing the stream pipeline to hang.

Minimal reproduction

// Node:            PASS  ("1 OK ID completed" is delivered)
// workerd (vitest): FAIL  (only "* ID NIL" delivered)
const { Transform } = require('node:stream');

const t = new Transform({
  readableObjectMode: true,
  writableObjectMode: false,
  transform(chunk, enc, cb) {
    const lines = chunk.toString('latin1').split('\r\n').filter(Boolean);
    (async () => {
      for (const line of lines) {
        // push one object, then WAIT for the consumer's next() (backpressure)
        await new Promise((resolve) => this.push({ line, next: resolve }));
      }
      cb();
    })();
  },
});

const received = [];
let reading = false; // reentrancy guard (as in imapflow's socketReadable)

const reader = async () => {
  let d;
  while ((d = t.read()) !== null) {
    received.push(d.line);
    await new Promise((r) => setImmediate(r)); // async gap (like `await handleResponse`)
    d.next(); // release backpressure
  }
};

t.on('readable', () => {
  if (!reading) {
    reading = true;
    reader()
      .catch(() => {})
      .finally(() => { reading = false; });
  }
});

t.write(Buffer.from('* ID NIL\r\n1 OK ID completed\r\n'));
t.end();

setTimeout(() => {
  console.log(received);
  console.log(received.length === 2 ? 'PASS' : 'FAIL');
}, 500);

Expected vs actual

Runtime Result
Node.js (v24.13.0) ["* ID NIL","1 OK ID completed"] β†’ PASS (deterministic, repeated runs)
workerd (miniflare, nodejs_compat_v2) ["* ID NIL"] β†’ FAIL (deterministic, repeated runs)

The second pushed object is never surfaced; the consumer's reader() is never re-invoked for it.

What I verified

  • The exact same code passes on Node.js and fails on workerd across many runs. It is a deterministic timing issue, not a flake.
  • The failure depends entirely on the .catch(...).finally(...) promise-chain shape. Without the extra .catch hop, the guard clears in time, and the code passes on workerd too.
  • In Node.js, 'readable' emission is deferred to process.nextTick (after all microtasks), but workerd's polyfill appears to emit it earlier, creating a microtask-ordering race condition that Node.js avoids.

Environment

  • wrangler 4.125.0, @cloudflare/vitest-plugin 1.0.0, nodejs_compat + nodejs_compat_v2, compat date 2026-08-01
  • Node v24.13.0 (reference)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions