process.stdin: release the reader in _destroy and make destroy() final

RO
7h ago
+779-58

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 in getStdinStream (src/js/builtins/ProcessObjectInternals.ts:341). removeAllListeners() drops it, an unhandled 'error' skips it, and resume() takes the reader back.
  • process.stdin.pause(); Readable.fromWeb(Bun.stdin.stream()) gives Segmentation fault at address 0x15C on main.

Fix

  • The instance _destroy releases the reader. own() and resume() refuse a destroyed stream, and the 'resume' and 'close' listeners are gone.
  • Pipe, socket and TTY stdin get net.Socket settings: constructed at creation, autoDestroy on, emitClose off.
  • FileReader::set_flowing returns before on_start, as in #46528.
  • Verified: test/js/node/process/process-stdin.test.ts (60 new cases, 53 fail on the base).

Background

  • process.stdin is a Readable over Bun.stdin.stream(). own() takes its reader and refs the event loop.
  • Before construction, _destroy and the first read wait on listeners that removeAllListeners() drops.
  • Considered the hook alone: the construct step stays.

Downsides

  • A destroy() in the turn that first touches stdin is final. On main the next resume() undoes it: a bun --hot reload or a later bun test file reads again. An ESM entry that awaits it spins (#14951).
  • removeAllListeners(); destroy() stops a held Bun.stdin.stream() reader, as a plain destroy() does: a console loop got 12,773 of 100,000 lines.
  • File stdin reads one tick later. Each resume() reads destroyed once. 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() and process.stdin.destroy(). The first 100 are in 60 repositories. 39 of them are a stdin.mjs of hook scripts, with a readStdin(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 getStdinStream and in one native line. src/js/node/tty.ts is not changed.
  • This PR deletes the 'resume' listener that #43100 guards, and keeps the tests of #43100. Two rows of its pause() in 'data', EOF, then resume() test change: after a pause() and a resume() in one turn, no second read is in flight, so endedAtResume is false. Two new rows reach the old state with the pause() and the resume() in two turns.
  • #47087 added the instance _destroy that keeps fd 0 open. fd 0 stays open here: each row of the table test checks fstatSync(0) when the child exits.

Order with open PRs. Conflict regions of a trial merge (git merge-tree --write-tree):

PRProcessObjectInternals.tsprocess-stdin.test.ts
#46528 (http consolidation)20
#47776 (remove libuv on Windows)21, which #43100 alone has too
#30633 (hot reload resets stdio)3, of which #43100 alone has 20
#47974 (terminal tty.ReadStream)00
  • #46528 rewrites own() and disown(), and keeps the 'close' and 'resume' listeners that this PR deletes. By its source (ProcessObjectInternals.ts:354 at edb9ce8f7a), removeAllListeners(); destroy() still leaves the reader there. I did not build that branch.
  • The FileReader::set_flowing line 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 the pause() and destroy() rows of a stdin that never read. The other 23 need the native parts of #46528.
  • #47776 adds an isUnreadable guard to own() and to the 'resume' listener. The guard is for a stdin that it destroys when getReader() throws.
  • #30633 resets the stdio listeners on a --hot reload. Its stdin reset is the place to revive a stdin that a load destroyed.
  • #29141 and #41495 build process.stdin on native handles and replace getStdinStream. #41495 covers a TTY only.
  • #46954 (draft) stops a stdio source when its reader is released. #45195 makes child.stdin constructed 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.

Programmain and #43100this PRNode.js
on('data'), and after 300 ms removeAllListeners(); destroy()exits when the writer closes (2,994 ms)exits at 302 msexits at 302 ms
on('data'); destroy() in the first turnalive, destroyed is falseexitsexits
destroy(err) with an uncaughtException handler and a 'close' listeneralive, no eventuncaught, then 'close' with truethe same
destroy(err) with no handler, then EOFno error printed, exit code 0error: mine, exit code 1exit code 1
destroy(); on('data')flows, emits 'resume'readableFlowing is nullnull
emit('close') by the programdestroys stdinno effectno effect
push({}) on a pipe'error' only'error', 'close', destroyedthe same
an 'end' listener of the program throws (pipe)destroyednot destroyednot destroyed
removeAllListeners(); on('data') in the first turn (pipe)reads nothingreadsreads
for await with a break, then a second loopaliveexitsexits

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:

BuildKeys of 100
main5, 87, 94, 86, 74
#4310089, 89, 51, 90, 90
this PR100, 100, 100, 100, 100
Node.js100, 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() and push(null).
  • 3 kinds of stdin: shell pipe, socket, PTY.

A cell is alive when the child does not exit while the writer is open.

BuildAliveAlive where Node.js exits
Node.js3
main9390
this PR63

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 earliermainthis PR
process.stdin.pause()Segmentation fault at address 0x15Creads
process.stdin.destroy()Segmentation fault at address 0x15Creads
removeAllListeners(); destroy(), or destroy(err)readsreads

The source that Bun.stdin.stream() shares.

  • A stdin that never read, and that nobody holds: pause() and destroy() 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 after removeAllListeners(); destroy() and after an unhandled destroy(err), because nothing is released there.
  • A reader holds Bun.stdin.stream() when process.stdin is destroyed: the source stops, and the program can exit. Main does this for pause() and for a plain destroy(). This PR does it for every destroy(). cat l100k.txt | bun trunc.cjs with a console loop that destroys process.stdin after 10 lines: 12,773 lines and exit code 0 after a plain destroy() 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 plain destroy(). #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 calls process.stdin.destroy(), load 2 adds a 'data' listener. Main reads in load 2. This PR reads nothing.
  • bun test with 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 --isolate both pass.
  • An ESM entry calls process.stdin.destroy() and then runs a top-level for await over 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 the destroy() in a later turn, main waits with no CPU use and does not exit. A bare await new Promise(() => {}) spins on main too (#14951).

Costs for a program that never destroys stdin.

Measuremain#43100this PRNode.js
getStdinStream in the builtins, bytes3,2513,2923,420
release binary, text bytes140,289,307140,289,348140,289,476
nextTick entries before the first _read (pipe, file, PTY)3, 3, 33, 3, 33, 4, 31, 4, 1
Function objects made by the first access (pipe, file, PTY)97, 95, 125the same97, 96, 125
internal 'resume', 'pause', 'end', 'close' listeners (pipe and PTY)1, 1, 1, 1the same0, 1, 0, 0
the same on file stdin1, 1, 1, 1the same0, 1, 1, 0
reads of destroyed in one resume()0010
  • Own properties of process.stdin do not change: 18, 16 and 23 (pipe, file, PTY).
  • disown() reads native.locked once when process.stdin holds no reader.
  • On a pipe or a socket, autoClose, _readableState.autoDestroy and constructed become true and emitClose becomes false, as on a net.Socket. On a TTY only emitClose and constructed change. File stdin keeps every value.
  • FileReader::set_flowing has one more branch.

Decisions.

  • 'close' is emitted by the hook on the next tick, with hadError, also after an 'error' that no listener took. This is what the handle of a net.Socket does. After an exception that nothing catches, Bun still runs the queued ticks. So a 'close' listener runs before the exit. A net.Socket in 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 checks readableEnded, so an emit('end') by the program does not destroy stdin.
  • The stream stays an fs.ReadStream made with new. For a pipe, a socket or a TTY, _readableState.constructed is set at creation. The construct step still runs, and nothing waits for it.
  • The release for pause() stays in the 'pause' listener, which removeAllListeners() also drops. Node.js has the same listener, and removeAllListeners(); pause() keeps both alive until EOF.

Not covered.

  • process.stdin.end() and destroySoon() throw a TypeError on 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 for tty.ReadStream.
  • The stdin of a node:worker_threads worker (src/js/internal/worker/stdio.ts) releases its port from a 'close' listener. In Node.js such a worker stays alive after destroy(), removeAllListeners(); destroy(), destroy(err) and pause(). Bun differs only after a plain destroy(), where the worker exits.
  • removeListener('readable') and unref() (#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: a Bun.spawn pipe, a shell pipe, a PTY and a file. The Bun.spawn pipe is a socket pair on POSIX. Each row expects destroyed, the events, an open fd 0 and exit code 0 while the writer is open. node gives the same reports runs 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() after pause() and after each form of destroy(). 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 after removeAllListeners(); destroy() and destroy(err).
  • test/js/node/test/pseudo-tty/test-readable-tty-keepalive.js is 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.ts passes 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 and child_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-* and test-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() and destroySoon(): they need the methods of net.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