process.stdin: release the native reader on destroy()
Problem
After arming process.stdin for reading (on('data') / resume() / readline), calling process.stdin.destroy() leaves the process alive indefinitely while the stdin pipe remains open. Node exits immediately.
process.stdin.on("data", () => {});
process.stdin.destroy();
console.log("destroyed =", process.stdin.destroyed); // true in both runtimes
setTimeout(() => { console.log("*** STILL ALIVE"); process.exit(1); }, 3000).unref();
// node: destroyed = true, exits 0 immediately
// bun: destroyed = true, *** STILL ALIVE, exits 1 after 3s
Run with stdin held open, e.g. (sleep 30 | bun repro.mjs). destroy()+pause() still hangs; only process.stdin.unref() lets it exit.
Cause
on('data')/resume() in getStdinStream call own() (refs the native FileReader and schedules a read) and then Readable.prototype.resume() schedules resume_ on nextTick. A synchronous destroy() before that tick sets kDestroyed, but because kConstructed has not been set yet (fs ReadStream has a _construct), the destroy body is deferred via once(kDestroy, ...).
On the next tick the 'resume' listener runs own() + stream._undestroy(), which clears kDestroyed. When constructNT's onConstruct then checks s.destroyed, it is false, so kDestroy is never emitted, the deferred destroy body never runs, and no path ever calls disown(). The FileReader poll stays ref'd and keeps the event loop alive until stdin hits EOF.
The inherited fs.ReadStream.prototype._destroy would not have helped anyway: for fd === 0 it reduces to cb(err) and never touches the native source.
Fix
In getStdinStream:
- Give stdin its own
_destroythat setsstream_destroyed, callsdisown()(dropssetFlowing, releases the reader,updateRef(false)) and invokes the callback. - Guard the
'resume'listener withif (stream.destroyed) return;so a deferred resume event cannot revert a destroy that raced in before it fired.
With both in place, onConstruct observes destroyed === true, emits kDestroy, the overridden _destroy disowns the native reader, 'close' fires, and the process exits.
Verification
test/js/node/process/process-stdin.test.ts gains two cases that spawn a child with an open stdin pipe, arm reading, destroy(), and assert the child exits 0 with destroyed === true (an unref'd timeout flags a hang). Both fail on current main with STILL_ALIVE / destroyed=false and pass with this change; the existing 14 tests in the file and the stdin pause/resume regression tests continue to pass.