Skip to content

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

Open
robobun wants to merge 8 commits into
mainfrom
farm/bd61099f/stdin-end-after-pause-in-data
Open

robobun wants to merge 8 commits into
mainfrom
farm/bd61099f/stdin-end-after-pause-in-data

Conversation

@robobun

@robobun robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

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:
  • 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

…es a buffered EOF

When a piped producer writes all its data and closes in one go, the native
reader delivers the chunk and closes the web stream controller together. A
'data' listener that calls process.stdin.pause() (typically via
readline.Interface#close()) schedules disown() on nextTick, which releaseLock()s
the reader before maybeReadMore() can issue the next pull. The already-queued
{done:true} is dropped, so push(null) never runs and 'end'/readableEnded never
fire.

Fix in two places:
- 'pause' handler: issue one more reader.read() before releaseLock(). If the
  controller is already closed that read is resolved {done:true} and survives
  the release; if it is still open the release rejects it and the existing
  catch re-arms as before.
- EOF path: call stream.read(0) after push(null) so endReadable() runs even
  when flow() is stopped. This matches Node's onStreamRead.

Node's net.Socket does not readStop on pause(), only on buffer-full, so the
EOF read always completes; Bun's pull-based stdin needs the extra pull here
to reach parity.
@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with the self-spawning script from the report (both the readline variant and a reduced process.stdin.pause() inside 'data'). Fix verified locally on Linux and Windows: both new process-stdin.test.ts cases fail on main and pass with the change; stdin-fixtures.test.ts switched to runBoth for the pause case (Node parity). No regressions in the existing process-stdin.test.ts cases, the Node parallel test-stdin-*/test-readline-* suite, readline.node.test.ts, or stdin-pause-resume.test.ts.

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, matching Node.

CI: gate passed (fail-before + pass-after on ASAN and release). All Linux/Windows/Alpine/Debian/Ubuntu test lanes that ran are green on the files this PR touches. Remaining red is unrelated: test-worker-message-port-transfer-terminate.js SIGABRT on debian x64-asan is pre-existing on main, and a handful of flaky tests passed on retry. Ready for maintainer review of the behavior change.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The stdin stream now tracks received data, advances EOF termination after paused reads, and can reacquire native stdin ownership before subsequent reads. Tests cover paused streams, closed readline interfaces, timeout cleanup, and Node/Bun parity.

stdin lifecycle

Layer / File(s) Summary
EOF lifecycle handling
src/js/builtins/ProcessObjectInternals.ts
Tracks received stdin data and invokes read(0) after EOF to complete stream termination.
Reader ownership reacquisition
src/js/builtins/ProcessObjectInternals.ts
Conditionally reacquires stdin ownership in _read() before delegating to the existing read trigger.
EOF regression coverage
test/js/node/process/process-stdin.test.ts, test/js/node/process/stdin/stdin-fixtures.test.ts
Adds paused and readline-close EOF cases, updates timeout cleanup, and compares pause behavior across Node and Bun.

Possibly related PRs

  • oven-sh/bun#34019: Modifies stdin read/EOF behavior and adds related process.stdin listener and EOF scenarios.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main fix: stdin now emits 'end' when pause() races a buffered EOF in a data handler.
Description check ✅ Passed The description follows the template and includes the bug, cause, fix, behavior change, and verification details.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:28 AM PT - Jul 18th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 34613

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

bun-34613 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the comment-length nit, I traced the new internalRead(stream) call in the 'pause' handler against disown()'s releaseLock(): the catch keys on readerForThisRead !== reader, so a non-EOF pending read that gets rejected by the release lands in the re-arm path (not stream.destroy(err)). Also checked that stream.read(0) after push(null) doesn't re-own() (the size !== 0 guard on the read override skips it), and that the existing "'end' not emitted when buffer never drained" test is unaffected (read(0) only calls endReadable() when state.length === 0). Deferring because getStdinStream has subtle nextTick/microtask ordering and a history of regressions — worth a maintainer's eyes.

Extended reasoning...

Traced the two logic additions through the pause→nextTick→disown ordering, the releaseLock rejection path in internalRead's catch, the stream.read override's size!==0 guard, and the stream_reachedEof guard on own(). Found no double-push(null), no spurious destroy(), and no re-own() after EOF. The existing pause/resume churn test covers the case where resume() runs before the pause nextTick fires (readableFlowing guard short-circuits the new internalRead). The only reported finding is a 4-line comment vs the 3-line CLAUDE.md rule, which is cosmetic. Still deferring: process.stdin's own/disown machinery is race-prone and this file has accumulated several regression fixes; a maintainer familiar with its history should confirm the microtask-vs-nextTick reasoning in the PR description.

Comment thread src/js/builtins/ProcessObjectInternals.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tightening the comment. I traced the fix and it looks correct to me, but I'd like a human to confirm one assumption it relies on: in the 'pause' handler, internalRead(stream) is called and then disown() runs synchronously right after. The fix depends on a closed controller's reader.read() returning an already-resolved {done:true} promise that survives the immediately-following releaseLock() — that holds per the WHATWG spec, but it's worth a maintainer's eye given how much of getStdinStream hinges on exact nextTick/microtask/releaseLock ordering.

Checked: the size !== 0 guard in the overridden stream.read means the new read(0) never re-own()s; the "buffer never drained" and "exit when paused" tests still pass; and the non-EOF case (extra read pulls a data chunk into the paused buffer, or pends and is rejected by releaseLock()) re-arms the same way as before.

Extended reasoning...

Overview

Two additions to getStdinStream in src/js/builtins/ProcessObjectInternals.ts (9 lines) plus two new test cases in process-stdin.test.ts (56 lines). The fix addresses a race where pause() inside a 'data' handler causes the 'pause' listener's nextTick'd releaseLock() to run before maybeReadMore() can issue the read that would observe the already-queued EOF, so push(null) never happens and 'end' never fires.

The two changes: (1) in the 'pause' nextTick, issue one more internalRead() before disown() so an already-closed controller's synchronously-resolved {done:true} reaches push(null); (2) after push(null), call stream.read(0) (mirroring Node's onStreamRead) so endReadable() runs even when flow is stopped.

Security risks

None. This is Node-compat stream plumbing on process.stdin; no parsing of untrusted input, no auth/crypto/permissions.

Level of scrutiny

Medium-high. src/js/builtins/ is hot-path builtin code, and getStdinStream is already a carefully-ordered dance between the Node Readable state machine, the WHATWG reader, and nextTick scheduling. The fix is small and well-argued (with a Node upstream citation for read(0)), but its correctness rests on a subtle spec detail: reader.read() on a closed-and-drained stream returns an already-fulfilled promise with no pending read-request entry, so the immediately-following releaseLock() cannot reject it. I verified this holds per the Streams spec, but a maintainer who knows Bun's ReadableStream internals should confirm it holds in practice.

Other factors

  • I traced the non-EOF branches: if the extra read pends, releaseLock() rejects it into the existing readerForThisRead !== reader catch → needsInternalReadRefresh, identical to pre-PR behavior. If it resolves with a data chunk, the chunk is pushed into the paused buffer and delivered on the next resume() via flow() → _read() — no data loss, no hang.
  • The overridden stream.read skips own() when size === 0, and own() itself early-returns once stream_reachedEof is set, so the new read(0) cannot re-acquire the reader.
  • Test evidence is solid: both new tests fail on main (debug + release) and pass with the fix; all pre-existing process-stdin.test.ts cases (including "exit when paused", "buffer never drained", and "pause/resume churn") still pass.
  • My earlier nit (4-line comment) was addressed in fa67f5f.

Deferring rather than approving because the correctness argument depends on precise promise-resolution-vs-releaseLock ordering in builtin code — the kind of thing REVIEW.md flags for maintainer sign-off.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the assumption flagged above: verified empirically against Bun's C++ ReadableStream, not just the spec.

const rs = new ReadableStream({ start(c) { c.enqueue(new Uint8Array([1,2,3])); c.close(); } });
const reader = rs.getReader();
await reader.read();                 // {value: <3 bytes>, done: false}
const p = reader.read();             // controller closed + queue empty
reader.releaseLock();
console.log(await p);                // {value: undefined, done: true}  (not rejected)

Same result in Node v26.3.0. A read issued against an open controller with no queued chunk does get rejected by releaseLock(), which the existing readerForThisRead !== reader catch already handles (re-arms via needsInternalReadRefresh).

…OF survives pause()

The earlier approach (pull once in the pause handler before releaseLock()) worked
on POSIX where the reader sees data+EOF in one synchronous read, but not on
Windows where libuv delivers data and EOF as separate IOCP completions.

Match Node's onpause/Socket._read contract instead: pause() disowns (readStop),
and _read() re-owns (readStart). addChunk()'s maybeReadMore() calls _read() after
every push(), so the pull that observes the queued EOF runs with an owned reader
on both platforms.

Gate the re-own on hasReceivedData && !isTTY so the construct-time maybeReadMore
from fs.ReadStream (stdin exits when paused before any data) and TTY pause
(child inheriting stdin) keep their existing behaviour.
Comment thread src/js/builtins/ProcessObjectInternals.ts
robobun added 2 commits July 18, 2026 11:38
The open-pipe variant asserted a Bun-specific divergence from Node (exit
after pause() mid-stream with the pipe still open). _read now re-arms the
native read after pause() so a queued EOF is delivered (Node's
onpause/Socket._read contract); with an open pipe that keeps the process
alive on Linux as Node does. Repoint the case at the closed-pipe scenario,
which is the behaviour this PR fixes and is identical in Bun and Node on
every platform.
The 1s autokill is shorter than a debug-build child's startup, so every
case in this file was autoKilled under bun bd. Scale to 10s when isDebug
and clear the timer on close so release runs stay fast.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/process/stdin/stdin-fixtures.test.ts`:
- Around line 27-33: Update the rejection paths in the stdin fixture test to
clear killTimer before rejecting, including both the error and exhausted-input
paths. Preserve the existing rejection behavior while ensuring failed setup
cancels the pending timer immediately rather than waiting for close.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: addea058-9ab6-44b5-b4c9-4cdc6e65a239

📥 Commits

Reviewing files that changed from the base of the PR and between 511caaa and 2ca4879.

📒 Files selected for processing (3)
  • src/js/builtins/ProcessObjectInternals.ts
  • test/js/node/process/process-stdin.test.ts
  • test/js/node/process/stdin/stdin-fixtures.test.ts

Comment thread test/js/node/process/stdin/stdin-fixtures.test.ts
Comment thread src/js/builtins/ProcessObjectInternals.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No open findings from this pass — the earlier three threads are all addressed. Deferring to a maintainer rather than auto-approving because this changes process-lifetime semantics: pause() inside a 'data' handler with the pipe still open now keeps the process alive on Linux (Node parity), and the stdin-fixtures "pause allows process to exit" case was reworked from open-pipe (end: false) to closed-pipe (end: true) with the open-pipe variant now acknowledged as platform-divergent (Linux vs Windows). That trade-off looks right to me but is a behavior/default change worth a human sign-off.

What was reviewed:

  • triggerReadOwning gating (hasReceivedData && !isTTY) against the pre-data maybeReadMore kick and TTY inherit (#23341) — both still exit/disown as before.
  • stream.read(0) after push(null) vs the size !== 0 / stream_reachedEof guards — no re-own after EOF; matches Node's onStreamRead.
  • releaseLock() racing the extra pull in the 'pause' handler — verified against Bun's ReadableStream that a read on a closed controller resolves {done:true} rather than rejecting.
Extended reasoning...

Overview

The PR touches getStdinStream in src/js/builtins/ProcessObjectInternals.ts (the process.stdin bridge over Bun.stdin.stream()), plus two test files. The runtime change: (1) _read becomes triggerReadOwning, which re-own()s the native reader when !reader && hasReceivedData && !isTTY && !stream_reachedEof && !stream_destroyed, mirroring Node's Socket._read → readStart(); (2) internalRead sets hasReceivedData on the first chunk and issues stream.read(0) after push(null) so endReadable() runs even when flow() is stopped; (3) the why-comment above the stream.read override is refreshed. Tests add two new process-stdin.test.ts cases (direct pause() and readline.close()), and stdin-fixtures.test.ts replaces the Bun-only open-pipe pause case with a runBoth closed-pipe case, plus harness hardening (isDebug-scaled kill timeout, clearTimeout on reject paths).

Security risks

None identified. This is Node-compat stream lifecycle logic in a builtin module; no untrusted-input parsing, auth, crypto, or filesystem paths are involved. The builtin uses $call and closure-captured locals, so tamper-resistance is unchanged.

Level of scrutiny

High. getStdinStream is a small but very stateful piece of code — own/disown, ref/unref, TTY vs pipe vs file, the #23341 TTY-inherit constraint, and multiple nextTick orderings all interact. Three prior review rounds on this PR each surfaced a real issue (comment length, a genuine test regression from the re-own, and a stale why-comment), all now fixed. The mechanism is well-argued against Node's lib/net.js / stream_base_commons.js, and the new tests fail on main and pass with the fix.

Other factors

The reason I'm not auto-approving is the intentional behavior change and the accompanying test replacement. Previously Bun let a process exit after pause() inside a 'data' handler even with the pipe still open; now (on Linux) it stays alive until the pipe closes or unref() is called, matching Node. The old stdin-fixtures test that asserted the Bun-specific exit was converted to a closed-pipe Node-parity test, and the author notes the open-pipe case is now platform-dependent (Linux matches Node, Windows still exits) and out of scope. REVIEW.md flags both "never silently weaken an existing test" (this one is stated, not silent, but it does drop coverage of the open-pipe scenario) and behavior/default changes as things a maintainer should weigh. That call — accepting the Node-parity lifetime change plus a known Windows divergence in exchange for correct 'end' delivery — is reasonable but should be made by a human.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any further issues — all three earlier threads are resolved — but this changes user-visible process.stdin lifetime semantics (pause() after data on an open pipe now keeps the process alive on Linux), introduces an acknowledged Linux/Windows divergence in that same case, and the macOS CI lanes didn't pick up the build, so a maintainer should sign off on the behavior change.

What was reviewed:

  • triggerReadOwning gating (hasReceivedData && !isTTY) against the pre-data pause-exits case and TTY child-inherit (#23341) — both preserved.
  • stream.read(0) after push(null): stream_reachedEof is set first, so neither the wrapper's size !== 0 guard nor triggerReadOwning re-owns on that call.
  • The rewritten stdin-fixtures pause case: input changed from end: false to end: true with a stated rationale; the dropped open-pipe assertion is the behavior change called out in the description.
Extended reasoning...

Overview

The PR fixes a Node-compat gap where process.stdin never emits 'end' if pause() (or rl.close()) is called from inside a 'data'/'line' handler while an EOF is already queued in the underlying web stream. The fix is in src/js/builtins/ProcessObjectInternals.ts getStdinStream: a new triggerReadOwning _read re-acquires the native reader (mirroring Node's Socket._read → readStart()), gated on hasReceivedData && !isTTY; and stream.read(0) is issued after push(null) so endReadable() runs even when flow() is stopped. Two new tests in process-stdin.test.ts cover the direct-pause() and readline.close() variants; stdin-fixtures.test.ts converts the pause case to runBoth({end: true}) and adds killTimer cleanup + a debug-sized timeout.

Security risks

None. This is stream lifecycle/event-ordering logic in a built-in JS module; no parsing of untrusted input, no auth/crypto, no new API surface.

Level of scrutiny

High. getStdinStream is hot-path built-in code with a history of subtle races (nextTick ordering between disown() and maybeReadMore_, releaseLock() rejecting in-flight reads, TTY child-inherit from #23341). More importantly, the PR ships a documented behavior change: on Linux, pause() from inside a 'data' handler with the pipe still open no longer lets the process exit — it stays alive until the pipe closes or unref() is called. That matches Node, but it inverts prior Bun behavior that an existing test asserted. The author also notes this introduces a Linux/Windows divergence (Windows still exits) that "would need a separate look at updateRef on Windows". A maintainer should confirm that trading the old divergence-from-Node for a new divergence-between-platforms is the right call here, and that no downstream Bun users depend on the old exit-on-pause behavior.

Other factors

  • All three of my earlier inline threads (4-line comment, the stdin-fixtures regression, and the stale stream.read why-comment) were addressed and resolved; the current diff reflects those fixes.
  • The stdin-fixtures.test.ts "pause allows process to exit" case had its input changed (["abc","pause","def"], end:false → ["abc","pause"], end:true) rather than being kept alongside a new case. REVIEW.md flags input-mutating an existing test; the author's rationale (the open-pipe variant is now platform-dependent and asserts a Bun-only divergence this PR removes) is stated in the thread and in the test comment, but it's still a weakening a human should ack.
  • CI: Linux/Windows/Alpine/Debian/Ubuntu green per the gate; the three darwin test lanes expired without an agent, so macOS is untested for this change.
  • The mechanism itself checks out: hasReceivedData keeps the construct-time maybeReadMore from re-owning (so "stdin should allow process to exit when paused" still passes), !isTTY preserves the child-inherit path, and stream_reachedEof set before disown()/push(null)/read(0) prevents that final read(0) from re-owning via either the wrapper or triggerReadOwning.

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