Skip to content

ai slop - #29127

Closed
alii wants to merge 2 commits into
mainfrom
ali/tty-native-handle
Closed

ai slop#29127
alii wants to merge 2 commits into
mainfrom
ali/tty-native-handle

Conversation

@alii

@alii alii commented Apr 10, 2026 •

Copy link
Copy Markdown
Member

This PR has been marked as AI slop and the description has been updated to avoid confusion or misleading reviewers.

Many AI PRs are fine, but sometimes they submit a PR too early, fail to test if the problem is real, fail to reproduce the problem, or fail to test that the problem is fixed. If you think this PR is not AI slop, please leave a comment.

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.
@robobun

robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:52 PM PT - Apr 10th, 2026

❌ @autofix-ci[bot], your commit 00fc8a0 has 5 failures in Build #44897 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29127

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

bun-29127 --bun

@alii alii added the slop label Apr 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been closed because it was flagged as AI slop.

Many AI-generated PRs are fine, but this one was identified as having one or more of the following issues:

  • Fails to verify the problem actually exists
  • Fails to test that the fix works
  • Makes incorrect assumptions about the codebase
  • Submits changes that are incomplete or misleading

If you believe this was done in error, please leave a comment explaining why.

@github-actions github-actions Bot changed the title tty: make ReadStream extend net.Socket via native TTY handle ai slop Apr 10, 2026
@github-actions github-actions Bot closed this Apr 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Found 8 issues this PR may fix:

  1. input into child process is lossy, occasionally hogs CPU #22237 - After using @inquirer/prompts, spawning vim with stdio: 'inherit' loses keystrokes and hogs CPU — same stdin fd 0 race condition this PR fixes
  2. input characters lost when using spawn('ssh') after using readline #17338 - After readline + rl.close(), spawning ssh with stdio: 'inherit' loses characters — parent fails to release stdin fd
  3. bun doesn't listen to readable event on stdin #5240 - process.stdin.addListener("readable", ...) causes immediate exit instead of keeping process alive — old fs.ReadStream-based impl lacked proper TTY event loop integration
  4. Ink (CLI framework) not working properly #6862 - Ink's useInput receives no input because process.stdin doesn't deliver data events — net.Socket-based ReadStream with native TTY handle fixes stdin data delivery
  5. Cannot re-open a new readline instance in the same run #5302 - Creating a second readline.createInterface after closing the first never receives input — proper readStop/readStart lifecycle allows re-acquiring stdin
  6. No way to pause console input stream #1877 - process.stdin.pause() doesn't actually stop reading — PR's backpressure mechanism (readableHighWaterMark: 0 + readStop) gives pause() real effect
  7. stdin error if run as a child process #15893 - When Bun is spawned as a child process by Ruby/Julia, stdin data events never fire — reworked stdin initialization in ProcessObjectInternals.ts fixes data delivery
  8. New subprocess piping still consumes much CPU #20815 - Subprocess piping uses 5-7% CPU vs Node's 0.3-0.7% — PR stops stdin busy-polling via readStop when no consumer is pulling

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

Fixes #22237
Fixes #17338
Fixes #5240
Fixes #6862
Fixes #5302
Fixes #1877
Fixes #15893
Fixes #20815

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. process: release stdin fd 0 via backpressure (Node parity) #29121 - Explicitly superseded; also releases stdin fd 0 via backpressure for Node parity
  2. fix(tty): keep externally-owned fd alive across EAGAIN in tty.ReadStream #29114 - Fixes tty.ReadStream fd handling across EAGAIN, same area rewritten by this PR
  3. fix(tty): prevent tty.ReadStream from auto-closing the file descriptor #28219 - Prevents tty.ReadStream from auto-closing the fd, addressed by this PR's rewrite
  4. fix(tty): prevent ReadStream from closing fd on EAGAIN #27286 - Prevents ReadStream from closing fd on EAGAIN, same issue addressed here
  5. node compat: fix tty prototype pollution, MessagePort listener aliases, fs.read position validation #29093 - Fixes tty prototype pollution, which overlaps with the ReadStream prototype chain change

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4101f91f-356f-4d37-957c-f4dbe28da3a0

📥 Commits

Reviewing files that changed from the base of the PR and between 7674b2d and 00fc8a0.

📒 Files selected for processing (1)
  • src/bun.js/node/TTY.zig

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

This PR replaces the C++ TTY wrapper with a Zig-native TTY implementation, wires it into the event loop and Node-compatible streams (Socket/ReadStream) with backpressure-aware behavior, adjusts stdin handling to stop polling when appropriate, and adds/updates tests exercising TTY/stdin behavior.

Changes

Cohort / File(s) Summary
TTY Native Implementation
src/bun.js/node/TTY.zig, src/bun.js/node/tty.classes.ts
Adds a Zig-based native TTY handle with reader/writer, FD ownership, JS callbacks, lifecycle/ref management, raw-mode and window-size support; adds TypeScript class descriptor mapping JS prototype methods and accessors.
C++ Bindings Removal
src/bun.js/bindings/ProcessBindingTTYWrap.cpp
Removes the C++ TTYWrap implementation; delegates JS constructor/provider to TTY__getConstructor(zigGlobal) and exports Bun__getTTYWindowSize for Zig usage.
Node Exports & Bindings
src/bun.js/bindings/generated_classes_list.zig, src/bun.js/node.zig
Exports TTY under bun.api.node (adds pub const TTY) and registers the class in generated classes list.
WebCore GC Metadata Cleanup
src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h, src/bun.js/bindings/webcore/DOMIsoSubspaces.h
Removes TTY-related iso-subspace members from WebCore GC iso-subspace declarations.
Event Loop Integration
src/async/posix_event_loop.zig
Adds TTYPoll tagged poll handler to FilePoll.Owner and dispatches TTY poll events to the TTY handler path.
Socket & Stream Integration
src/js/node/net.ts, src/js/node/tty.ts
Extends Socket to accept a handle and wire its read/ondrain callbacks; tty.ReadStream now constructs a native TTY handle and uses net.Socket with readableHighWaterMark: 0, aligning backpressure behavior with Node.
Process stdin & Shared Utilities
src/js/builtins/ProcessObjectInternals.ts, src/js/internal/shared.ts
Implements a TTY-specific stdin path that calls readStop() and tracks pause to avoid stealing input; adjusts disowning logic based on stream.push() backpressure; exports owner_symbol.
Tests & Regression Cases
test/js/node/nodettywrap.test.ts, test/js/node/tty-readstream-prototype.test.ts, 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
Updates expectations for TTY constructor behavior; adds prototype/handle structure tests and end-to-end PTY/stdin tests exercising removal of readable listeners and ensuring fd release (regression test for #29126).

Possibly related PRs

Suggested reviewers

  • pfgithub
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'tty: make ReadStream extend net.Socket via native TTY handle' is clear, specific, and directly describes the main change: refactoring tty.ReadStream to use a net.Socket base with a native TTY handle instead of fs.ReadStream.
Description check ✅ Passed The PR description comprehensively covers what was changed (native TTY handle, net.Socket inheritance), why (backpressure-driven fd release), how (implementation details in TTY.zig, net.Socket updates), and includes a test plan with regression/prototype tests.
Linked Issues check ✅ Passed The PR addresses issue #29126 by implementing backpressure-driven fd release via readableHighWaterMark: 0 and handle.readStop(), matching Node's behavior and allowing inherited children exclusive access to stdin.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the native TTY handle, updating net.Socket to support handles, and wiring stdin construction for TTYs; no unrelated refactoring or feature additions are present.

✏️ 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.

Comment thread src/js/node/net.ts
Comment on lines +2591 to +2610
}
}
}

// Node lib/internal/stream_base_commons.js onStreamRead, adapted to the
// (buf | null | Error) encoding the Zig TTY handle uses.
function onStreamRead(buf) {
const self = this[owner_symbol];
if (buf === null) {
self.push(null);
self.read(0);
return;
}
if (buf instanceof Error) {
self.destroy(buf);
return;
}
self.bytesRead += buf.length;
if (!self.push(buf)) {
this.reading = false;

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.

🔴 After TTY stdin reaches EOF, onStreamRead(null) calls self.read(0) (net.ts:2596), which hits Socket.prototype.read (net.ts:1224) that unconditionally invokes this._handle?.resume() — aliased to readStart for TTY handles via tty.classes.ts:27 — before delegating to Duplex.prototype.read. This incorrectly calls readStart() post-EOF, re-activating the TTY handle's event loop keepalive and (since isDone() is false for TTY with close_handle=false) potentially re-registering the underlying poll, causing the process to hang after stdin EOF. Node.js's Socket.prototype.read does not call handle.resume() unconditionally; the fix is to guard the resume() call in Socket.prototype.read behind \!socket.readStart, matching the branching already in Socket.prototype._read.

Extended reasoning...

What the bug is and how it manifests

onStreamRead in net.ts (the new StreamBase commons callback added by this PR) handles EOF by calling self.push(null) then self.read(0). The intent of self.read(0) (borrowed from Node's stream_base_commons.js) is to trigger internal flow bookkeeping without consuming data. However, Bun's Socket.prototype.read does something Node's does not: it unconditionally calls this._handle?.resume() before delegating to Duplex.prototype.read:

Socket.prototype.read = function read(size) {
  if (\!this.connecting) {
    this._handle?.resume(); // ← called unconditionally for ALL handle types
  }
  return Duplex.prototype.read.$call(this, size);
};

The specific code path that triggers it

For TTY handles, tty.classes.ts line 27 aliases resume → readStart. So the call chain is:

  1. TTY fd sends EOF → onReaderDone() → callOnRead(jsNull())
  2. onStreamRead(null) fires (net.ts:2591) → self.push(null) then self.read(0)
  3. Socket.prototype.read is invoked — this._handle.resume() fires before Duplex.prototype.read
  4. resume = readStart (TTY.zig) is called post-EOF

Inside readStart's else-branch (reader already started):

  • this.reader.unpause() is called
  • watch() is conditionally skipped only if isDone(), but for TTY with close_handle=false (set at TTY.zig:124), close_handle=false means closeWithoutReporting() sets closed_without_reporting=true but does NOT call handle.close(), so the handle stays as .poll and isDone() returns false after EOF — meaning watch() IS called, re-registering the poll
  • updateRef(true) is called unconditionally (no isDone() guard), incrementing the event loop's keepalive count

Why existing code doesn't prevent it

Socket.prototype._read correctly handles TTY vs usocket by branching on $isCallable(socket.readStart) and only calling tryReadStart if \!socket.reading. But Socket.prototype.read has no such branch — it calls handle.resume() for all handle types. This was harmless before this PR when TTY used fs.ReadStream, but now that TTY goes through the Socket path, the TTY handle's readStart is triggered post-EOF.

Addressing the refutations

One refutation argues that PosixBufferedReader.updateRef has an internal guard (getPoll() orelse return) and that isDone() is always true when handle is .closed. This is true for non-TTY readers, but TTY.zig explicitly sets close_handle=false (line 124), which means the handle never transitions to .closed — it stays .poll — and finish() returns early without setting is_done=true. So isDone() is false after EOF for TTY, and getPoll() returns the still-live poll handle, making updateRef(true) a real operation.

A second refutation argues socket.reading is still true after push(null) so tryReadStart is blocked. This is true for the _read path, but irrelevant here: Socket.prototype.read calls handle.resume() before ever reaching _read. The socket.reading guard only exists in Socket.prototype._read / tryReadStart, not in Socket.prototype.read.

Impact

Any program that creates a TTY process.stdin (i.e., runs with a terminal) and consumes stdin until EOF will hang indefinitely because the event loop stays alive after stdin EOF. This is a regression introduced by this PR — previously, tty.ReadStream extended fs.ReadStream and did not go through the Socket.prototype.read code path.

How to fix it

Match Node.js behavior: in Socket.prototype.read, guard the resume() call to only fire for usocket-style handles (those without a readStart method):

Socket.prototype.read = function read(size) {
  if (\!this.connecting && \!($isCallable(this._handle?.readStart))) {
    this._handle?.resume();
  }
  return Duplex.prototype.read.$call(this, size);
};

For TTY handles, _read() → tryReadStart() is the correct path for re-arming reads; read() should not trigger readStart independently.

Step-by-step proof

  1. User runs bun script.js in a terminal with process.stdin.on('data', ...)
  2. Terminal sends EOF (Ctrl+D)
  3. BufferedReader calls onReaderDone() → callOnRead(jsNull())
  4. JS onStreamRead(null) fires: calls self.push(null) then self.read(0)
  5. Socket.prototype.read executes this._handle.resume() (= readStart()) — this is the bug
  6. readStart else-branch: isDone() is false (close_handle=false), so watch() re-registers the poll AND updateRef(true) increments event loop active count
  7. The poll that was just re-registered will never fire (EOF fd), but the active count prevents the event loop from draining
  8. Process hangs — the event loop never exits even though all user work is done

Comment thread src/js/node/tty.ts
Comment on lines 50 to 70
Object.defineProperty(ReadStream, "prototype", {
get() {
const Prototype = Object.create(fs.ReadStream.prototype);

// Add ref/unref methods to make tty.ReadStream behave like Node.js
// where TTY streams have socket-like behavior
Prototype.ref = function () {
// Get the underlying native stream source if available
const source = this.$bunNativePtr;
if (source?.updateRef) {
source.updateRef(true);
}
return this;
};

Prototype.unref = function () {
// Get the underlying native stream source if available
const source = this.$bunNativePtr;
if (source?.updateRef) {
source.updateRef(false);
}
return this;
};
const { Socket } = require("node:net");
const Prototype = Object.create(Socket.prototype);

Prototype.setRawMode = function (flag) {
flag = !!flag;

// On windows, this goes through the stream handle itself, as it must call
// uv_tty_set_mode on the uv_tty_t.
//
// On POSIX, I tried to use the same approach, but it didn't work reliably,
// so we just use the file descriptor and use termios APIs directly.
if (process.platform === "win32") {
// Special case for stdin, as it has a shared uv_tty handle
// and it's stream is constructed differently
if (this.fd === 0) {
const err = ttySetMode(flag);
if (err) {
this.emit("error", new Error("setRawMode failed with errno: " + err));
}
return this;
}

const handle = this.$bunNativePtr;
if (!handle) {
this.emit("error", new Error("setRawMode failed because it was called on something that is not a TTY"));
return this;
}

// If you call setRawMode before you call on('data'), the stream will
// not be constructed, leading to EBADF
// This corresponds to the `ensureConstructed` function in `native-readable.ts`
this.$start();

const err = handle.setRawMode(flag);
if (err) {
this.emit("error", err);
return this;
}
} else {
const err = ttySetMode(this.fd, flag);
if (err) {
this.emit("error", new Error("setRawMode failed with errno: " + err));
return this;
}
const err = this._handle?.setRawMode(flag);
if (err) {
this.emit("error", new Error("setRawMode failed with errno: " + err));
return this;
}

this.isRaw = flag;

return this;
};

Object.defineProperty(ReadStream, "prototype", { value: Prototype });

Object.setPrototypeOf(ReadStream, Socket);
return Prototype;
},
enumerable: true,

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 lazy ReadStream.prototype getter creates Object.create(Socket.prototype) but never sets Prototype.constructor = ReadStream, so (new tty.ReadStream(fd)).constructor === Socket instead of ReadStream. Code that uses .constructor for type checks or reads .constructor.name for error reporting will see 'Socket' instead of 'ReadStream'; fix by adding Prototype.constructor = ReadStream; inside the lazy getter.

Extended reasoning...

What the bug is and how it manifests

In src/js/node/tty.ts, the lazy ReadStream.prototype getter (lines 50–70) does:

const Prototype = Object.create(Socket.prototype);
// ... methods added ...
Object.defineProperty(ReadStream, "prototype", { value: Prototype });

Object.create(Socket.prototype) produces an object with no own constructor property. When code accesses Prototype.constructor, it walks up the prototype chain to Socket.prototype.constructor, which is Socket. So (new tty.ReadStream(0)).constructor === Socket — not ReadStream.

The specific code path

new ReadStream(fd) calls Socket.$call(this, ...), which sets up the instance correctly. But ReadStream.prototype (the object that new ReadStream() instances inherit from) was created via Object.create(Socket.prototype) without an own constructor property. The inherited value is Socket, not ReadStream.

Why existing code doesn't prevent it

The old code used $toClass(ReadStream, 'ReadStream', fs.ReadStream), which set up the constructor property correctly. The new PR dropped $toClass and replaced it with a manual lazy getter that omits the constructor assignment. Node.js itself is explicit about this — in Node's lib/tty.js:

ReadStream.prototype = Object.create(net.Socket.prototype, {
  constructor: { value: ReadStream, ... }
});

The codebase itself follows this pattern elsewhere — events.ts:89 has EventEmitterPrototype.constructor = EventEmitter — but the ReadStream getter does not.

Impact

instanceof checks still work (they use the prototype chain, not .constructor), so the primary use of ReadStream is unaffected. However:

  • someReadStream.constructor === tty.ReadStream returns false
  • someReadStream.constructor.name returns 'Socket' instead of 'ReadStream'
  • Error reporting, debugging tools, and libraries that do constructor-based type discrimination (common in the Node.js ecosystem) will see the wrong class name.
  • process.stdin.constructor.name returns 'Socket' on a TTY, which is a Node.js compatibility regression.

Step-by-step proof

  1. const tty = require('node:tty');
  2. The lazy getter runs: Prototype = Object.create(Socket.prototype)
  3. Prototype has no own constructor property.
  4. Object.defineProperty(ReadStream, 'prototype', { value: Prototype }) — Prototype is now ReadStream.prototype.
  5. (new tty.ReadStream(0)).__proto__ === Prototype — the instance inherits from Prototype.
  6. (new tty.ReadStream(0)).constructor — no own property, walks to Prototype.__proto__ which is Socket.prototype, finds Socket.prototype.constructor === Socket.
  7. Result: (new tty.ReadStream(0)).constructor === Socket ✓ (the bug), not ReadStream.

How to fix

Add one line inside the lazy getter after Object.create(Socket.prototype):

Prototype.constructor = ReadStream;

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.

2 participants