Keep process.stdin.readableEnded latched after resume() following 'end'

Closed
RO
2mo ago
+37-0

What

process.stdin.readableEnded is documented as a one-way latch: once it becomes true after 'end', it should stay true. In Bun it flips back to false if resume() is called after 'end', a common no-op in Node code (finally { stream.resume() }, keep-alive wrappers, readline teardown, a late consumer). One macrotask later an ended, destroyed stdin claims it never ended.

Repro

// echo -n abc | bun ended.mjs
const si = process.stdin;
si.on("data", () => {});
si.on("end", () => {
  const atEnd = si.readableEnded;          // true
  si.pause(); si.resume();                 // a post-'end' no-op in node
  const afterResume = si.readableEnded;    // true
  setTimeout(() => {
    console.log(JSON.stringify({ atEnd, afterResume, later: si.readableEnded, destroyed: si.destroyed }));
  }, 40);
});
// node : {"atEnd":true,"afterResume":true,"later":true, "destroyed":true}
// bun  : {"atEnd":true,"afterResume":true,"later":false,"destroyed":true}  <- un-latched

Cause

stdin's "resume" event handler (src/js/builtins/ProcessObjectInternals.ts) unconditionally calls stream._undestroy() to re-arm the native reader after a pause/disown cycle. _undestroy() resets endEmitted to false (src/js/internal/streams/destroy.ts:168), and the readableEnded getter reads endEmitted. When the resume happens after 'end', this un-latches the flag.

Fix

Once 'end' has emitted there is nothing left to read, so a resume() is a no-op. The handler now returns early when endEmitted is already set, leaving readableEnded latched. This matches Node, where resume() after endEmitted does not touch the ended state.

No byte loss existed before or after this change (read() after 'end' stays null, 'end' is not re-emitted); this only restores the state-machine invariant.

Verification

test/js/node/process/process-stdin.test.ts gains "readableEnded stays latched after a resume() following 'end'". Fails on the released build (later: false), passes with the fix.