streams: restore the native source ref when a new reader locks a released stream

RO
2mo ago
+259-27

Problem

  • After for await (const line of console) { break; }, the next reader of Bun.stdin.stream() gets nothing. The process exits with code 0 while its read is pending. A second getReader() after releaseLock() fails the same way (pipe and tty).
  • Bun.stdin.stream() is one stream per process. readableStreamReaderGenericRelease (src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp:557) calls updateRef(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_ref returns 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 a FilePoll on 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 in finally, so break triggers 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() or fs.readSync(0) can miss input (Notes).
  • Not fixed here: a reader after a paused process.stdin still 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.ts on 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 previous KEEP_ALIVE value.
  • Earlier shape of this PR: a bit on the C++ JSReadableStream plus a boolean return value from the handle's updateRef(). With that shape, for await (console) break, then process.stdin.unref(), then getReader() brought the ref back against the unref(). The bookkeeping now lives in FileReader, where an explicit updateRef() can cancel it.
  • Only FileReader has SUPPORTS_REF, so the direct call on JSFileInternalReadableStreamSource covers every source that the old by-name updateRef(false) call reached.
  • Only reader locks can follow a release. Bun.write(file, stream), new Response(stream) and Bun.readableStreamToText(stream) reject a stream that ever had a reader (ERR_BODY_ALREADY_USED), because materializeNativeSource sets m_disturbed at the first getReader(). pipeTo() after a release takes a reader and works with this PR.
  • The Windows reader had no stored ref state (update_ref called uv_ref/uv_unref directly). It now keeps a KEEP_ALIVE flag like the POSIX reader so it can report the previous state. WindowsBufferedReader::from already 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_locked that it must not dispatch a read. All four are done.
  • Top-level await in 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.ts fails under a debug (ASAN) build with and without the change: its 1 s auto-kill timer is shorter than the process.stdin bootstrap 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