cloudflare / cloudflare/workerd

🐛 Bug Report — Runtime APIs: `node:stream` on workerd drops a `'readable'` event under a reentrancy-guarded reader with manual backpressure

Open
#7,136 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

# `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

```js
// 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)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.