Skip to content

process: make process.stdin Node-faithful for all fd types - #29134

Closed
alii wants to merge 3 commits into
mainfrom
ali/stdin-full-node-parity
Closed

alii wants to merge 3 commits into
mainfrom
ali/stdin-full-node-parity

Conversation

@alii

@alii alii commented Apr 10, 2026

Copy link
Copy Markdown
Member

Fixes #29126. Supersedes #29121 and #29127.

What

process.stdin is now constructed exactly as Node's getStdin() does:

fd type Before After
TTY tty.ReadStream (extended fs.ReadStream) tty.ReadStream extends net.Socket, _handle = native TTY
PIPE fs.ReadStream via Bun.stdin.stream() bridge net.Socket({fd}), _handle = native Pipe
FILE fs.ReadStream fs.ReadStream (unchanged)

After on('readable') → removeListener('readable'), Readable stops calling _read(); the next chunk's push() returns false (highWaterMark: 0 on TTY) → onStreamRead calls handle.readStop() → fd 0 is released and a stdio: 'inherit' child reads it exclusively. No listener tracking, no _readableState pokes, no instance method overrides — Node's backpressure mechanism, driven by Node's handle architecture.

How

Native:

  • node_util_binding.zig: guessHandleType(fd) (POSIX isatty/fstat/SO_TYPE, Windows uv_guess_handle)
  • Pipe.zig + pipe.classes.ts (new): two-step new Pipe(type) + .open(fd), readStart/readStop/ref/unref/close/onread with (nread, ArrayBuffer) encoding (Node's stream_base contract)
  • TTY.zig: onread → (nread, ArrayBuffer); removed pause/resume/$write/$end shims (Node's wraps don't have them); negative-errno returns
  • ProcessBindingPipeWrap.{cpp,h} (new): process.binding('pipe_wrap') with {Pipe, constants}

JS:

  • net.ts: createHandle(fd) via guessHandleType; Socket({fd}) → Pipe handle; Socket({handle, manualStart}); onStreamRead(nread, buf) per Node's stream_base_commons; pause/resume/read gated on kBuffer (Node's pattern); readable/writable options honored
  • tty.ts: ReadStream matches Node's lib/tty.js line-for-line
  • ProcessObjectInternals.ts: the Bun.stdin.stream()/own/disown/internalRead/listener-override bridge replaced by Node's switch — −168 net lines

Node-parity corrections (deliberate behavior changes)

  • process.stdin.end is a function (was the number Infinity for pipes — .end() threw)
  • process.stdin.writable === false for pipes
  • process.stdin instanceof net.Socket === true for TTY and pipe
  • pause() alone doesn't let the process exit if the pipe is open (Node doesn't either)

Test plan

  • test/regression/issue/29126.test.ts — PTY-based fd-release; fails on system bun
  • test/js/node/tty-readstream-prototype.test.ts — instanceof net.Socket, _handle: TTY, hwm=0
  • test/js/node/process/stdin/stdin-pipe-prototype.test.ts — instanceof net.Socket, _handle: Pipe, .end() works
  • stdin/stdio/tty/readline/stream/net suites: 251 pass / 0 fail
  • bun run zig:check-all — all platforms
  • Adversarial: removeAllListeners(), pause/resume cycles, once('readable') re-subscribe, 1MB pipe, empty pipe, write-to-readonly, destroy-then-read, net.Socket({fd:999})/{fd:-1}, TCP pause/resume regression — all match Node

Follow-ups

  • Pipe writeBuffer/shutdown are ENOTSUP stubs (stdin is writable:false; full duplex net.Socket({fd}) write support is a separate effort)
  • process.binding('pipe_wrap') props are non-enumerable (Object.keys returns []; getOwnPropertyNames works) — cosmetic
  • IPC-on-fd-0 guard not implemented (exotic; process.channel.fd comparison would need IPC fd surfaced from VirtualMachine)

alii added 2 commits April 10, 2026 11:54
Fixes #29126.

After process.stdin.on('readable', fn) → removeListener('readable', fn),
Bun kept polling fd 0, so a stdio:'inherit' child raced the parent for
input. Node releases fd 0 via backpressure: tty.ReadStream extends
net.Socket with readableHighWaterMark: 0, so push() returns false on
every chunk and the handle's readStop() fires; _read() re-acquires only
when a consumer pulls.

Bun's tty.ReadStream extended fs.ReadStream (with default hwm) and the
stdin wrapper had no backpressure-driven stop. This rewrites the TTY
path to match Node's architecture:

Native:
- New Zig TTY handle (tty.classes.ts + TTY.zig) backed by
  bun.io.BufferedReader + StreamingWriter, exposing readStart/readStop/
  setRawMode/getWindowSize/ref/unref/close/onread plus the usocket
  surface net.Socket expects (pause/resume/$write/$end/bytesWritten).
  JSRef for GC liveness; fd reopened O_NONBLOCK with original-fd
  fallback (libuv-style).
- ProcessBindingTTYWrap.cpp: deleted C++ TTYWrapObject; the codegen TTY
  is now process.binding('tty_wrap').TTY.

JS:
- tty.ReadStream: extends net.Socket, _handle = new TTY(fd),
  readableHighWaterMark: 0, manualStart: true — line-for-line Node
  lib/tty.js.
- net.Socket: accepts {handle, manualStart}; initSocketHandle wires
  onread/ondrain when _handle.readStart is callable; new
  onStreamRead/tryReadStart (Node stream_base_commons pattern).
- ProcessObjectInternals.ts getStdinStream: TTY branch is now just
  new tty.ReadStream(fd) + readStop + 'pause' listener (Node
  is_main_thread.js parity). Pipe/file path unchanged.

After removeListener('readable'), Readable stops calling _read(); the
in-flight read delivers one chunk, push() returns false, readStop()
unregisters the poll, and the child reads fd 0 exclusively. No listener
tracking, no _readableState pokes, no instance method overrides.
Fixes #29126.

process.stdin is now constructed exactly as Node's getStdin() does:
- TTY  → tty.ReadStream (extends net.Socket, native TTY handle)
- PIPE → net.Socket({fd, readable:true, writable:false, manualStart:true})
         with native Pipe handle
- FILE → fs.ReadStream(null, {fd, autoClose:false})

After on('readable')/removeListener('readable'), Readable stops calling
_read(); the next chunk's push() returns false (highWaterMark 0 on TTY)
→ onStreamRead calls handle.readStop() → fd 0 is released and a
stdio:'inherit' child reads it exclusively. No listener tracking,
no _readableState pokes, no instance method overrides.

Native:
- node_util_binding.zig: guessHandleType(fd) (POSIX isatty/fstat/SO_TYPE,
  Windows uv_guess_handle)
- Pipe.zig + pipe.classes.ts (new): two-step new Pipe(type) + .open(fd),
  readStart/readStop/ref/unref/close/onread, (nread, ArrayBuffer) encoding
- TTY.zig: onread → (nread, ArrayBuffer); removed pause/resume/$write/$end
  shims (Node's wraps don't have them); negative-errno returns
- ProcessBindingPipeWrap.{cpp,h} (new): exposes process.binding('pipe_wrap')
  with {Pipe, constants}

JS:
- net.ts: createHandle(fd) via guessHandleType; Socket({fd}) constructs a
  Pipe handle; Socket({handle, manualStart}); onStreamRead(nread, buf) per
  Node's stream_base_commons; pause/resume/read gated on kBuffer (Node's
  pattern, not handle shims); readable/writable options honored
- ProcessObjectInternals.ts getStdinStream: replaced the
  Bun.stdin.stream()/own/disown/internalRead/listener-override bridge with
  Node's switch — −168 net lines

Behavior changes that are deliberate Node-parity corrections:
- process.stdin.end is a function (was the number Infinity for pipes)
- process.stdin.writable === false for pipes
- pause() alone does not let the process exit if the pipe is open
  (stdin-fixtures "pause allows exit" updated to runBoth Node-parity)
@robobun

robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 3:33 PM PT - Apr 10th, 2026

❌ @autofix-ci[bot], your commit 866255e has 13 failures in Build #44919 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29134

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

bun-29134 --bun

@coderabbitai

coderabbitai Bot commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

This PR implements native Node.js TTY and Pipe stream-wrap bindings to resolve stdin backpressure issues. The changes introduce new Zig/C++ bindings for TTY and Pipe handles, refactor the stdin stream to use type-specific Node.js streams (TTY, File, Pipe, or generic Readable), and update Socket/TTY prototypes to properly integrate with the new native handles.

Changes

Cohort / File(s) Summary
New Zig TTY/Pipe implementations
src/bun.js/node/TTY.zig, src/bun.js/node/Pipe.zig, src/bun.js/node/node_util_binding.zig
Added native TTY and Pipe handle types with read/write lifecycle management, event callbacks, and ref/unref behavior. Introduced guessHandleType(fd) helper to classify file descriptors as TTY, FILE, PIPE, TCP, UDP, or UNKNOWN for correct stream type selection.
C++ process bindings
src/bun.js/bindings/ProcessBindingPipeWrap.h, src/bun.js/bindings/ProcessBindingPipeWrap.cpp, src/bun.js/bindings/BunProcess.cpp
Created new pipe-wrap binding infrastructure with createNodePipeWrapObject() returning a JS object exposing the Pipe constructor and constants; updated BunProcess.cpp to return this object instead of throwing "not implemented".
Refactored TTY binding
src/bun.js/bindings/ProcessBindingTTYWrap.cpp
Removed inline JSC bindings for TTYWrapObject, delegating construction to external TTY__getConstructor(); added Bun__getTTYWindowSize() C API for window size queries.
WebCore garbage collection cleanup
src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h, src/bun.js/bindings/webcore/DOMIsoSubspaces.h
Removed TTYWrapObject iso-subspace member declarations, reflecting the shift from JSC-based to Zig-based TTY binding.
Binding exports and class definitions
src/bun.js/bindings/generated_classes_list.zig, src/bun.js/node.zig, src/bun.js/node/tty.classes.ts, src/bun.js/node/pipe.classes.ts
Exported TTY and Pipe modules as public class aliases; defined class interface mappings for constructor, methods (open, readStart, readStop, setRawMode, etc.), and property accessors.
Socket and TTY stream wrappers
src/js/node/net.ts, src/js/node/tty.ts
Updated Socket to support stream-wrap handles with readStart/readStop instead of pause/resume; refactored ReadStream to wrap native TTY handle, extend net.Socket instead of fs.ReadStream, and use readableHighWaterMark: 0 for backpressure-driven read control.
stdin stream refactoring
src/js/builtins/ProcessObjectInternals.ts
Replaced custom ReadableStream wrapper with type-aware construction: spawns tty.ReadStream for TTY fd, fs.ReadStream for files, net.Socket for pipes/TCP, or basic Readable for unknown types; calls readStop() on pause to enable child process input inheritance.
Shared utilities
src/js/internal/shared.ts
Added owner_symbol export for shared symbol-based ownership tracking across modules.
Tests — stdin and TTY behavior
test/js/node/process/process.test.js, test/js/node/nodettywrap.test.ts, test/js/node/process/stdin/stdin-fixtures.test.ts, test/js/node/process/stdin/stdin-pipe-prototype.test.ts, test/js/node/tty-readstream-prototype.test.ts
Updated process binding tests to expect pipe_wrap and TTY bindings to succeed; added tests validating process.stdin is typed correctly (TTY, FILE, or Socket) and that the underlying handles expose expected methods and properties.
PTY integration tests
test/js/node/process/stdin/readable-removed-releases-tty.mjs, test/js/node/process/stdin/run-with-pty-readable.py, test/regression/issue/29126.test.ts
Added end-to-end PTY tests that spawn interactive child processes and validate stdin is properly released after listener removal, enabling children to read fd 0 exclusively (fixes issue #29126).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'process: make process.stdin Node-faithful for all fd types' clearly and concisely summarizes the main objective of the PR, focusing on the core change: aligning process.stdin behavior with Node's implementation across all file descriptor types.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering 'What' (behavioral changes table), 'How' (native and JS implementation details), Node-parity corrections, and test plan. It provides clear context on the changes and rationale.
Linked Issues check ✅ Passed The PR fully implements the primary objective from issue #29126: enabling fd 0 release after the last 'readable' listener is removed via Node's backpressure mechanism (readableHighWaterMark: 0 and readStop() calls). All coding requirements are met: native Pipe/TTY handles with proper onread encoding, guessHandleType() fd classification, and Node-faithful Socket construction in ProcessObjectInternals.
Out of Scope Changes check ✅ Passed All changes are scope-aligned with issue #29126. File modifications serve the PR objective: ProcessBindingPipeWrap (new binding), Pipe/TTY.zig (native handles), node_util_binding (handle type detection), net/tty.ts (Node-faithful construction), and test additions (validation). No unrelated functionality was introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. Node.js compat: process.binding('pipe_wrap') (use case: SCM_RIGHTS-based connection hand-off proxy) #17247 - PR implements process.binding('pipe_wrap') which was previously throwing "not implemented"
  2. bun doesn't listen to readable event on stdin #5240 - PR rewrites process.stdin for TTY to properly support the readable event via tty.ReadStream extending net.Socket
  3. Ink (CLI framework) not working properly #6862 - Ink relies on process.stdin being a proper TTY stream with setRawMode and readable event support, which this PR provides
  4. Bun processes fail to read from stdin when launched by Java-based process managers #21669 - PR rewrites PIPE stdin to use net.Socket with native Pipe handle and guessHandleType(fd) for correct fd type detection
  5. stdin error if run as a child process #15893 - PR's pipe stdin rewrite and guessHandleType(fd) detection address stdin not working when Bun is spawned as a child process
  6. Heredoc stdin lost when importing node:process (Array.fromAsync(process.stdin) resolves to []) #25320 - PR makes stdin start paused with readStop(), fixing race where data could be consumed before user code attaches listeners

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17247
Fixes #5240
Fixes #6862
Fixes #21669
Fixes #15893
Fixes #25320

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(tty): keep externally-owned fd alive across EAGAIN in tty.ReadStream #29114 - Also rewrites tty.ReadStream fd ownership semantics in src/js/node/tty.ts, noting that Node's ReadStream extends net.Socket rather than fs.ReadStream
  2. fix(tty): prevent tty.ReadStream from auto-closing the file descriptor #28219 - Fixes the same tty.ReadStream auto-closing-fd bug by adding autoClose: false, a narrower fix for the same problem process: make process.stdin Node-faithful for all fd types #29134 addresses wholesale
  3. fix(tty): prevent ReadStream from closing fd on EAGAIN #27286 - Another fix for the same tty.ReadStream fd-closing-on-EAGAIN bug, overriding _read to handle EAGAIN gracefully

🤖 Generated with Claude Code

@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: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/node/Pipe.zig`:
- Around line 233-234: The current code in Pipe.zig silently drops input when
allocations fail: change the allocation handling around
bun.default_allocator.dupe(u8, chunk) and
jsc.ArrayBuffer.createBuffer(globalThis, duped) so OOMs call bun.handleOom() and
other errors are propagated (not returning this.flags.reading); if createBuffer
fails, free the duplicated buffer (duped) via the allocator before returning or
propagating the error (use defer or explicit free) so no memory leaks occur;
ensure the function returns or propagates an error state instead of pretending
the chunk was consumed and only convert error.OutOfMemory to a crash via
bun.handleOom().

In `@src/bun.js/node/TTY.zig`:
- Around line 287-288: The read path currently swallows allocation/creation
failures by returning this.flags.reading and leaking duped when
jsc.ArrayBuffer.createBuffer fails; change it to call bun.handleOom(err) if
bun.default_allocator.dupe returns error.OutOfMemory, otherwise propagate or
return the error instead of pretending the chunk was consumed, and ensure duped
is freed (or deferred) if createBuffer fails before returning the error—adjust
the code around bun.default_allocator.dupe, duped, and
jsc.ArrayBuffer.createBuffer to use defer/cleanup and bun.handleOom() per
guidelines.

In `@src/js/node/net.ts`:
- Around line 70-77: The createHandle function misclassifies TCP fds as Pipe and
write support for fd-backed stream handles is missing; update createHandle
(which uses guessHandleType and handleTypes) to return a TCP wrapper instance
(new TCP(...)) when the guessed type is "TCP" instead of creating a Pipe, and
ensure Pipe still handles "PIPE"; then implement proper write/shutdown plumbing
in the fh-backed stream-wrap code paths (the _write method and related
shutdown/send handles used by net.Socket({ fd, writable: true })) so writable
fd-backed sockets call the TCP wrapper's write/send/shutdown operations rather
than rejecting, matching Node.js behavior—use symbols TCP, Pipe, PipeConstants,
createHandle, _write and the stream-wrap handling code to locate and update the
logic.

In `@test/js/node/process/stdin/run-with-pty-readable.py`:
- Around line 23-27: The timeout_handler function currently swallows all
exceptions with a bare except; change it to only catch the expected
process-termination errors—e.g., except (ProcessLookupError, OSError):—when
calling os.kill(pid, 9) and let other exceptions (like
KeyboardInterrupt/SystemExit) propagate; update timeout_handler to catch those
specific exceptions and avoid a bare except so real harness failures are not
hidden.

In `@test/regression/issue/29126.test.ts`:
- Around line 33-37: The test currently asserts an empty stderr in the object
comparison using expect({ stderr, childLines, exitCode }).toEqual(...); remove
the strict stderr: "" assertion so the test only checks childLines (retain the
expect.arrayContaining(['CHILD:"B"', 'CHILD:"C"', 'CHILD:"D"', 'CHILD:"E"'])
reference) and exitCode: 0; update the expectation to compare only childLines
and exitCode (or assert stderr is defined/ignored) to avoid ASAN spurious output
failures while keeping the childLines assertions intact.
🪄 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: 67de147a-d29d-4499-b7e6-5876f0e8e474

📥 Commits

Reviewing files that changed from the base of the PR and between d81b0ca and 866255e.

📒 Files selected for processing (25)
  • src/bun.js/bindings/BunProcess.cpp
  • src/bun.js/bindings/ProcessBindingPipeWrap.cpp
  • src/bun.js/bindings/ProcessBindingPipeWrap.h
  • src/bun.js/bindings/ProcessBindingTTYWrap.cpp
  • src/bun.js/bindings/generated_classes_list.zig
  • src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h
  • src/bun.js/bindings/webcore/DOMIsoSubspaces.h
  • src/bun.js/node.zig
  • src/bun.js/node/Pipe.zig
  • src/bun.js/node/TTY.zig
  • src/bun.js/node/node_util_binding.zig
  • src/bun.js/node/pipe.classes.ts
  • src/bun.js/node/tty.classes.ts
  • src/js/builtins/ProcessObjectInternals.ts
  • src/js/internal/shared.ts
  • src/js/node/net.ts
  • src/js/node/tty.ts
  • test/js/node/nodettywrap.test.ts
  • test/js/node/process/process.test.js
  • test/js/node/process/stdin/readable-removed-releases-tty.mjs
  • test/js/node/process/stdin/run-with-pty-readable.py
  • test/js/node/process/stdin/stdin-fixtures.test.ts
  • test/js/node/process/stdin/stdin-pipe-prototype.test.ts
  • test/js/node/tty-readstream-prototype.test.ts
  • test/regression/issue/29126.test.ts
💤 Files with no reviewable changes (2)
  • src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h
  • src/bun.js/bindings/webcore/DOMIsoSubspaces.h

Comment thread src/bun.js/node/Pipe.zig
Comment on lines +233 to +234
const duped = bun.default_allocator.dupe(u8, chunk) catch return this.flags.reading;
const buf = jsc.ArrayBuffer.createBuffer(globalThis, duped) catch return this.flags.reading;

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.

⚠️ Potential issue | 🟠 Major

Don't silently drop chunks on allocation failure.

Both failure paths currently just return this.flags.reading, which loses input bytes, and the createBuffer() failure path also leaves the duplicated slice without visible cleanup. This should either crash on OOM or surface an error, not continue as if the chunk was handled.

As per coding guidelines, "Use bun.handleOom() to convert error.OutOfMemory into a crash without swallowing other errors" and "Be careful with memory management in Zig code - use allocators and defer for cleanup".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/node/Pipe.zig` around lines 233 - 234, The current code in
Pipe.zig silently drops input when allocations fail: change the allocation
handling around bun.default_allocator.dupe(u8, chunk) and
jsc.ArrayBuffer.createBuffer(globalThis, duped) so OOMs call bun.handleOom() and
other errors are propagated (not returning this.flags.reading); if createBuffer
fails, free the duplicated buffer (duped) via the allocator before returning or
propagating the error (use defer or explicit free) so no memory leaks occur;
ensure the function returns or propagates an error state instead of pretending
the chunk was consumed and only convert error.OutOfMemory to a crash via
bun.handleOom().

Comment thread src/bun.js/node/TTY.zig
Comment on lines +287 to +288
const duped = bun.default_allocator.dupe(u8, chunk) catch return this.flags.reading;
const buf = jsc.ArrayBuffer.createBuffer(globalThis, duped) catch return this.flags.reading;

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.

⚠️ Potential issue | 🟠 Major

Don't swallow allocation failures in the read path.

Returning this.flags.reading here drops terminal input on allocation/buffer-creation failure, and the createBuffer() error path does not show cleanup for duped. This path should fail loudly instead of pretending the chunk was consumed.

As per coding guidelines, "Use bun.handleOom() to convert error.OutOfMemory into a crash without swallowing other errors" and "Be careful with memory management in Zig code - use allocators and defer for cleanup".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/node/TTY.zig` around lines 287 - 288, The read path currently
swallows allocation/creation failures by returning this.flags.reading and
leaking duped when jsc.ArrayBuffer.createBuffer fails; change it to call
bun.handleOom(err) if bun.default_allocator.dupe returns error.OutOfMemory,
otherwise propagate or return the error instead of pretending the chunk was
consumed, and ensure duped is freed (or deferred) if createBuffer fails before
returning the error—adjust the code around bun.default_allocator.dupe, duped,
and jsc.ArrayBuffer.createBuffer to use defer/cleanup and bun.handleOom() per
guidelines.

Comment thread src/js/node/net.ts
Comment on lines +70 to +77
function createHandle(fd, isServer) {
validateInt32(fd, "fd", 0);
const type = handleTypes[guessHandleType(fd)];
if (type === "PIPE" || type === "TCP") {
return new Pipe(isServer ? PipeConstants.SERVER : PipeConstants.SOCKET);
}
throw $ERR_INVALID_FD_TYPE(type);
}

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In current Node.js lib/net.js, how does createHandle(fd, is_server)mapguessHandleType(fd)values"PIPE"and"TCP", and does net.Socket({ fd, writable: true }) support writing on fd-backed stream handles?

💡 Result:

In current Node.js lib/net.js, createHandle(fd, is_server) calls guessHandleType(fd) (from internal bindings like tty_wrap/util) to determine the handle type. For "PIPE", it creates a new Pipe(is_server ? PipeConstants.SERVER : PipeConstants.SOCKET). For "TCP", it creates a new TCP(is_server ? TCPConstants.SERVER : TCPConstants.SOCKET). net.Socket({ fd, writable: true }) supports writing on fd-backed stream handles (PIPE or TCP), as the writable: true option explicitly allows writes when an fd is passed, enabling the Duplex stream's write functionality on valid socket/pipe FDs.

Citations:


🏁 Script executed:

# First, let's examine the createHandle function at lines 70-77
sed -n '65,85p' src/js/node/net.ts

Repository: oven-sh/bun

Length of output: 781


🏁 Script executed:

# Now let's look at the _write rejection around lines 1514-1519
sed -n '1510,1525p' src/js/node/net.ts

Repository: oven-sh/bun

Length of output: 643


🏁 Script executed:

# Let's also check for TCP binding imports in the file
rg "tcp_wrap|TCP" src/js/node/net.ts -n | head -20

Repository: oven-sh/bun

Length of output: 395


Fix createHandle() to map TCP descriptors to new TCP() and implement write support for fd-backed stream handles.

createHandle() returns new Pipe() for both "PIPE" and "TCP" types, misclassifying TCP descriptors. Additionally, _write() rejects every stream-wrap handle, leaving net.Socket({ fd, writable: true }) non-functional even though the constructor now accepts these fds. TCP requires the proper TCP wrapper, and writable stream-wrap handles need complete write/shutdown plumbing to match Node.js behavior.

Example fix for createHandle
 function createHandle(fd, isServer) {
   validateInt32(fd, "fd", 0);
   const type = handleTypes[guessHandleType(fd)];
-  if (type === "PIPE" || type === "TCP") {
+  if (type === "PIPE") {
     return new Pipe(isServer ? PipeConstants.SERVER : PipeConstants.SOCKET);
   }
+  if (type === "TCP") {
+    const { TCP, constants: TCPConstants } = process.binding("tcp_wrap");
+    return new TCP(isServer ? TCPConstants.SERVER : TCPConstants.SOCKET);
+  }
   throw $ERR_INVALID_FD_TYPE(type);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/js/node/net.ts` around lines 70 - 77, The createHandle function
misclassifies TCP fds as Pipe and write support for fd-backed stream handles is
missing; update createHandle (which uses guessHandleType and handleTypes) to
return a TCP wrapper instance (new TCP(...)) when the guessed type is "TCP"
instead of creating a Pipe, and ensure Pipe still handles "PIPE"; then implement
proper write/shutdown plumbing in the fh-backed stream-wrap code paths (the
_write method and related shutdown/send handles used by net.Socket({ fd,
writable: true })) so writable fd-backed sockets call the TCP wrapper's
write/send/shutdown operations rather than rejecting, matching Node.js
behavior—use symbols TCP, Pipe, PipeConstants, createHandle, _write and the
stream-wrap handling code to locate and update the logic.

Comment on lines +23 to +27
def timeout_handler(signum, frame):
try:
os.kill(pid, 9)
except:
pass

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.

⚠️ Potential issue | 🟡 Minor

Narrow the timeout-handler exception.

The bare except: here will also swallow KeyboardInterrupt/SystemExit and can hide real harness failures. Catch the expected process-termination error (ProcessLookupError/OSError) instead.

Suggested fix
 def timeout_handler(signum, frame):
     try:
         os.kill(pid, 9)
-    except:
+    except ProcessLookupError:
+        pass
+    except OSError:
         pass
     sys.exit(1)
🧰 Tools
🪛 Ruff (0.15.9)

[warning] 23-23: Unused function argument: signum

(ARG001)


[warning] 23-23: Unused function argument: frame

(ARG001)


[warning] 24-27: Use contextlib.suppress(BaseException) instead of try-except-pass

Replace try-except-pass with with contextlib.suppress(BaseException): ...

(SIM105)


[error] 26-26: Do not use bare except

(E722)


[error] 26-27: try-except-pass detected, consider logging the exception

(S110)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/process/stdin/run-with-pty-readable.py` around lines 23 - 27,
The timeout_handler function currently swallows all exceptions with a bare
except; change it to only catch the expected process-termination errors—e.g.,
except (ProcessLookupError, OSError):—when calling os.kill(pid, 9) and let other
exceptions (like KeyboardInterrupt/SystemExit) propagate; update timeout_handler
to catch those specific exceptions and avoid a bare except so real harness
failures are not hidden.

Comment on lines +33 to +37
expect({ stderr, childLines, exitCode }).toEqual({
stderr: "",
childLines: expect.arrayContaining(['CHILD:"B"', 'CHILD:"C"', 'CHILD:"D"', 'CHILD:"E"']),
exitCode: 0,
});

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.

⚠️ Potential issue | 🟠 Major

Avoid asserting empty stderr in this regression test.

Line 34 can make this test fail on ASAN builds even when behavior is correct. Keep the child-line assertions and assert only exitCode for pass/fail.

Suggested assertion update
-  expect({ stderr, childLines, exitCode }).toEqual({
-    stderr: "",
-    childLines: expect.arrayContaining(['CHILD:"B"', 'CHILD:"C"', 'CHILD:"D"', 'CHILD:"E"']),
-    exitCode: 0,
-  });
+  expect(childLines).toEqual(expect.arrayContaining(['CHILD:"B"', 'CHILD:"C"', 'CHILD:"D"', 'CHILD:"E"']));
   expect(childLines.length).toBeGreaterThanOrEqual(4);
+  expect(exitCode).toBe(0);

Based on learnings: in test/regression/issue/*.test.ts, do not assert stderr is empty for spawned subprocesses because ASAN may emit benign warnings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29126.test.ts` around lines 33 - 37, The test currently
asserts an empty stderr in the object comparison using expect({ stderr,
childLines, exitCode }).toEqual(...); remove the strict stderr: "" assertion so
the test only checks childLines (retain the expect.arrayContaining(['CHILD:"B"',
'CHILD:"C"', 'CHILD:"D"', 'CHILD:"E"']) reference) and exitCode: 0; update the
expectation to compare only childLines and exitCode (or assert stderr is
defined/ignored) to avoid ASAN spurious output failures while keeping the
childLines assertions intact.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This has to be ref-counted. All the current usages of flags.finalized as UAF.

Comment thread src/bun.js/node/Pipe.zig
Comment on lines +233 to +234
const duped = bun.default_allocator.dupe(u8, chunk) catch return this.flags.reading;
const buf = jsc.ArrayBuffer.createBuffer(globalThis, duped) catch return this.flags.reading;

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.

🔴 In both Pipe.zig:onReadChunk (lines 233-234) and TTY.zig:onReadChunk (lines 287-288), the buffer allocation from bun.default_allocator.dupe() is leaked on every successful chunk read because jsc.ArrayBuffer.createBuffer internally copies the data via Bun__createUint8ArrayForCopy and never takes ownership of the input slice. Additionally, if dupe() succeeds but createBuffer() fails (OOM), the allocated buffer is also leaked. The fix is to pass chunk directly to createBuffer (since it copies anyway), eliminating the unnecessary dupe() entirely.

Extended reasoning...

The Bug

In Pipe.zig (lines 233-234) and TTY.zig (lines 287-288), onReadChunk does:

const duped = bun.default_allocator.dupe(u8, chunk) catch return this.flags.reading;
const buf = jsc.ArrayBuffer.createBuffer(globalThis, duped) catch return this.flags.reading;

Why duped Always Leaks (the Normal Path)

jsc.ArrayBuffer.createBuffer (in src/bun.js/jsc/array_buffer.zig) takes a []const u8 slice and calls Bun__createUint8ArrayForCopy (in ZigGlobalObject.cpp). That C++ function creates a new JSUint8Array via createUninitialized() then memcpy's the input bytes into it — it makes its own copy of the data. The []const u8 signature confirms no ownership transfer. After createBuffer returns successfully, duped is an unreferenced heap allocation that nothing will ever free. This leak occurs on every successful read, not just under error conditions.

Why duped Also Leaks on createBuffer Failure

If createBuffer throws an exception (e.g., OOM), the catch return this.flags.reading silently discards both the exception and the already-allocated duped slice. There is no errdefer bun.default_allocator.free(duped) before the second line, so the allocation is abandoned.

Concrete Proof

For a process reading 1 KB/chunk from stdin, each call to onReadChunk allocates via dupe() and immediately loses the pointer. After 100 MB of data read, 100 MB of allocator memory has been abandoned. The heap grows unboundedly proportional to stdin/pipe throughput, never freed until process exit.

Why Existing Code Does Not Prevent It

The comment in TTY.zig says g6: dupe — the reader buffer is reused across reads, correctly identifying the reason to copy the data. But the chosen mechanism (dupe + createBuffer) copies the data twice: once into duped, once inside Bun__createUint8ArrayForCopy. The first copy is not only unnecessary but is the one that leaks.

Impact

Memory usage grows linearly with data read through any pipe or TTY stdin. Long-running processes that read substantial stdin (interactive REPLs, servers reading stdin for commands, etc.) will exhibit steady memory growth that cannot be GC'd because the allocations are from bun.default_allocator (not the JS heap).

Fix

Since createBuffer copies chunk internally anyway, pass chunk directly:

const buf = jsc.ArrayBuffer.createBuffer(globalThis, chunk) catch return this.flags.reading;

This eliminates the dupe() entirely. The same one-line fix applies in TTY.zig lines 287-288. The reader buffer remains valid for the duration of the createBuffer call since it copies synchronously before returning.

Comment on lines +155 to +163
const { Readable } = require("node:stream");
stdin = new Readable({ read() {} });
stdin.push(null);
}
}

function triggerRead(_size) {
$debug("_read();", reader);
stdin.fd = fd;

if (reader && !shouldDisown) {
internalRead(this);
} else {
// The stream has not been ref()ed yet. If it is ever ref()ed,
// run internalRead()
needsInternalReadRefresh = true;
}
// `stdin` starts out life in a paused state. Explicitly readStop() it to put

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.

🔴 The new stdin 'pause' event handler in ProcessObjectInternals.ts schedules readStop() via process.nextTick() with only a handle-existence guard, dropping the old check for whether the stream has already been resumed before the tick fires. This race can permanently silence stdin when backpressure cycling causes pause→resume faster than a tick, particularly with the new TTY/Pipe handles whose hwm is 0, making every chunk trigger it.

Extended reasoning...

What the bug is and how it manifests

In the new getStdinStream() implementation (ProcessObjectInternals.ts lines 172–180), the pause handler queues readStop() asynchronously via process.nextTick():

stdin.on("pause", () => {
  process.nextTick(() => {
    if (\!stdin._handle) return;
    stdin._handle.reading = false;
    stdin._readableState.reading = false;
    stdin._handle.readStop();
  });
});

The only guard is if (\!stdin._handle) return. The old code had an equivalent of if (\!stream.readableFlowing) { disown(); } which prevented the readStop call when the stream had already been resumed before the tick fired. The new code has no such guard, creating a window where a resume that happens synchronously between the 'pause' event and the nextTick callback fires will be silently undone.

The specific code path that triggers it

The most reliable trigger is backpressure cycling through onStreamRead, which for TTY handles fires on every chunk because highWaterMark is effectively 0 for raw TTY mode:

  1. onStreamRead fires with data; self.push(data) returns false (buffer full / hwm=0).
  2. onStreamRead checks: if (ret === false && this.reading) { this.reading = false; this.readStop(); }
  3. Duplex internals detect the push() return value and transition readable state, synchronously emitting 'pause'.
  4. Our handler queues process.nextTick(readStop).
  5. Before the tick fires, the consumer calls stream.read() or data flows through piped consumers. Duplex's flow() → stream.read(0) → _read() is called.
  6. Socket.prototype._read: if (!socket.reading) tryReadStart(this) → socket._handle.reading = true, readStart() succeeds.
  7. Now the nextTick fires: reading = false, readStop() — silently cancels the readStart. stdin is permanently stuck.

Why existing code does not prevent it

The refutation argued that Socket.prototype.resume/read/pause are all gated on this[kBuffer] and stdin is constructed without an onread option, so kBuffer is never set, meaning those Socket methods are no-ops and can never call tryReadStart(). This is correct for the Socket-level pause/resume methods — they will not call tryReadStart(). However, Socket.prototype._read is not gated on kBuffer:

Socket.prototype._read = function _read(size) {
  const socket = this._handle;
  if (this.connecting || \!socket) {
    this.once("connect", () => this._read(size));
  } else if ((socket.readStart)) {
    if (\!socket.reading) tryReadStart(this);  // ← NOT gated on kBuffer
  } else {
    socket?.resume?.();
  }
};

_read() is the method that Duplex's stream machinery calls. When flow() or a piped consumer triggers stream.read(), it goes through Readable.prototype.read() → this._read(), which calls tryReadStart(). This path exists independently of the kBuffer gating on the Socket-level methods.

Impact

Stdin can silently stop delivering data after the first backpressure cycle. Any program that pipes stdin through a Transform, reads chunks in a loop, or even just attaches a 'data' listener (which puts the stream in flowing mode and causes continuous push()/pause cycles) is vulnerable. Because readStop() ultimately holds a GC reference (JSRef) that keeps the process alive, a stuck stdin may also cause the process to hang or exit unexpectedly depending on whether the ref is still held. The bug is especially likely to manifest with the new TTY and Pipe handle types introduced in this PR, which use BufferedReader at the Zig level with aggressive backpressure.

How to fix it

Add a guard at the top of the nextTick callback to abort if the stream is no longer paused:

stdin.on("pause", () => {
  process.nextTick(() => {
    if (\!stdin._handle) return;
    if (\!stdin.isPaused()) return;  // ← add this guard
    stdin._handle.reading = false;
    stdin._readableState.reading = false;
    stdin._handle.readStop();
  });
});

stdin.isPaused() returns true only when readableFlowing === false, which is exactly the condition the old code checked. Alternatively, checking stdin._readableState.flowing !== false is equivalent and slightly more direct.

Step-by-step proof

Setup: Node-faithful net.Socket stdin (fd=0, PIPE type). A consumer pipes stdin: stdin.pipe(process.stdout).

  1. readStart() fires for the first time via Socket constructor → tryReadStart → reading=true.
  2. First chunk arrives: onStreamRead(nread>0) → self.push(chunk). For a default Duplex, hwm=16384. push() returns true. No readStop.
  3. (hwm=0 scenario or buffer full:) push() returns false. onStreamRead: reading=false, readStop(). Duplex transitions paused. 'pause' emitted synchronously.
  4. Our handler: process.nextTick(fn) queued (fn will set reading=false and call readStop).
  5. Synchronously: pipe's internal drain handler or stdout's 'drain' triggers stdin.resume() → Duplex flow() → stream.read(0) → Socket._read() → !socket.reading is true → tryReadStart() → reading=true, readStart() called. stdin is reading again.
  6. Queued nextTick fires. stdin._handle exists ✓. No isPaused() guard. reading=false, readStop() called. stdin stops reading.
  7. No more data will arrive. If this is interactive stdin, the terminal appears frozen. If piped, the program silently stops processing input.

Comment thread src/js/node/tty.ts
Comment on lines +38 to +43
Socket.$call(this, {
readableHighWaterMark: 0,
handle: tty,
manualStart: true,
...options,
});

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.

🔴 tty.ReadStream does not pass writable: false to the Socket constructor, so process.stdin.writable === true on TTY stdin instead of the expected false. The fix requires both passing writable: false in src/js/node/tty.ts and honoring options.writable in the Socket handle-path in src/js/node/net.ts (which currently only reads options.readable/writable on the fd-path).

Extended reasoning...

What the bug is and how it manifests

tty.ReadStream is a read-only stream representing stdin on a terminal. In Node.js, process.stdin.writable is false when stdin is a TTY. In Bun's implementation, process.stdin.writable is true on TTY stdin because tty.ReadStream never tells the underlying Socket it is write-only.

The specific code path that triggers it

In src/js/node/tty.ts lines 38-43, ReadStream calls the Socket constructor with no writable: false in the options bag. Node.js lib/tty.js passes { readable: true, writable: false, handle: tty, manualStart: true } explicitly, so its Duplex base class sets this.writable = false on construction.

There is also a secondary issue in src/js/node/net.ts: the Socket constructor's handle branch only assigns this._handle = options.handle and calls initSocketHandle(this), but never reads options.writable or options.readable. The fd branch further down does read those options and sets this.readable / this.writable correctly. So even if tty.ReadStream were fixed to pass writable: false, the Socket constructor would still ignore it on the handle path -- both sites need a fix.

Why existing code does not prevent it

Duplex (and therefore Socket) defaults this.writable = true unless explicitly set otherwise. Because the handle path in Bun's Socket constructor never applies the caller-supplied writable option, the default is never overridden. The newly added test file test/js/node/tty-readstream-prototype.test.ts checks the prototype chain, readableHighWaterMark, and handle methods, but does not assert writable === false, so the regression has no test coverage.

Impact

Code that guards TTY writes with if (!process.stdin.writable) or checks stream.writable to decide whether piping is safe will behave incorrectly. Libraries like readline and interactive CLI tools frequently inspect stdin.writable; returning true instead of false can cause unexpected write attempts on a read-only handle.

How to fix it

  1. In src/js/node/tty.ts, add writable: false to the Socket options object passed to Socket.$call.
  2. In src/js/node/net.ts, apply options.readable / options.writable in the handle branch similarly to how the fd branch does.
  3. Add an assertion expect(stream.writable).toBe(false) in test/js/node/tty-readstream-prototype.test.ts.

Step-by-step proof

  1. Create a script: console.log(process.stdin.writable)
  2. Run it in a terminal: bun run script.ts
  3. Expected output (Node.js): false
  4. Actual output (Bun with this PR): true, because Socket.$call receives no writable: false, the handle path never sets this.writable, and the Duplex default of true persists.

Comment thread src/js/node/net.ts
Comment on lines +69 to +77

function createHandle(fd, isServer) {
validateInt32(fd, "fd", 0);
const type = handleTypes[guessHandleType(fd)];
if (type === "PIPE" || type === "TCP") {
return new Pipe(isServer ? PipeConstants.SERVER : PipeConstants.SOCKET);
}
throw $ERR_INVALID_FD_TYPE(type);
}

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.

🔴 In createHandle() (net.ts lines 73-74), both 'PIPE' and 'TCP' fd types return new Pipe(...), but Node.js returns new TCP() for TCP fds. Any user calling net.Socket({fd}) where fd is a connected TCP socket will get a Pipe handle with incorrect semantics — writes immediately fail with ERR_METHOD_NOT_IMPLEMENTED, and TCP-specific operations (setNoDelay, setKeepAlive, TCP shutdown semantics) are absent. Since tcp_wrap is not implemented in Bun, the correct fix is to throw ERR_INVALID_FD_TYPE for TCP fds rather than silently using the wrong handle type.

Extended reasoning...

What the bug is and how it manifests

In src/js/node/net.ts lines 73-74, createHandle() contains:

if (type === "PIPE" || type === "TCP") {
  return new Pipe(isServer ? PipeConstants.SERVER : PipeConstants.SOCKET);
}

When guessHandleType(fd) returns 'TCP' (i.e., the fd is a TCP socket), the function still constructs a Pipe handle. The || type === 'TCP' branch was added to mirror Node's createHandle interface signature, but the constructor was not replaced with a TCP-specific one.

The specific code path that triggers it

Any call to new net.Socket({ fd: tcpFd }) where tcpFd is a TCP socket will flow through createHandle() → returns new Pipe(SOCKET). The Socket constructor then stores this Pipe as this._handle, sets this._handle.onread = onStreamRead, and proceeds normally — silently using the wrong handle type.

Why existing code doesn't prevent it

The _write path does guard against stream-wrap handles:

if ((socket.readStart)) {
  callback(("_write on stream-wrap handle"));
  return false;
}

But this only surfaces on writes. The handle is still created and used — reads proceed through the Pipe's BufferedReader, which has no TCP semantics (no MSG_WAITALL, no TCP backpressure management). Operations like setNoDelay, setKeepAlive, and TCP's FIN-based shutdown vs pipe close are also absent from the Pipe interface.

Impact

Any library or application that passes a TCP socket fd to net.Socket({fd}) — e.g., code inheriting a pre-connected socket, custom IPC over TCP, or any Node.js pattern that relies on fd-passing — will silently get a broken socket. Reads may work partially (Pipe's BufferedReader can drain a file-like fd), but the semantics differ. Writes fail with ERR_METHOD_NOT_IMPLEMENTED. This is a silent correctness failure with no obvious error at construction time.

How to fix it

Since process.binding('tcp_wrap') is currently PROCESS_BINDING_NOT_IMPLEMENTED in BunProcess.cpp, the proper Node-faithful fix (creating a real TCP handle) is not yet available. The correct interim fix is to throw ERR_INVALID_FD_TYPE for TCP fds rather than silently constructing a broken Pipe:

 function createHandle(fd, isServer) {
   validateInt32(fd, "fd", 0);
   const type = handleTypes[guessHandleType(fd)];
   if (type === "PIPE") {
     return new Pipe(isServer ? PipeConstants.SERVER : PipeConstants.SOCKET);
   }
+  // TCP fds require a tcp_wrap handle which is not yet implemented.
+  // Silently returning a Pipe would produce a handle with wrong semantics.
   throw (type);
 }

This is consistent with Node's behavior: if the right handle type can't be constructed, fail loudly rather than silently misbehave.

Step-by-step proof

  1. User calls net.Socket({ fd: someTCPSocketFd })
  2. Socket constructor reaches: this._handle = createHandle(options.fd, false)
  3. guessHandleType(someTCPSocketFd) → inspects fd via getsockopt(SO_TYPE) + getsockname → family is INET/INET6, type is SOCK_STREAM → returns index 0 → handleTypes[0] = 'TCP'
  4. createHandle hits if (type === 'PIPE' || type === 'TCP') → true → returns new Pipe(SOCKET)
  5. Pipe is stored as this._handle; this.writable = options.writable \!== false → socket appears writable
  6. User calls socket.write('hello')
  7. _write checks (socket.readStart) → true (Pipe has readStart) → immediately calls callback((...))
  8. The write silently fails with a method-not-implemented error; no TCP data is ever sent

🔬 also observed by verify-runtime

@alii

alii commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

this was too big and risky of a change - will stack into a few prs.

@alii alii closed this Apr 10, 2026
@alii

alii commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

Split into a reviewable stack with the ref-counting fix applied:

Stack: main → A → B → {C, D} → E. The UAF is addressed in C and D via bun.ptr.RefCount.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

process.stdin keeps reading fd 0 after last 'readable' listener is removed

3 participants