node:stream: stop 'data' and 'end' after destroy() on a native-backed Readable
Merged
RO
1mo ago+215+215+012345678901234567890123456789-12-12-01234567890123456789
Related to #36169
Problem
child.stdout.destroy()inside a'data'listener is followed by one more'data'event (destroyed === true), and by'end'if EOF was due. Node emits only'close'. Same forchild.stderrandReadable.fromWeb()over a native stream.exec()andexecFile()stop a stream withdestroy()atmaxBuffer, as Node does. The extra chunk gives the callback up to one chunk too many.- Cause:
_read()insrc/js/internal/streams/native-readable.tspushes synchronously, so a flowingReadableholds the next chunk while a'data'listener runs.flow()emits it afterdestroy(). EOF follows a tick later.
Fix
destroy()drops what a flowing stream has buffered, and EOF is not pushed to a destroyed stream. The events now match Node:'data', then'close'.- A paused or ended stream keeps its buffer, as Node's does. A destroy from inside
_read()is the source failing, and the bytes read before the error still reach'data'. - Verified:
test/js/node/stream/node-stream.test.js(six cases fail on main),test/js/node/child_process/child_process.test.ts(two fail on main). Node v26.3.0 gives every expected sequence.
Background
NativeReadableis theReadableover a nativeReadableStreamsource.child_processuses it forstdoutandstderr,Readable.fromWeb()for a native web stream.- In flowing mode,
Readable.read()calls_read()before it removes the chunk it is about to emit. A synchronouspush()lands behind that chunk, so the source stays one chunk ahead. - Node's child stdio (
net.Socket) andfromWeb()push asynchronously, so their buffer is empty while a'data'listener runs.
Notes
Repro from the report. Node v26.3.0 and Bun 1.3.14 print 0. Bun 1.4.0 to main print 1.
const { spawn } = require("child_process");
const c = spawn("python3", ["-c", "import os\nb=b'x'*65536\nfor i in range(48): os.write(1, b)"]);
let at = -1, n = 0, after = 0;
c.stdout.on("data", d => {
n += d.length;
if (at >= 0) return void after++;
if (n > 1024 * 1024) { at = n; c.stdout.destroy(); }
});
c.on("close", () => console.log("'data' events after destroy():", after));
- The extra chunk does not come from the pending
pull()promise:push()already returns false once the stream is destroyed. An instrumented run shows the chunk in_readableState.bufferwhendestroy()runs, andflow()emits it fromemitReadable_. - Deterministic form:
Readable.fromWeb(new Blob([Buffer.alloc(1 << 20)]).stream())withdestroy()in the first'data'listener. main:data, data (destroyed=true), end, close. Node and this branch:data, close. A 100 byte body on main:data, end, close. execFilewithmaxBuffer1 MiB against a 3 MiB writer, debug build: 11 of 32 callbacks over the cap on main (1,077,568 to 1,114,112 bytes), 0 of 32 on this branch. Release main with the python writer above: 7 of 16. Node: 0 of 16. #36169 latches the overflow in theexechandler. With this change the handler needs no latch, as in Node.- Read errors:
pull()throws a stored read error from inside_read(), and the stream is destroyed with it while a chunk is still buffered. #41456 delivers the bytes and then the error.destroy()keeps the buffer in that case (state.sync). The new case intest/js/bun/spawn/spawn-stdio-syscall-error.test.tscounts the bytes the parent received before an injectedEIOand expects all of them in'data'. It passes on main, and it fails (about 180 KiB lost) when thestate.synccheck is removed. - The read path does not change. To push each pull result on a later tick also fixes this, but it costs a tick for each chunk on the hot path, and the EOF check is still needed.
Readableitself is unchanged. A pure JSReadablethat pushes synchronously emits one more'data'afterdestroy()in Node too, and Node keeps buffered data acrossdestroy().- One deliberate difference from Node: chunks buffered while paused (source not ended), then
resume(), thendestroy()in the first'data'listener. Node and main emit the rest of the buffered chunks afterdestroy(). This branch drops them. To keep them needs a record of which buffered chunks came from a pause, on the hot path. - Not changed here:
Duplex.from(nativeWebStream)emits one'data'afterdestroy()on main and on this branch (its own_readloop finds several chunks ready). When the source ended with two or more chunks buffered, Bun emits'end'before'close'afterdestroy()and Node does not. - No event after
destroy()on main for:httpclient response,httpserver request,fs.createReadStream,zlibgunzip,net.Socket,child.stdio[3]. - On Windows the child stdio cases pass with and without the fix (reads there complete asynchronously). The
fromWebcases fail without it. - Suites run on the debug build:
test/js/node/child_process/,test/js/node/stream/,readline.node.test.ts,node-fetch.test.js,undici.test.ts,test/js/web/streams/streams.test.js,fetch-backpressure.test.ts,body.test.ts,process-stdin.test.ts,spawn.test.ts(stdio subset),spawn-stdio-syscall-error.test.ts,spawn-streaming-stdout.test.ts,test/regression/issue/1632.test.ts,09555.test.ts, and about 55test-child-process-*,test-stdio-*,test-stream-*andfromWebfiles fromtest/js/node/test/parallel/.