process.stdin: release the reader in _destroy and make destroy() final
Stacked on #43100. Conflicts with #46528 and #47776 (Notes).
Problem
process.stdin.removeAllListeners(); process.stdin.destroy()keeps the native reader on fd 0. With a quiet writer, the process exits when that writer closes (2,994 ms, Node.js 302 ms). A child on the same PTY got 5 to 94 of 100 keys.- The release on
destroy()is a'close'listener ingetStdinStream(src/js/builtins/ProcessObjectInternals.ts:341).removeAllListeners()drops it, an unhandled'error'skips it, andresume()takes the reader back. process.stdin.pause(); Readable.fromWeb(Bun.stdin.stream())givesSegmentation fault at address 0x15Con main.
Fix
- The instance
_destroyreleases the reader.own()andresume()refuse a destroyed stream, and the'resume'and'close'listeners are gone. - Pipe, socket and TTY stdin get
net.Socketsettings: constructed at creation,autoDestroyon,emitCloseoff. FileReader::set_flowingreturns beforeon_start, as in #46528.- Verified:
test/js/node/process/process-stdin.test.ts(60 new cases, 53 fail on the base).
Background
process.stdinis aReadableoverBun.stdin.stream().own()takes its reader and refs the event loop.- Before construction,
_destroyand the first read wait on listeners thatremoveAllListeners()drops. - Considered the hook alone: the construct step stays.
Downsides
- A
destroy()in the turn that first touches stdin is final. On main the nextresume()undoes it: abun --hotreload or a laterbun testfile reads again. An ESM entry that awaits it spins (#14951). removeAllListeners(); destroy()stops a heldBun.stdin.stream()reader, as a plaindestroy()does: a console loop got 12,773 of 100,000 lines.- File stdin reads one tick later. Each
resume()readsdestroyedonce. The builtin grows by 128 bytes.
Notes
Scope.
- No issue reports this. A code search on GitHub finds 196 public files that contain both
process.stdin.removeAllListeners()andprocess.stdin.destroy(). The first 100 are in 60 repositories. 39 of them are astdin.mjsof hook scripts, with areadStdin(timeoutMs)that makes the two calls when the time is over. Its purpose is that the hook does not wait on a parent that keeps stdin open. The 300 ms row below is that helper. - #43100 names this as a case that its fix does not cover. #35621 proposed the release in
_destroy. It was closed in favor of the first version of #43100, which no longer has this part. - The change is in
getStdinStreamand in one native line.src/js/node/tty.tsis not changed. - This PR deletes the
'resume'listener that #43100 guards, and keeps the tests of #43100. Two rows of itspause() in 'data', EOF, then resume()test change: after apause()and aresume()in one turn, no second read is in flight, soendedAtResumeisfalse. Two new rows reach the old state with thepause()and theresume()in two turns. - #47087 added the instance
_destroythat keeps fd 0 open. fd 0 stays open here: each row of the table test checksfstatSync(0)when the child exits.
Order with open PRs. Conflict regions of a trial merge (git merge-tree --write-tree):
| PR | ProcessObjectInternals.ts | process-stdin.test.ts |
|---|---|---|
| #46528 (http consolidation) | 2 | 0 |
| #47776 (remove libuv on Windows) | 2 | 1, which #43100 alone has too |
| #30633 (hot reload resets stdio) | 3, of which #43100 alone has 2 | 0 |
#47974 (terminal tty.ReadStream) | 0 | 0 |
- #46528 rewrites
own()anddisown(), and keeps the'close'and'resume'listeners that this PR deletes. By its source (ProcessObjectInternals.ts:354at edb9ce8f7a),removeAllListeners(); destroy()still leaves the reader there. I did not build that branch. - The
FileReader::set_flowingline is the one of #46528 (44e485859c). The two merge to the same text. - #46528 adds 56 tests for the next reader of
Bun.stdin.stream(). Release builds: main and #43100 pass 17, this PR passes 33. No test that passes on main fails here. The 16 more are thepause()anddestroy()rows of a stdin that never read. The other 23 need the native parts of #46528. - #47776 adds an
isUnreadableguard toown()and to the'resume'listener. The guard is for a stdin that it destroys whengetReader()throws. - #30633 resets the stdio listeners on a
--hotreload. Its stdin reset is the place to revive a stdin that a load destroyed. - #29141 and #41495 build
process.stdinon native handles and replacegetStdinStream. #41495 covers a TTY only. - #46954 (draft) stops a stdio source when its reader is released. #45195 makes
child.stdinconstructed from the start.
Measured behaviour. Linux x64. Release builds of main (7ca0042d3b), of #43100 and of this PR. Node.js v26.3.0. The writer of stdin stays open unless a row says EOF. Main and #43100 give the same result in every row.
| Program | main and #43100 | this PR | Node.js |
|---|---|---|---|
on('data'), and after 300 ms removeAllListeners(); destroy() | exits when the writer closes (2,994 ms) | exits at 302 ms | exits at 302 ms |
on('data'); destroy() in the first turn | alive, destroyed is false | exits | exits |
destroy(err) with an uncaughtException handler and a 'close' listener | alive, no event | uncaught, then 'close' with true | the same |
destroy(err) with no handler, then EOF | no error printed, exit code 0 | error: mine, exit code 1 | exit code 1 |
destroy(); on('data') | flows, emits 'resume' | readableFlowing is null | null |
emit('close') by the program | destroys stdin | no effect | no effect |
push({}) on a pipe | 'error' only | 'error', 'close', destroyed | the same |
an 'end' listener of the program throws (pipe) | destroyed | not destroyed | not destroyed |
removeAllListeners(); on('data') in the first turn (pipe) | reads nothing | reads | reads |
for await with a break, then a second loop | alive | exits | exits |
A parent on a PTY reads one key, calls removeAllListeners(); destroy(), and spawns a child with inherited stdio. The driver types 100 keys. Keys that the child received, 5 runs each:
| Build | Keys of 100 |
|---|---|
| main | 5, 87, 94, 86, 74 |
| #43100 | 89, 89, 51, 90, 90 |
| this PR | 100, 100, 100, 100, 100 |
| Node.js | 100, 100, 100, 100, 100 |
With a plain destroy() in the parent, every build gives 100.
A matrix of 105 cells:
- 5 ways to take the reader:
on('data'),on('readable'),resume(),ref(),read(1). - 7 orders of
destroy(),destroy(err),removeAllListeners()andpush(null). - 3 kinds of stdin: shell pipe, socket, PTY.
A cell is alive when the child does not exit while the writer is open.
| Build | Alive | Alive where Node.js exits |
|---|---|---|
| Node.js | 3 | |
| main | 93 | 90 |
| this PR | 6 | 3 |
The 3 cells that remain are ref(); removeAllListeners('end'); push(null) on each kind. No destroy() is called there. Bun exits when the writer closes (2,995 ms), and Node.js exits in 3 ms.
A consumer that never destroys stdin: pause() in 'data', then the work and resume() in a setImmediate, with 300,000 bytes of input. Debug builds of the two heads: 'end' comes before the work of the last chunk in 6 of 80 runs on #43100 and in 0 of 320 here. Node.js: 0 of 20. The 'resume' listener made a third read per resume(), and the last chunk and EOF could then arrive while the consumer was paused.
The bound on main: a writer that keeps sending fills the native buffer. The stranded reader then stops at 16,384 bytes and lets go of the event loop. So "exits when the writer closes" is the case of a writer that sends little or nothing.
The crash. A pause of a source that has not started records flowing = false. Readable.fromWeb() then calls setFlowing(true) before start(), and FileReader::set_flowing reads through a reader that has no parent. pause() and a plain destroy() reach it on main. The guard returns before on_start.
Before Readable.fromWeb(Bun.stdin.stream()), two turns earlier | main | this PR |
|---|---|---|
process.stdin.pause() | Segmentation fault at address 0x15C | reads |
process.stdin.destroy() | Segmentation fault at address 0x15C | reads |
removeAllListeners(); destroy(), or destroy(err) | reads | reads |
The source that Bun.stdin.stream() shares.
- A stdin that never read, and that nobody holds:
pause()anddestroy()leave the ref of the source alone. The next reader reads stdin to its end and keeps the process alive. On main this works only afterremoveAllListeners(); destroy()and after an unhandleddestroy(err), because nothing is released there. - A reader holds
Bun.stdin.stream()whenprocess.stdinis destroyed: the source stops, and the program can exit. Main does this forpause()and for a plaindestroy(). This PR does it for everydestroy().cat l100k.txt | bun trunc.cjswith a console loop that destroysprocess.stdinafter 10 lines: 12,773 lines and exit code 0 after a plaindestroy()on main, and after each form here. #46528 keeps this rule (lets a program exit that holds a reader). - A stdin that read, then
destroy(), then a new reader: the source is paused and the reader gets nothing, as on main after a plaindestroy(). #46528 and #46954 change this in native code.
Behaviour that programs can depend on, and lose. Each case needs a destroy() in the turn that first touches stdin. Main does not complete that destroy(): the next resume() sets destroyed back to false. A destroy() in a later turn is final on main too. The first two programs then read nothing on both builds.
bun --hot: load 1 adds a'data'listener and callsprocess.stdin.destroy(), load 2 adds a'data'listener. Main reads in load 2. This PR reads nothing.bun testwith two files in one process: file A does the same, file B reads stdin. Main passes both. This PR fails B (TIMEOUT destroyed=true). With--isolateboth pass.- An ESM entry calls
process.stdin.destroy()and then runs a top-levelfor awaitover readline. Main reads the lines. This PR never settles the await: the process uses a full CPU (2.99 s of user time in 3.04 s) and does not exit. Node.js exits with code 13. With thedestroy()in a later turn, main waits with no CPU use and does not exit. A bareawait new Promise(() => {})spins on main too (#14951).
Costs for a program that never destroys stdin.
| Measure | main | #43100 | this PR | Node.js |
|---|---|---|---|---|
getStdinStream in the builtins, bytes | 3,251 | 3,292 | 3,420 | |
release binary, text bytes | 140,289,307 | 140,289,348 | 140,289,476 | |
nextTick entries before the first _read (pipe, file, PTY) | 3, 3, 3 | 3, 3, 3 | 3, 4, 3 | 1, 4, 1 |
| Function objects made by the first access (pipe, file, PTY) | 97, 95, 125 | the same | 97, 96, 125 | |
internal 'resume', 'pause', 'end', 'close' listeners (pipe and PTY) | 1, 1, 1, 1 | the same | 0, 1, 0, 0 | |
| the same on file stdin | 1, 1, 1, 1 | the same | 0, 1, 1, 0 | |
reads of destroyed in one resume() | 0 | 0 | 1 | 0 |
- Own properties of
process.stdindo not change: 18, 16 and 23 (pipe, file, PTY). disown()readsnative.lockedonce whenprocess.stdinholds no reader.- On a pipe or a socket,
autoClose,_readableState.autoDestroyandconstructedbecometrueandemitClosebecomesfalse, as on anet.Socket. On a TTY onlyemitCloseandconstructedchange. File stdin keeps every value. FileReader::set_flowinghas one more branch.
Decisions.
'close'is emitted by the hook on the next tick, withhadError, also after an'error'that no listener took. This is what the handle of anet.Socketdoes. After an exception that nothing catches, Bun still runs the queued ticks. So a'close'listener runs before the exit. Anet.Socketin Bun does the same today.- File stdin. Node.js does not destroy a file stdin after
'end'. Bun does, and'close'follows. This PR keeps that, with one listener that checksreadableEnded, so anemit('end')by the program does not destroy stdin. - The stream stays an
fs.ReadStreammade withnew. For a pipe, a socket or a TTY,_readableState.constructedis set at creation. The construct step still runs, and nothing waits for it. - The release for
pause()stays in the'pause'listener, whichremoveAllListeners()also drops. Node.js has the same listener, andremoveAllListeners(); pause()keeps both alive until EOF.
Not covered.
process.stdin.end()anddestroySoon()throw aTypeErroron a pipe, a socket or a TTY. Node.js returns and destroys.- Raw mode.
destroy()leaves the terminal as it is, as on main. #47974 and #41495 have the rules fortty.ReadStream. - The stdin of a
node:worker_threadsworker (src/js/internal/worker/stdio.ts) releases its port from a'close'listener. In Node.js such a worker stays alive afterdestroy(),removeAllListeners(); destroy(),destroy(err)andpause(). Bun differs only after a plaindestroy(), where the worker exits. removeListener('readable')andunref()(#29126) still leave the parent reading.- Bytes that the native reader took before
destroy()do not go back to the pipe.
On current releases. process.stdin.unref?.() after destroy() lets the process exit (310 ms in the probe above). It does not stop the reads: the PTY child still lost keys (43 to 61 of 100 arrived).
Tests.
destroy()block, 60 cases. A table of 27 rows runs on four kinds of stdin: aBun.spawnpipe, a shell pipe, a PTY and a file. TheBun.spawnpipe is a socket pair on POSIX. Each row expectsdestroyed, the events, an open fd 0 and exit code 0 while the writer is open.node gives the same reportsruns the same rows under Node.js and compares. It needs Node.js 26.1 or later.- Bytes after
destroy(): 3 rows on a socket pair check that bytes written later stay in the socket. 2 rows on a PTY check that a line typed later goes to a child that inherits stdin. All are POSIX. - The next reader: 8 rows read
Bun.stdin.stream()afterpause()and after each form ofdestroy(). 6 rows hold a reader while stdin is destroyed. - With the source of #43100, a debug build and a release build each pass 35 and fail 55: 53 of the 60 cases, and the 2 changed rows of the pause test. The 7 cases that pass on both are
node gives the same reports, one row on file stdin and one socket-pair row. The other 4 are the next-reader rows afterremoveAllListeners(); destroy()anddestroy(err). test/js/node/test/pseudo-tty/test-readable-tty-keepalive.jsis the Node.js v26.3.0 file, not changed.
Suites. Linux x64 only. I cannot run macOS or Windows. The release builds are at 7ca0042d3b. The branch now has main at 2596ac5d39.
- Debug build of this branch:
process-stdin.test.tspasses 90 of 90, 2 runs. - CI build 138495, first attempt of the file: macOS aarch64, macOS x64 and Debian x64 ASAN pass 90 of 90. Windows 2019 x64 and Windows 11 aarch64 pass 67 and skip 17, which are the POSIX cases.
- Release build: 30 of 30 runs of the file pass, one at a time. With 4 runs of the file at once, 38 of 40 pass. Both failures are
explicit read(n) with no 'readable' listener still pulls from stdin, a test of main. It fails at the same rate with the file of #43100 (5 of 100). - 122 more files for stdin, readline, the console,
node:tty, streams,Bun.spawn, the terminal andchild_process: 2,675 pass and 3 fail here, 2,674 pass and 4 fail on main. The 3 are the same on both (spawn_waiter_thread.test.ts,child_process.test.ts,streams-leak.test.ts). The fourth on main passed in 3 more runs. - Node's
test-stdin-*,test-readline-*,test-tty-*andtest-repl-*(114 files): the same exit codes on both. 108 exit 0, and 6 REPL files exit 1.
Self-review. 37 concerns raised, 32 addressed. An earlier version changed tty.ts to leave raw mode in destroy(). The review showed that it left a terminal raw on a tty.ReadStream that the program made. That change is gone. Not changed, with the reason:
process.stdin.end()anddestroySoon(): they need the methods ofnet.Socket, which the native handles bring.- The read-ahead of the native reader: #46954 has it.
- The worker stdin: Node.js keeps such a worker alive (see Not covered).
- A table of descriptor-level readers after
destroy()(Bun.stdin.text(),prompt(),fs.readFileSync(0)): the socket-pair rows and the PTY rows check the same bytes at the descriptor. - A tracking issue for the cases under Not covered: none exists. Each case is listed there with what Node.js does.
no test proof · iteration 5 · 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