process.stdin: deliver 'end' when pause() inside a 'data' handler races a buffered EOF

Closed
RO
23d ago
+89-12

What does this PR do?

producer | tool where the tool calls rl.close() (or process.stdin.pause()) from inside the first 'line'/'data' handler never sees process.stdin emit 'end', and readableEnded stays false. Node delivers 'end'.

Repro (deterministic, self-spawning):

import { spawn } from "node:child_process";
import readline from "node:readline";

if (process.argv[2] === "child") {
  const out = [];
  const rl = readline.createInterface({ input: process.stdin });
  rl.on("line", l => { out.push("line:" + l); if (out.length === 1) rl.close(); });
  process.stdin.on("end", () => out.push("stdin-end"));
  process.on("exit", () => console.log(JSON.stringify({ out, readableEnded: process.stdin.readableEnded })));
} else {
  const c = spawn(process.execPath, [process.argv[1], "child"], { stdio: ["pipe", "inherit", "inherit"] });
  c.stdin.write("a\nb\nc\n"); c.stdin.end();
}
// node: {"out":["line:a","line:b","line:c","stdin-end"],"readableEnded":true}
// bun:  {"out":["line:a","line:b","line:c"],"readableEnded":false}

Cause

process.stdin wraps Bun.stdin.stream() with a pull-based reader. The 'pause' listener nextTicks disown(), which calls releaseLock() and setFlowing(false) (uv_read_stop on Windows). That runs before maybeReadMore()'s nextTick can issue the next pull, so the EOF that arrived with the chunk is never observed and push(null) never runs.

Node's process.stdin uses a different contract: onpause calls readStop(), but Socket._read() calls readStart(). addChunk() schedules maybeReadMore() after every push, which calls _read(), so the native read is re-armed after the pause and the queued EOF is delivered (lib/internal/bootstrap/switches/is_main_thread.js + lib/net.js).

Fix

getStdinStream:

  • _read (triggerReadOwning) re-own()s when the reader was released, mirroring Node's Socket._read -> readStart(). Gated on hasReceivedData && !isTTY so:
    • pause() before any data still lets the process exit (the construct-time maybeReadMore from fs.ReadStream does not re-own),
    • TTY stdin stays released so a child spawned with stdio:"inherit" is not raced (the behaviour from #23341).
  • After push(null), call stream.read(0) so endReadable() runs even when flow() is stopped. This mirrors Node's onStreamRead (lib/internal/stream_base_commons.js).

Behavior change

pause() from inside a 'data' handler with the pipe still open no longer exits the process on Linux; it stays alive until the pipe closes or unref() is called. This matches Node (Socket._read re-arms readStart() after onpause's readStop()). The previous Bun behaviour was a divergence that made EOF delivery impossible. stdin-fixtures.test.ts is updated to assert the closed-pipe Node-parity case instead.

How did you verify your code works?

Two new cases in test/js/node/process/process-stdin.test.ts (direct pause() and readline.close()), both fail on main and pass with this change on Linux and Windows. stdin-fixtures.test.ts switched to runBoth for the pause case and passes on both platforms. The rest of process-stdin.test.ts, the test-stdin-*/test-readline-* Node parallel tests, readline.node.test.ts, and stdin-pause-resume.test.ts are unchanged.


[review] gate passed · iteration 5 · 3 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/process/process-stdin.test.ts test/js/node/process/stdin/stdin-fixtures.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e904115a7)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [1291.69ms]
(pass) file does the right thing [1287.15ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [1519.72ms]
(pass) stdin with 'readable' event handler should receive data when paused [2266.42ms]
(pass) stdin with 'data' event handler should NOT receive data when paused [2285.91ms]
(pass) a read() that throws does not keep the process alive [1320.82ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [1525.57ms]
(pass) touching stdin again after 'end' does not keep the process alive [1527.32ms]
(pass) 'end' is not emitted when the buffer
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (2a780bca5)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [42.77ms]
(pass) file does the right thing [36.42ms]
(pass) a read() that throws does not keep the process alive [33.09ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [34.78ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [33.91ms]
(pass) touching stdin again after 'end' does not keep the process alive [32.52ms]
(pass) 'end' is not emitted when the buffer is never drained, and the process still exits [31.73ms]
(pass) stdin should allow process to exit when paused [30.15ms]
(pass) pausing inside a 'data' handler with EOF already buffered still delivers 'end' [31.25ms]
(pass) readline close() inside a 'line' handler with piped EOF still lets stdin emit 'end' [31.40ms]
(pass) a throw from a 'data' listener is an uncaughtException, and stdin keeps reading [30.44ms]
(pass) a throw from a 'readable' listener is an uncaughtException, including the EOF emission [40.25ms]
(pass) pause() and resume() churn while data is in flight never destroys stdin [223.47ms]
(pass) stdin should not allow process to exit when n
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/process/process-stdin.test.ts test/js/node/process/stdin/stdin-fixtures.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (e904115a7)

test/js/node/process/process-stdin.test.ts:
(pass) pipe does the right thing [1300.38ms]
(pass) file does the right thing [1292.06ms]
(pass) paused mode read(n) returns the buffered remainder at EOF [1529.76ms]
(pass) stdin with 'readable' event handler should receive data when paused [2267.25ms]
(pass) stdin with 'data' event handler should NOT receive data when paused [2284.00ms]
(pass) a read() that throws does not keep the process alive [1303.13ms]
(pass) explicit read(n) with no 'readable' listener still pulls from stdin [1527.53ms]
(pass) touching stdin again after 'end' does not keep the process alive [1528.89ms]
(pass) 'end' is not emitted when the buffer
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/20] gen JS modules (bundle-modules)
Preprocess modules (7621ms)
Bundle modules (46ms)
Postprocesss modules (191ms)
Bundle Functions (822ms)
Generate Code (110ms)

[8.81s] Bundled "src/js" for production
  2036 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current versio
... (truncated)
diff hotspot
src/js/builtins/ProcessObjectInternals.ts         | 21 +++++++--
 test/js/node/process/process-stdin.test.ts        | 56 +++++++++++++++++++++++
 test/js/node/process/stdin/stdin-fixtures.test.ts | 24 ++++++----
 3 files changed, 89 insertions(+), 12 deletions(-)

gate history · 5 passed · 1 rejected · iteration 5

evidence per changed file
file                                               reads  edits  tests
src/js/builtins/ProcessObjectInternals.ts              7     15      0
test/js/node/process/process-stdin.test.ts             3      1      0
test/js/node/process/stdin/stdin-fixtures.test.ts      4      7      0