streams: restore the native source ref when a new reader locks a released stream
Problem
- After
for await (const line of console) { break; }, the next reader ofBun.stdin.stream()gets nothing. The process exits with code 0 while its read is pending. A secondgetReader()afterreleaseLock()fails the same way (pipe and tty). Bun.stdin.stream()is one stream per process.readableStreamReaderGenericRelease(src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp:557) callsupdateRef(false)on the native source. Nothing restores the ref when the next reader locks the stream.
Fix
- The reader lock and release call
FileReader__setReaderLocked. A release records that it took a held ref away, and the next lock gives it back. BufferedReader::update_refreturns the previous state, so a source already unref'd (proc.unref()) has nothing to restore.- An explicit
updateRef()(process.stdin.unref()) cancels a pending restore, in any order. - Verified:
test/js/bun/console/console-iterator.test.ts(6 new tests, 4 fail on main). Also the process.stdin, spawn, terminal, readline and web streams suites.
Background
- A native source (
FileReader) owns aFilePollon the fd. Its ref decides whether the poll keeps the event loop alive. - The unref at release stops an abandoned stdin reader from holding the process open (c8305df5c2). This PR adds the other half.
- The console iterator (
ConsoleObject.ts) releases its reader infinally, sobreaktriggers the bug.
Downsides
- Behavior change: a second reader that stays idle now keeps the process alive until it is released or the stream ends. Before, the process exited.
- Not fixed here: a released stdin source still reads ahead, so
prompt()orfs.readSync(0)can miss input (Notes). - Not fixed here: a reader after a paused
process.stdinstill exits with its read pending (#43747).
Notes
Review history. This change was folded into #43747 for a short time on 2026-09-22. Jarred-Sumner requested changes on that folded diff at 0fe30434d8: https://github.com/oven-sh/bun/pull/43747#pullrequestreview-5273826212 ("does this really need a getter? does this really need another function?"). None of what that diff had is in this PR: no locked getter, no return value from the handle's updateRef(), no bit on JSReadableStream, no JS call on the lock or release path. What is left is one static C++ helper (it replaces the inline by-name block, so ReadableStreamOperations.cpp is 2 lines shorter than on main), one FileReader method with its extern, and one Cell<bool>.
The reply in #43747 ("it needed neither") was right for the getter and the helper of that PR. It was wrong for the ref restore: the second reader case needs it, which is why this PR is open again. The restore needs one bit of state for two reasons:
- The shape without state (stop the source at release, resume it at lock, 1d2ebbb936) leaked an fd in
streams-leak.test.tson the POSIX lanes. A pull that arrives after the release is parked in the stopped source and roots it (https://github.com/oven-sh/bun/pull/43747#issuecomment-5771335424). - A re-ref at every lock brings back a ref that the user took away with
proc.unref().
With #43747. The two PRs conflict in ReadableStreamOperations.cpp and FileReader.rs, because both add a hook at the reader lock. If this PR lands first, #43747 can resume the paused source from FileReader::set_reader_locked(true) and needs no extern of its own. That resume must only arm the poll: the C++ callers are userJS: no.
Known gap, not changed here. The handoff that led here described two fds on the same pty that compete for input. That is real for a direct reader of fd 0. On a tty the stream reads from a second fd (open_as_nonblocking_tty, FileReader.rs:148). After a release that fd stays armed and reads ahead up to the highwater mark. Probe: console iterator break, then a 1.5 s sleep while the parent types second, then fs.readSync(0). It returns the third line, on main and on this branch. Readers that share the stream (Bun.stdin.stream(), process.stdin, readline) get every byte. I found no open PR for this.
Other notes.
- Rebased on main 4ada08b5ab. The one conflict was
PosixBufferedReader::update_ref(src/io/PipeReader.rs). The branch keeps main's check that an unarmed poll is not ref'd (#42038) and returns the previousKEEP_ALIVEvalue. - Earlier shape of this PR: a bit on the C++
JSReadableStreamplus a boolean return value from the handle'supdateRef(). With that shape,for await (console) break, thenprocess.stdin.unref(), thengetReader()brought the ref back against theunref(). The bookkeeping now lives inFileReader, where an explicitupdateRef()can cancel it. - Only
FileReaderhasSUPPORTS_REF, so the direct call onJSFileInternalReadableStreamSourcecovers every source that the old by-nameupdateRef(false)call reached. - Only reader locks can follow a release.
Bun.write(file, stream),new Response(stream)andBun.readableStreamToText(stream)reject a stream that ever had a reader (ERR_BODY_ALREADY_USED), becausematerializeNativeSourcesetsm_disturbedat the firstgetReader().pipeTo()after a release takes a reader and works with this PR. - The Windows reader had no stored ref state (
update_refcalleduv_ref/uv_unrefdirectly). It now keeps aKEEP_ALIVEflag like the POSIX reader so it can report the previous state.WindowsBufferedReader::fromalready copies flags. The tty tests are POSIX only, so no test covers a Windows tty. - Self-reviewed the reworked diff. It asked for four things: the review history, the known gap, the order with #43747, and the note on
set_reader_lockedthat it must not dispatch a read. All four are done. - Top-level
awaitin the child hides the bug because the pending module promise keeps the loop alive. The new tests use an async IIFE. - The two
unref()tests pass on main too. They guard the restore against a ref that the user took away. test/js/node/process/stdin/stdin-fixtures.test.tsfails under a debug (ASAN) build with and without the change: its 1 s auto-kill timer is shorter than theprocess.stdinbootstrap under ASAN.
no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/console/console-iterator.test.ts