node: deliver the buffered stdin remainder at EOF in paused mode
What
process.stdin in paused (pull) mode loses the final partial chunk of piped input at EOF, and a bare process.stdin.read() with no 'readable' listener never delivers anything.
// echo -n "abcdefgh" | bun app.js
const chunks = [];
process.stdin.on("readable", () => {
let c;
while ((c = process.stdin.read(3)) !== null) chunks.push(c.toString());
});
process.stdin.on("end", () => console.log(chunks, process.stdin.readableEnded));
// node: [ 'abc', 'def', 'gh' ] true
// bun: [ 'abc', 'def' ] false
read(n) returning the remaining < n bytes once the stream has ended is core stream.Readable behavior, so anything pulling fixed-size records from stdin (length-prefixed protocols, fixed-width formats, block decoders) silently drops the last record.
Why
getStdinStream's EOF path in ProcessObjectInternals.ts did stream.emit("end") + stream.destroy() by hand instead of stream.push(null). Without push(null) the readable state never reaches ended, so howMuchToRead(n) keeps returning 0 for the < n remainder, readableEnded never becomes true, and 'end' fires even while data is still buffered (Node only emits 'end' after the buffer is drained).
Separately, only on('readable'), resume() and ref() acquired the native reader. A consumer that just calls process.stdin.read() (no 'readable' listener) hit _read() with no source, so it never produced data and 'end' never fired.
How
In getStdinStream:
- On EOF, call
stream.push(null)so the buffered remainder stays readable and'end'/readableEndedcome from the stream machinery, then release the native reader right away so the process can still exit even if the consumer never drains the buffer. - The stream is created with
autoClose: false(soautoDestroyis off); destroy it once'end'has emitted, which keeps Node's'end'then'close'ordering. - An explicit
read()now acquires the native reader. Internalread(0)kicks (maybeReadMore,nReadingNextTick) deliberately don't, sopause()still lets the process exit.
Verification
New tests in test/js/node/process/process-stdin.test.ts (the first three fail on bun 1.4.0 and all pass with this change; output matches node v26.3.0):
read(3)over 8 piped bytes yields["abc","def","gh"]andreadableEnded === true- polling
read(3)with no'readable'listener drains stdin and reaches'end' 'end'is not emitted when the buffer is never drained, and the process still exits- a
read()that throws while validating its argument (read(2 ** 31)) does not leave stdin holding the process open
Existing stdin suites (test/js/node/process/, test/js/node/process/stdin/, test/regression/issue/{stdin,tty}-*, and the vendored test/js/node/test/parallel/test-stdin-*.js) pass unchanged, except five stdin-fixtures.test.ts cases and test-stdin-from-file-spawn.js, which fail identically on an unmodified debug build (1s fixture auto-kill vs debug startup time, and a pre-existing debug-only assertion).
Note on test/js/bun/util/fuzzy-wuzzy.test.ts
CI caught the fuzzer failing on Windows after this change: it calls process.stdin.pipe() with no arguments, and Readable#pipe() registers its once('end', onend) handler before it ever touches dest, so the call throws synchronously but leaves an 'end' listener whose dest is undefined. Node does the same thing:
node -e "try { process.stdin.pipe() } catch {} process.stdin.resume()" < /dev/null
# TypeError: Cannot read properties of undefined (reading 'end') at onend
The fuzzer only survived that listener before because the old EOF path emitted 'end' from inside internalRead's try/catch, whose error sink is stream.destroy(), which the fuzzer replaces with a no-op, so the listener's throw was silently swallowed. Now that process.stdin ends through the normal stream machinery, the throw surfaces as an uncaught exception, exactly as it does in Node. pipe is now in the fuzzer's banned list (its no-argument call always threw, so no coverage is lost).