Skip to content

refactor(ext/node): rewrite process stdio to match Node.js pattern#33230

Open
bartlomieju wants to merge 7 commits intomainfrom
refactor/stdio-node-first
Open

refactor(ext/node): rewrite process stdio to match Node.js pattern#33230
bartlomieju wants to merge 7 commits intomainfrom
refactor/stdio-node-first

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

@bartlomieju bartlomieju commented Apr 10, 2026

Summary

Rewrites process.stdin/stdout/stderr creation to exactly match Node.js's
lib/internal/bootstrap/switches/is_main_thread.js pattern. This is a
follow-up / alternative approach to #33154.

Key changes:

  • process.stdout/stderr are now created based on guessHandleType(fd):
    • TTY → tty.WriteStream(fd) (libuv TTY handle via net.Socket)
    • PIPE/TCP → net.Socket({ fd }) (libuv Pipe/TCP handle)
    • FILE → SyncWriteStream(fd) (synchronous fs.writeSync)
    • UNKNOWN → dummy Writable
  • process.stdin similarly uses the correct Node.js stream type per fd type
  • Removes the old pattern of wrapping Deno.stdout.writeSync() in a generic
    Writable regardless of fd type
  • Simplifies the bootstrap code — removes separate TTY vs non-TTY branching
    and warmup stream replacement logic
  • Deno.stdin/stdout/stderr continue to work via RID-based ops; a
    __setNodeStreams() hook is added for future delegation

Bug fixes included:

  • child_process: #_waitForChildStreamsToClose now skips resume() on
    parent process stdout/stderr to prevent EBADF when they are write-only
    net.Socket instances
  • isTTY is now undefined for non-TTY streams (matching Node.js) instead
    of false

Fixes #33131: The old code used io.stdout.isTerminal() (Rust's
is_terminal()) to decide whether to create a tty.WriteStream. On Windows
Git Bash, Rust's is_terminal() returns true but uv_tty_init fails with
EBADF because Git Bash uses named pipes, not a real Windows console. This PR
switches to guessHandleType(fd) (which calls libuv's uv_guess_handle),
matching Node.js. On Git Bash, uv_guess_handle correctly returns PIPE, so
a net.Socket is created instead of attempting TTY initialization.

Net deletions: ~200 lines removed.

Closes #33131

Test plan

  • ./x test-node process_test — all pass (82 passed, 0 failed)
  • ./x test-node child_process_test — all pass (47 passed, 0 failed)
  • ./x test-spec pipe_open_fd — 5/5 pass
  • ./x test-spec net_socket_fd — 4/4 pass
  • ./x test-spec stdio — 3/3 pass
  • ./x test-spec tty — 3/3 pass
  • ./x test-spec process — all pass (only pre-existing denort failures)
  • ./x test-spec node — 246 tests, only pre-existing denort/compile failures
  • ./x test-unit process_test — pass
  • ./x test-unit stdio_test — pass
  • Manual: process.stdout.destroy() + subsequent write still works (indestructible)
  • Manual: piped stdout correctly creates net.Socket, file redirect creates SyncWriteStream

🤖 Generated with Claude Code

Rewrites process.stdin/stdout/stderr creation to exactly match Node.js's
`lib/internal/bootstrap/switches/is_main_thread.js` pattern:

- TTY fds -> tty.WriteStream / tty.ReadStream (via libuv TTY handle)
- PIPE/TCP fds -> net.Socket({ fd }) (via libuv Pipe/TCP handle)
- FILE fds -> SyncWriteStream (stdout/stderr) / fs.ReadStream (stdin)
- UNKNOWN -> dummy streams

Previously, process.stdout/stderr were always a generic Writable wrapping
Deno's io.writeSync(), regardless of fd type. Now they are proper Node.js
stream types with real handles, matching Node.js behavior.

Also fixes:
- child_process: skip resume() on parent's stdout/stderr in
  waitForChildStreamsToClose to prevent EBADF on write-only Sockets
- Tests: isTTY is now `undefined` (not `false`) for non-TTY streams,
  matching Node.js behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
isTTY is `true` for TTY streams and `undefined` for non-TTY, not `false`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@bartlomieju bartlomieju force-pushed the refactor/stdio-node-first branch from cc402b7 to 592a3f6 Compare April 10, 2026 11:29
bartlomieju and others added 5 commits April 10, 2026 17:18
Match Node.js bootstrap ordering: set up the IPC channel before
creating stdio streams so that process.channel.fd is available.
When a stdio fd is already claimed by IPC, reuse the channel handle
via net.Socket({ handle: process.channel }) instead of opening the
fd again (which fails with EEXIST).

Also fixes unused `process` import lint error in tty_test.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
createStdin always returns a stream, so the conditional check was
unnecessary (leftover from old initStdin warmup path).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
NativePipe was missing the setBlocking method that net.Socket calls
on Windows for stdout/stderr (fd 1/2). This caused a panic when
process.stdout was a pipe on Windows because the Socket constructor
tried to call this._handle.setBlocking(true).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The native write_cb was silently dropping the write status (including
EPIPE). Now it calls an onwrite JS callback on the native handle with
the actual status, and Pipe.writeBuffer uses this callback to fire
req.oncomplete with the real result instead of eagerly reporting
success in a microtask.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- uv_stream_set_blocking now correctly dispatches to uv_pipe_t for
  UV_NAMED_PIPE handles instead of incorrectly casting to uv_tcp_t
- Remove Windows-only guard for stdout/stderr setBlocking(true) in
  net.Socket constructor, matching Node.js which does it on all
  platforms

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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.

Panic: TTY initialization failed (uv_tty_init EBADF) in Git Bash on Windows

1 participant