cloudflare / cloudflare/workerd
🐛 Bug Report — Runtime APIs: `node:stream` on workerd drops a `'readable'` event under a reentrancy-guarded reader with manual backpressure
- 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
Assessment
This issue has not been assessed yet.