Skip to content

streams: stop a stdio source when its reader is released - #46954

Draft
robobun wants to merge 1 commit into
claude/http-consolidatedfrom
robobun/de9d772f/stdio-reader-release
Draft

robobun wants to merge 1 commit into
claude/http-consolidatedfrom
robobun/de9d772f/stdio-reader-release

Conversation

@robobun

@robobun robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until #46528 merges: it needs resumeNativeSource of that branch. Then I cut it again on main.

Problem

Fix

  • The hook calls Bun__FileReader__onReaderRelease. A stdio source on a pipe, socket or terminal stops and keeps its ref. The next reader lock resumes it.
  • A stop inside a chunk delivery ends the read loop. The console iterator keeps a line that a chunk splits.
  • Verified: process-stdin.test.ts, console-iterator.test.ts: 51 new tests, 41 fail on the base.
  • Self-reviewed: 33 concerns raised, 26 addressed, 7 in part (Notes).

Background

Downsides

  • Behavior change: a waiting second reader of stdin keeps the process alive.
  • A stopped stream sees stdin end only at its next reader. A writer is held after 65,536 late bytes (base 196,608).
  • A release and relock of idle stdin costs 3 more system calls.
Notes

How this was found. No user reported these forms. A check of what prompt() and confirm() read after a stream reader of stdin found the defect, and #41578 already lists it as a known gap. The reports that exist are for the process.stdin form (#23333, #13978), which #23341 fixed. That PR also tried this rule: d227cc7 set flowing in getReader() and cleared it in releaseLock(), for every file reader. It was taken out because it stopped the file readers of Windows and the stdout of a child. 1d2ebbb in #43747 tried it again and leaked the descriptor of an abandoned Bun.file().stream() reader. The scope here (fd 0, 1 or 2 on a pipe, socket or terminal) leaves out both of those sources.

Repro (base is the head of #46528, 9cd5284)

let first;
for await (const line of console) { first = line; break; }
await Bun.sleep(1000);               // "x" arrives now, and nothing holds the stream
const r = prompt("next?");           // "y" arrives now
let second;
for await (const line of console) { second = line; break; }
// base: { first: "p", r: "y", second: "x" }    this PR: { first: "p", r: "x", second: "y" }

On a terminal: a console loop reads go and breaks. Then Bun.spawn starts a child with stdin "inherit" that reads process.stdin 'data' events, and eight lines are typed. The parent and the child both poll the terminal, and each line goes to one of them. A child that blocks in read(2) wins every line, so only a child that reads through an event loop (an editor, a REPL, ssh) loses input.

Reference. Node v26.3.0 on a terminal, with the same child:

The parent lets go with lines that the child gets, of 8
Readable.toWeb(process.stdin).getReader(), read(), releaseLock() 8 (3 of 3 runs)
rl.once("line"), then rl.close() 8 (3 of 3 runs)
for await (const line of rl) { break } 3 to 4

Node has this on a terminal only, where it does not read ahead (process.stdin.readableHighWaterMark is 0 there and 65,536 on a pipe). On a pipe Node reads ahead: after pause() and after a readline break, a later fs.readSync(0) finds nothing. Its docs say that iterating a readline interface "will always consume the input stream fully". So the pipe rows follow the rule that #23341 set for process.stdin.pause(), not Node. Bun has synchronous readers of fd 0 that Node has not (prompt, confirm, alert). The console iterator of Bun also keeps the lines that it did not yield for the next loop (#5175), so its break is a pause and not an end.

Second and third fix in this PR

  • A consumer that stops the source inside the delivery of a chunk now ends the read loop (resolve_pending_read). The loop read once more after a chunk of 131,072 bytes or more. With a second write of 131,072 bytes queued, the base and the first form of this PR left 0 of those bytes on fd 0. Now 131,072 stay, also for process.stdin.pause() inside a 'data' listener.
  • The console iterator dropped the unfinished line at the end of the chunk that it stopped in (ConsoleObject.ts, the loop that runs when a stopped iterator starts again). printf 'a\nb\nc' | bun x.js with a break after the first line lost c. One line per loop over 3.2 MB of piped input cut 48 of 200,000 lines on the base. The stop makes chunks smaller, so it cut 78 before this line was added, and 0 now. The line is the one of hot: let the newest console iterator take stdin after a reload #43951.

Measurements (linux x64, release builds unless noted, base 9cd5284 against this PR)

base this PR
late input that the stream takes with no reader, of 200,000 bytes on a pipe (3 of 3 runs) 131,072 0
the same on a socket (6 runs) 109,632 to 200,000 0
lines that a child with inherited stdin gets on a terminal after a console break, of 8 (5 runs) 1 to 5 8
the same after getReader(), read(), releaseLock() (5 runs) 2 to 4 8
release forms after which fs.readSync(0) gets the late line, of 4 0 4
next readers of stdin after a console break that get the late input, of 25 forms 11 22
a second console loop in an async function (no top-level await) exits with code 0 before it reads reads the rest
bytes taken off a full pipe by 1, 200 and 1000 console loops that break after one line (3 runs each) 196,590 each 65,530 to 98,298
instructions per getReader() and releaseLock() cycle: JS source 4068 4061
the same: Blob stream 4523 4079
the same: Bun.file(path) stream 4572 4116
the same: closed JS stream, closed Blob stream (controls) 4196, 4205 4198, 4198
release and relock of an idle stdin pipe (debug log): register, unregister, poll 2, 0, 2 3, 1, 3
console loop to EOF over 1000 lines (debug log): register, unregister, poll 1, 1, 3 1, 1, 3
console loop over 2,000,000 lines, instructions:u, median of 5 5,676,126,805 5,667,636,755
release binary .text, .rodata, .bun_builtins -256 B, 0, +92 B
size_of::<FileReader>() 344 344
descriptors left by 8 released and abandoned Bun.file(0).stream() readers on a terminal, after one EOF and GC (at 3fd0627) 7 8
  • The other 3 of the 25 next-reader forms reject a stream that had a reader, on both builds.
  • The instruction counts per cycle are medians. Batches of runs differ by about 10 per cycle.
  • The runs of the 2,000,000-line loop spread by 0.21 percent on the base and 0.55 percent here. The two medians differ by 0.15 percent.
  • With the stop but without the limit in set_flowing(true), 300 loops take all of an 8 MiB feed into the buffer of the source.
  • The change adds 1 extern function and 0 host functions.
  • The base moved from 3fd0627 to 9cd5284 after the first push. http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528 took the disown() repair for a break, then process.stdin.pause(), with its own test. Every row is measured again on 9cd5284, except the last two.
  • Tools: a perf_event_open counter for instructions:u, size -A, and the debug SYS and FileReader logs. strace, perf and bloaty are not installed here.

Scope. The source stops when its Blob names fd 0, 1 or 2 and the descriptor is a pipe, a socket or a terminal. On Windows only a pipe stops. These stay out on purpose:

  • A Bun.spawn pipe. The child must not block, and the parent has to see the pipe end.
  • A regular file and Bun.file(path). A stopped reader that is abandoned never reaches its end.
  • Bun.file(fd) for another descriptor.
  • A Windows console. libuv cancels a console line read with an Enter key of its own (uv__cancel_read_console), so a stop in the middle of a typed line drops that line. I did not run a Windows console, so that source does not stop here.

The scope is a flag because a terminal is read through a second descriptor, so the descriptor of the reader does not say that the source is stdin. The release is its own extern function because updateRef(false) is also what process.stdin.unref() calls, and that source has to keep reading. shared_stdio is about the descriptor. The shared flag of #45431 is about the cached stream of a Blob.

Not fixed here. No user reported these forms, so I did not open an issue for them. This list is the record.

  • A reader that holds its lock and does not read. A child that starts inside the body of the console loop gets 3 to 5 of 8 lines on a terminal (base 2 to 6, Node 8 for its stream iterators).
  • Readable.toWeb(process.stdin), then a reader release: 65,536 to 131,072 of 200,000 late bytes are taken, and a child on a terminal gets 2 to 5 of 8 lines (Node 8).
  • A path: Bun.file("/dev/stdin").stream(), then a reader release. 131,072 of 200,000 late bytes are taken.
  • A Windows console, as above.
  • Bytes that the stream read before the release. With printf 'p\nx\ny\n' | bun x.js all three lines are in the first chunk. Node does the same like for like.
  • A cancel (reader.cancel(), a break out of for await (const chunk of Bun.stdin.stream()), a default pipeTo) ends the one cached stream for the rest of the process. It takes no late input, on the base and here.
  • Two sources on fd 0 (Bun.file(0).stream() beside Bun.stdin.stream()) collide in epoll on Linux (Bun.file().stream(): poll a dup of a stdio pipe, socket or terminal #46807). On the base the second source fails with EEXIST. Here it reads, and the shared stream fails at its next lock if the second source still holds the descriptor.
  • The next reader of a source that is not stdio does not keep the process alive after a release (streams: restore the native source ref when a new reader locks a released stream #41578). The six tests of streams: restore the native source ref when a new reader locks a released stream #41578 pass with this PR (base: 4 fail).
  • prompt() reads through its own 4096-byte buffer (Delete BufferedStdin: prompt(), bun init and the publish OTP question take one line of stdin #36369). The tests use confirm() and a one-byte fs.readSync loop.
  • disown() of process.stdin is not changed. Its setFlowing(false) and updateRef() calls do nothing more for a pipe or a terminal, and a file stdin still needs the first one.
  • The CR of a CRLF line in the loop that runs when a stopped console iterator starts again, on Windows (console iterator: split lines like node:readline #33478).

Tests. 49 new tests in process-stdin.test.ts and 2 in console-iterator.test.ts. The child asks through process.send(), the test writes the next input and answers, so no new test waits for time.

  • 41 fail on the base: 24 next-reader rows for the four ways to let go, a synchronous read after each of 8 ways to let go, confirm(), 200,000 late bytes, a release inside a large delivery, finished() on a stopped stream, a FIFO row, 2 terminal rows, and the 2 console iterator tests.
  • 10 pass on both builds and guard the change. Each clause below fails at least one test when I take it out (built and run):
Taken out Tests that fail
the stop (the base) 39 in process-stdin.test.ts
"keeps its ref" (stop and unref) the 24 next-reader rows
the flowing test in the delivery the large-delivery test
the limit in set_flowing(true) the 300-loop test: 8,388,612 bytes taken, bound 1,048,576
is_stdio && in the scope the Bun.file(fd) over a FIFO test
the scope (every source stops) that test and the child's stdout test
the unref of the other sources "a released reader of a child's stdout lets the process exit"
the line in ConsoleObject.ts both console iterator tests
  • The tests are serial. Concurrent children in this file turned the Windows lanes red before (Jarred on http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528).
  • The two terminal tests and the FIFO tests skip Windows. The large-delivery test runs on Linux only: a socket of macOS does not hold 131,072 bytes.
  • The two tests that need the end of a FIFO run on Linux only. kqueue does not tell a FIFO reader that the last writer closed (bun-serve-file.test.ts:1891 and FdPipe.rs:79 note it), and the first of the two timed out on macOS.
  • Both files: 115 and 19 pass on this PR (debug and release). On the base 76 and 17 pass. One terminal row can pass on the base when the child wins every line.
  • Also run on the release build: test/js/web/streams, test/js/bun/spawn, test/js/bun/console, test/js/node/readline, test/js/node/process, and 55 more files that use releaseLock(), preventCancel or stdin, 141 files in all. 6 tests fail. 3 fail on the base in every run. The other 3 are timing tests that fail on the base in some runs (4 of 8, 5 of 8 and 25 of 60).

Self-review. These recommendations are not followed, or only in part:

  • Remove the two calls in disown() that the hook makes redundant. That function belongs to http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528, which changed it again in 9cd5284. I left it to that PR.
  • A tracking issue and test.todo rows for the forms that are not fixed. No user ran into them, so the list above is the record.
  • Concurrent tests. See above.
  • A ConPTY test for the Windows console. I have no Windows machine here. The console source is out of the scope for that reason.
  • A scope test that reaches the Windows arm. The Bun.file(fd) test needs a FIFO.
  • A doc line on SourceContext::set_ref_unref. The release no longer goes through it.
  • Evidence from public callers of the console iterator. I did not verify it.

Platforms. Linux x64 ran here. Windows and macOS ran in CI only. cargo check passes for x86_64-pc-windows-msvc, aarch64-pc-windows-msvc and aarch64-apple-darwin.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process-stdin.test.ts

@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:23 AM PT - Oct 26th, 2026

❌ @robobun, your commit dfbce35 has 3 failures in Build #132961 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46954

That installs a local version of the PR into your bun-46954 executable, so you can run:

bun-46954 --bun

@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it, on 9cd5284 (the head of #46528), Linux x64:

  • A child reads one line with for await (const line of console) and breaks. Then 200,000 bytes arrive. With no reader, the stream takes 131,072 of them from a pipe, and up to all 200,000 from a socket. With this PR all 200,000 stay on fd 0.
  • On a terminal, a child with inherited stdin gets 1 to 5 of 8 typed lines after the break. With this PR it gets 8 of 8.
  • test/js/node/process/process-stdin.test.ts and test/js/bun/console/console-iterator.test.ts: 41 of the 51 new tests fail on 9cd5284. All pass with this PR.

CI on dfbce35 (build 132961, finished): 181 of 182 jobs passed. Both test files pass on Linux, Windows and macOS (aarch64 and x64). The one red job ran on the macOS agent darwin-aarch64-26.6.2-1 and timed out in tests that this PR does not change: the two next-pages dev server tests could not install their dependencies there, and serve.test.ts timed out. Both files of this PR passed on that agent before the timeout (19 pass, and 112 pass with 3 skipped).

PR: #46954. It is a draft until #46528 merges.

A reader release only dropped the event-loop ref of the native file
source. The source of Bun.stdin.stream() kept its poll on fd 0 and took
input that arrived while no reader was attached: one parked pull, then
one more chunk. A dialog, fs.readSync(0) or a child process with
inherited stdin did not get that input. After a break out of
`for await (const line of console)` on a terminal, a child that reads
stdin got 1 to 5 of 8 typed lines.

The release hook now calls Bun__FileReader__onReaderRelease. A source
that reads stdin, stdout or stderr as a pipe, socket or terminal stops,
and the next reader lock resumes it through resumeNativeSource. A
stopped source keeps nothing alive, so it keeps its ref for that next
reader: a second console loop in an async function now waits for its
input. Every other source reads on and drops its ref as before: a
regular file, a path, another descriptor, a Bun.spawn pipe, and on
Windows a console.

A consumer that stops the source inside the delivery of a chunk ends
the read loop. The loop read once more after a chunk of 131,072 bytes
or more.

set_flowing(true) reads only when the buffer of the source is below
the highwater mark. Without that limit each release and lock adds one
chunk to the buffer.

The console iterator keeps the unfinished line at the end of the chunk
that it stopped in. The next loop lost that start of a line.

The rule "a reader release stops the native source, a reader lock
resumes it" is from d227cc7 in #23341 and from 1d2ebbb in #43747.
Both had no scope. The first stopped the file readers of Windows and
the stdout of a child. The second leaked the descriptor of an abandoned
Bun.file().stream() reader.
@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Order note: #48209 deletes the resume branch of asyncIterator in src/js/builtins/ConsoleObject.ts and keeps one split loop. That loop stores the rest of a chunk that an earlier loop stopped in, which is what the line if (last < actualChunk.length) pendingChunk = actualChunk.subarray(last); of this PR does.

Nothing changes here until #48209 is on main. After that, this PR can drop that line when it is cut again on main. Its tests for the split line stay valid.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant