Compare commits

...

12 Commits

Author SHA1 Message Date
Claude Bot
c31f2ef407 fix: track duped PTY master FDs for error cleanup
Add duped master FDs to to_close_on_error list so they are properly
cleaned up if spawn fails after duping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:48:18 +00:00
autofix-ci[bot]
064b1d36d1 [autofix.ci] apply automated fixes 2025-12-03 11:37:01 +00:00
Claude Bot
e67ed9de05 fix: clarify Writable.zig PTY comment for Windows
Update comment to clearly state PTY stdin is not supported on Windows
rather than misleadingly mentioning "falls back to pipe".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:35:11 +00:00
Claude Bot
021dac5748 fix: address CodeRabbit PTY lifecycle and docs review comments
- Fix PTY master FD leak: close original master FD after spawn succeeds
  since parent uses dup()'d copies for stdio/extra_fds
- Fix PosixSpawnResult.close: correct type signature and add PTY handling
- Harmonize shell PTY behavior: change POSIX stdin panic to return ignore
  (consistent with Windows behavior)
- Update docs: add "Not supported with spawnSync" to PTY table entries
- Update docs: expand TS type comments with platform/spawnSync notes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:34:37 +00:00
Claude Bot
bc239627a1 fix: handle PTY case in Writable.zig Windows switch
Add .pty case to the Windows switch statement in Writable.zig.
On Windows, PTY falls back to pipe behavior, so stdin with PTY
returns ignore (same as other unsupported stdin types).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:21:56 +00:00
Claude Bot
0245de5c78 fix: address CodeRabbit review comments for PTY support
- Fix docs example consistency: use array form consistently
- Add spawnSync warning to TypeScript PTY type definitions
- Fix Windows compile error in js_bun_spawn_bindings.zig
- Fix extra_fds PTY to use dup() for consistency
- Add isNumber() type check for PTY width/height options
- Fix error message consistency in stdio.zig
- Fix switch case overlap in shell/subproc.zig (remove .pipe/.readable_stream that were already handled)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 11:11:03 +00:00
autofix-ci[bot]
6d4965c79b [autofix.ci] apply automated fixes 2025-12-03 10:48:11 +00:00
Claude Bot
fff68a0fb1 docs: update PTY docs to say it falls back to pipe on Windows
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:46:30 +00:00
Claude Bot
74bea006fd feat(spawn): finalize PTY support with docs and tests
- Add comprehensive PTY documentation with examples:
  - Basic usage
  - Multiple PTY streams (stdin/stdout/stderr)
  - Custom terminal dimensions (width/height)
  - Colored output from git, grep, etc.
  - Platform support table
  - Limitations section

- Make PTY fall back to pipe on Windows (instead of error)
- Add spawnSync error for PTY (not supported)
- Add tests for spawnSync PTY error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:44:04 +00:00
Claude Bot
5d775066b2 test(spawn): expand PTY test coverage
Add tests for:
- stderr: 'pty' only
- stdin: 'pty' only (stdout/stderr not PTY)
- PTY object syntax with custom width/height dimensions
- ANSI color output detection
- Multiple concurrent PTY spawns
- Windows error handling (skipped on non-Windows)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:27:26 +00:00
Claude Bot
f773816d02 cleanup: remove unused PTY code, add types and docs
- Remove unused uws import from PipeReader.zig
- Remove unused PTY helper functions (setWinSize, getWinSize, setControllingTerminal)
- Remove unused ioctl constants (TIOCSWINSZ, TIOCSCTTY, TIOCGWINSZ)
- Change spawn_bindings log visibility back to hidden
- Add "pty" to TypeScript types in bun.d.ts
- Add PTY documentation to docs/runtime/child-process.mdx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 10:24:36 +00:00
Claude Bot
ee4ace7159 feat(spawn): add PTY support for stdin, stdout, stderr
Add support for `stdin: "pty"`, `stdout: "pty"`, and `stderr: "pty"` options
in `Bun.spawn()`. This allows spawned processes to see `process.stdout.isTTY === true`.

Key implementation details:
- PTY creation via openpty/forkpty syscalls in sys.zig
- When multiple stdios use PTY, they share the same master FD
- Handle epoll EEXIST gracefully when FD is already registered
- Timer-based polling fallback for shared PTY FDs
- EIO treated as normal EOF when PTY slave closes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:08:06 +00:00
13 changed files with 655 additions and 50 deletions

View File

@@ -39,18 +39,19 @@ const text = await proc.stdout.text();
console.log(text); // "const input = "hello world".repeat(400); ..."
```
| Value | Description |
| ------------------------ | ------------------------------------------------ |
| `null` | **Default.** Provide no input to the subprocess |
| `"pipe"` | Return a `FileSink` for fast incremental writing |
| `"inherit"` | Inherit the `stdin` of the parent process |
| `Bun.file()` | Read from the specified file |
| `TypedArray \| DataView` | Use a binary buffer as input |
| `Response` | Use the response `body` as input |
| `Request` | Use the request `body` as input |
| `ReadableStream` | Use a readable stream as input |
| `Blob` | Use a blob as input |
| `number` | Read from the file with a given file descriptor |
| Value | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `null` | **Default.** Provide no input to the subprocess |
| `"pipe"` | Return a `FileSink` for fast incremental writing |
| `"inherit"` | Inherit the `stdin` of the parent process |
| `"pty"` | Use a pseudo-terminal (PTY). Child sees `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`. |
| `Bun.file()` | Read from the specified file |
| `TypedArray \| DataView` | Use a binary buffer as input |
| `Response` | Use the response `body` as input |
| `Request` | Use the request `body` as input |
| `ReadableStream` | Use a readable stream as input |
| `Blob` | Use a blob as input |
| `number` | Read from the file with a given file descriptor |
The `"pipe"` option lets incrementally write to the subprocess's input stream from the parent process.
@@ -105,13 +106,121 @@ console.log(text); // => "1.3.3\n"
Configure the output stream by passing one of the following values to `stdout/stderr`:
| Value | Description |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `"pipe"` | **Default for `stdout`.** Pipe the output to a `ReadableStream` on the returned `Subprocess` object |
| `"inherit"` | **Default for `stderr`.** Inherit from the parent process |
| `"ignore"` | Discard the output |
| `Bun.file()` | Write to the specified file |
| `number` | Write to the file with the given file descriptor |
| Value | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `"pipe"` | **Default for `stdout`.** Pipe the output to a `ReadableStream` on the returned `Subprocess` object |
| `"inherit"` | **Default for `stderr`.** Inherit from the parent process |
| `"ignore"` | Discard the output |
| `"pty"` | Use a pseudo-terminal (PTY). Child sees `process.stdout.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`. |
| `Bun.file()` | Write to the specified file |
| `number` | Write to the file with the given file descriptor |
## Pseudo-terminal (PTY)
Use `"pty"` to spawn a process with a pseudo-terminal, making the child process believe it's running in an interactive terminal. This causes `process.stdout.isTTY` to be `true` in the child, which is useful for:
- Getting colored output from CLI tools that detect TTY
- Running interactive programs that require a terminal
- Testing terminal-dependent behavior
### Basic usage
```ts
const proc = Bun.spawn(["ls", "--color=auto"], {
stdout: "pty",
});
// The child process sees process.stdout.isTTY === true
const output = await proc.stdout.text();
console.log(output); // includes ANSI color codes
```
### Checking isTTY in child process
```ts
const proc = Bun.spawn(["bun", "-e", "console.log('isTTY:', process.stdout.isTTY)"], {
stdout: "pty",
});
const output = await proc.stdout.text();
console.log(output); // "isTTY: true"
```
### Multiple PTY streams
You can use PTY for stdin, stdout, and/or stderr. When multiple streams use PTY, they share the same underlying pseudo-terminal:
```ts
const code = `
console.log("stdout.isTTY:", process.stdout.isTTY);
console.log("stdin.isTTY:", process.stdin.isTTY);
console.log("stderr.isTTY:", process.stderr.isTTY);
`;
const proc = Bun.spawn(["bun", "-e", code], {
stdin: "pty",
stdout: "pty",
stderr: "pty",
});
const output = await proc.stdout.text();
// stdout.isTTY: true
// stdin.isTTY: true
// stderr.isTTY: true
```
### Custom terminal dimensions
Specify terminal width and height using the object syntax:
```ts
const code = `
console.log("columns:", process.stdout.columns);
console.log("rows:", process.stdout.rows);
`;
const proc = Bun.spawn(["bun", "-e", code], {
stdout: {
type: "pty",
width: 120, // columns
height: 40, // rows
},
});
const output = await proc.stdout.text();
// columns: 120
// rows: 40
```
### Getting colored output from git, grep, etc.
Many CLI tools detect whether they're running in a TTY and only emit colors when they are:
```ts
// Without PTY - no colors
const noColor = Bun.spawn(["git", "status"], { stdout: "pipe" });
console.log(await noColor.stdout.text()); // plain text
// With PTY - colors enabled
const withColor = Bun.spawn(["git", "status"], { stdout: "pty" });
console.log(await withColor.stdout.text()); // includes ANSI color codes
```
### Platform support
| Platform | PTY Support |
| -------- | -------------------------------------------- |
| macOS | ✅ Full support |
| Linux | ✅ Full support |
| Windows | ⚠️ Falls back to `"pipe"` (no TTY semantics) |
On Windows, `"pty"` silently falls back to `"pipe"` behavior. The child process will see `process.stdout.isTTY` as `undefined`. This allows cross-platform code to work without errors, though TTY-dependent features won't work on Windows.
### Limitations
- **Not supported with `spawnSync`**: PTY requires asynchronous I/O. Using `"pty"` with `Bun.spawnSync()` will throw an error.
- **Line endings**: PTY converts `\n` to `\r\n` on output (standard terminal behavior).
- **No dynamic resize**: Terminal dimensions are set at spawn time and cannot be changed after.
## Exit handling
@@ -413,6 +522,7 @@ namespace SpawnOptions {
| "pipe"
| "inherit"
| "ignore"
| "pty" // use a pseudo-terminal (macOS/Linux only, falls back to "pipe" on Windows, not supported with spawnSync)
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -423,6 +533,7 @@ namespace SpawnOptions {
| "pipe"
| "inherit"
| "ignore"
| "pty" // use a pseudo-terminal (macOS/Linux only, falls back to "pipe" on Windows, not supported with spawnSync)
| null // equivalent to "ignore"
| undefined // to use default
| BunFile

View File

@@ -1740,9 +1740,9 @@ declare module "bun" {
* @default "esm"
*/
format?: /**
* ECMAScript Module format
*/
| "esm"
* ECMAScript Module format
*/
| "esm"
/**
* CommonJS format
* **Experimental**
@@ -3316,10 +3316,10 @@ declare module "bun" {
function color(
input: ColorInput,
outputFormat?: /**
* True color ANSI color string, for use in terminals
* @example \x1b[38;2;100;200;200m
*/
| "ansi"
* True color ANSI color string, for use in terminals
* @example \x1b[38;2;100;200;200m
*/
| "ansi"
| "ansi-16"
| "ansi-16m"
/**
@@ -5335,6 +5335,7 @@ declare module "bun" {
| "pipe"
| "inherit"
| "ignore"
| "pty"
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -5348,6 +5349,7 @@ declare module "bun" {
| "pipe"
| "inherit"
| "ignore"
| "pty"
| null // equivalent to "ignore"
| undefined // to use default
| BunFile
@@ -5405,14 +5407,16 @@ declare module "bun" {
* - `"ignore"`, `null`, `undefined`: The process will have no standard input (default)
* - `"pipe"`: The process will have a new {@link FileSink} for standard input
* - `"inherit"`: The process will inherit the standard input of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`, `Blob`, `Bun.file()`, `Response`, `Request`: The process will read from buffer/stream.
* - `number`: The process will read from the file descriptor
*
* For stdout and stdin you may pass:
* For stdout and stderr you may pass:
*
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdout.isTTY === true` / `process.stderr.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5427,6 +5431,7 @@ declare module "bun" {
* - `"ignore"`, `null`, `undefined`: The process will have no standard input
* - `"pipe"`: The process will have a new {@link FileSink} for standard input
* - `"inherit"`: The process will inherit the standard input of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdin.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`, `Blob`: The process will read from the buffer
* - `number`: The process will read from the file descriptor
*
@@ -5439,6 +5444,7 @@ declare module "bun" {
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stdout.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5451,6 +5457,7 @@ declare module "bun" {
* - `"pipe"`, `undefined`: The process will have a {@link ReadableStream} for standard output/error
* - `"ignore"`, `null`: The process will have no standard output/error
* - `"inherit"`: The process will inherit the standard output/error of the current process
* - `"pty"`: The process will use a pseudo-terminal (PTY). The child will see `process.stderr.isTTY === true`. Falls back to `"pipe"` on Windows. Not supported with `spawnSync`.
* - `ArrayBufferView`: The process write to the preallocated buffer. Not implemented.
* - `number`: The process will write to the file descriptor
*
@@ -5650,17 +5657,11 @@ declare module "bun" {
maxBuffer?: number;
}
interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable> extends BaseOptions<
In,
Out,
Err
> {}
interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {}
interface SpawnOptions<In extends Writable, Out extends Readable, Err extends Readable> extends BaseOptions<
In,
Out,
Err
> {
interface SpawnOptions<In extends Writable, Out extends Readable, Err extends Readable>
extends BaseOptions<In, Out, Err> {
/**
* If true, stdout and stderr pipes will not automatically start reading
* data. Reading will only begin when you access the `stdout` or `stderr`

View File

@@ -635,6 +635,16 @@ pub fn spawnMaybeSync(
.stdout_maxbuf = subprocess.stdout_maxbuf,
};
if (comptime Environment.isPosix) {
log("After subprocess init: stdout state={s}, stdin FD={?d}, stdout FD={?d}", .{
@tagName(subprocess.stdout),
if (spawned.stdin) |fd| fd.native() else null,
if (spawned.stdout) |fd| fd.native() else null,
});
} else {
log("After subprocess init: stdout state={s}", .{@tagName(subprocess.stdout)});
}
subprocess.process.setExitHandler(subprocess);
promise_for_stream.ensureStillAlive();
@@ -997,7 +1007,7 @@ pub fn appendEnvpFromJS(globalThis: *jsc.JSGlobalObject, object: *jsc.JSObject,
}
}
const log = Output.scoped(.Subprocess, .hidden);
const log = Output.scoped(.spawn_bindings, .hidden);
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
const IPC = @import("../../ipc.zig");

View File

@@ -1004,6 +1004,13 @@ pub const PosixSpawnOptions = struct {
pipe: bun.FileDescriptor,
// TODO: remove this entry, it doesn't seem to be used
dup2: struct { out: bun.jsc.Subprocess.StdioKind, to: bun.jsc.Subprocess.StdioKind },
/// Pseudo-terminal with optional window size configuration
pty: PtyConfig,
pub const PtyConfig = struct {
width: u16 = 80,
height: u16 = 24,
};
};
pub fn deinit(_: *const PosixSpawnOptions) void {
@@ -1104,15 +1111,21 @@ pub const PosixSpawnResult = struct {
extra_pipes: std.array_list.Managed(bun.FileDescriptor) = std.array_list.Managed(bun.FileDescriptor).init(bun.default_allocator),
memfds: [3]bool = .{ false, false, false },
/// PTY master file descriptor if PTY was requested for any stdio.
/// The child process has the slave side; parent uses this for I/O.
pty_master: ?bun.FileDescriptor = null,
// ESRCH can happen when requesting the pidfd
has_exited: bool = false,
pub fn close(this: *WindowsSpawnResult) void {
pub fn close(this: *PosixSpawnResult) void {
if (this.pty_master) |fd| {
fd.close();
this.pty_master = null;
}
for (this.extra_pipes.items) |fd| {
fd.close();
}
this.extra_pipes.clearAndFree();
}
@@ -1301,6 +1314,38 @@ pub fn spawnProcessPosix(
var dup_stdout_to_stderr: bool = false;
// Check if any stdio uses PTY and create a single PTY pair if needed
var pty_slave: ?bun.FileDescriptor = null;
var pty_master: ?bun.FileDescriptor = null;
for (stdio_options) |opt| {
if (opt == .pty) {
// Create PTY pair with the configured window size
const winsize = bun.sys.WinSize{
.ws_col = opt.pty.width,
.ws_row = opt.pty.height,
};
const pty_pair = try bun.sys.openpty(&winsize).unwrap();
pty_master = pty_pair.master;
pty_slave = pty_pair.slave;
log("PTY created: master={d}, slave={d}", .{ pty_pair.master.native(), pty_pair.slave.native() });
// Track for cleanup
try to_close_at_end.append(pty_pair.slave);
try to_close_on_error.append(pty_pair.master);
// Set master to non-blocking for async operations
if (!options.sync) {
try bun.sys.setNonblocking(pty_pair.master).unwrap();
}
spawned.pty_master = pty_pair.master;
break;
}
}
for (0..3) |i| {
const stdio = stdios[i];
const fileno = bun.FD.fromNative(@intCast(i));
@@ -1417,6 +1462,29 @@ pub fn spawnProcessPosix(
try actions.dup2(fd, fileno);
stdio.* = fd;
},
.pty => {
// Use the slave side of the PTY for this stdio
// The PTY pair was already created above
const slave = pty_slave.?;
try actions.dup2(slave, fileno);
// The parent gets the master side for I/O.
// Each stdio gets its own dup'd FD so they can register with epoll independently.
// stderr is ignored if stdout already has PTY (they share the same stream).
if (i == 2 and stdio_options[1] == .pty) {
// stdout is also PTY, stderr becomes ignore (user reads both from stdout)
stdio.* = null;
log("PTY stderr: ignored (stdout has PTY)", .{});
} else {
// dup() the master FD so each stdio has its own FD for epoll
const duped = try bun.sys.dup(pty_master.?).unwrap();
if (!options.sync) {
try bun.sys.setNonblocking(duped).unwrap();
}
try to_close_on_error.append(duped);
stdio.* = duped;
log("PTY {s}: duped master={d}", .{ if (i == 0) "stdin" else if (i == 1) "stdout" else "stderr", duped.native() });
}
},
}
}
@@ -1463,6 +1531,19 @@ pub fn spawnProcessPosix(
try extra_fds.append(fd);
},
.pty => {
// Use existing PTY slave (should have been created from primary stdio)
if (pty_slave) |slave| {
try actions.dup2(slave, fileno);
// dup() the master FD so each extra_fd has its own FD for epoll
const duped = try bun.sys.dup(pty_master.?).unwrap();
if (!options.sync) {
try bun.sys.setNonblocking(duped).unwrap();
}
try to_close_on_error.append(duped);
try extra_fds.append(duped);
}
},
}
}
@@ -1494,6 +1575,13 @@ pub fn spawnProcessPosix(
spawned.extra_pipes = extra_fds;
extra_fds = std.array_list.Managed(bun.FileDescriptor).init(bun.default_allocator);
// Parent uses dup()'d copies of the PTY master for stdio/extra_fds;
// the original master FD is no longer needed and should be closed
// to avoid leaking one FD per PTY spawn.
if (pty_master) |fd| {
fd.close();
}
if (comptime Environment.isLinux) {
// If it's spawnSync and we want to block the entire thread
// don't even bother with pidfd. It's not necessary.

View File

@@ -14,6 +14,16 @@ pub const Stdio = union(enum) {
pipe,
ipc,
readable_stream: jsc.WebCore.ReadableStream,
/// Pseudo-terminal: creates a PTY master/slave pair for the spawned process.
/// The child gets the slave side, parent gets the master side for I/O.
pty: PtyOptions,
pub const PtyOptions = struct {
/// Terminal width in columns (default: 80)
width: u16 = 80,
/// Terminal height in rows (default: 24)
height: u16 = 24,
};
const log = bun.sys.syslog;
@@ -192,6 +202,7 @@ pub const Stdio = union(enum) {
.path => |pathlike| .{ .path = pathlike.slice() },
.inherit => .{ .inherit = {} },
.ignore => .{ .ignore = {} },
.pty => |pty_opts| .{ .pty = .{ .width = pty_opts.width, .height = pty_opts.height } },
},
};
}
@@ -244,6 +255,7 @@ pub const Stdio = union(enum) {
.path => |pathlike| .{ .path = pathlike.slice() },
.inherit => .{ .inherit = {} },
.ignore => .{ .ignore = {} },
.pty => .{ .buffer = bun.handleOom(bun.default_allocator.create(uv.Pipe)) }, // PTY falls back to pipe on Windows
.memfd => @panic("This should never happen"),
},
@@ -346,8 +358,18 @@ pub const Stdio = union(enum) {
out_stdio.* = Stdio{ .pipe = {} };
} else if (str.eqlComptime("ipc")) {
out_stdio.* = Stdio{ .ipc = {} };
} else if (str.eqlComptime("pty")) {
if (is_sync) {
return globalThis.throwInvalidArguments("PTY is not supported with spawnSync", .{});
}
// On Windows, PTY falls back to pipe (no real PTY support)
if (comptime Environment.isWindows) {
out_stdio.* = Stdio{ .pipe = {} };
} else {
out_stdio.* = Stdio{ .pty = .{} };
}
} else {
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', Bun.file(pathOrFd), number, or null", .{});
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', 'pty', Bun.file(pathOrFd), number, or null", .{});
}
return;
} else if (value.isNumber()) {
@@ -436,7 +458,56 @@ pub const Stdio = union(enum) {
return;
}
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'ignore', or null", .{});
// Check for PTY object: { type: "pty", width?: number, height?: number }
if (value.isObject()) {
if (try value.getTruthy(globalThis, "type")) |type_val| {
if (type_val.isString()) {
const type_str = try type_val.getZigString(globalThis);
if (type_str.eqlComptime("pty")) {
if (is_sync) {
return globalThis.throwInvalidArguments("PTY is not supported with spawnSync", .{});
}
// On Windows, PTY falls back to pipe (no real PTY support)
if (comptime Environment.isWindows) {
out_stdio.* = Stdio{ .pipe = {} };
return;
}
var pty_opts: PtyOptions = .{};
if (try value.get(globalThis, "width")) |width_val| {
if (!width_val.isUndefinedOrNull()) {
if (!width_val.isNumber()) {
return globalThis.throwInvalidArguments("PTY width must be a number", .{});
}
const width = width_val.toInt32();
if (width <= 0 or width > std.math.maxInt(u16)) {
return globalThis.throwInvalidArguments("PTY width must be a positive integer <= 65535", .{});
}
pty_opts.width = @intCast(width);
}
}
if (try value.get(globalThis, "height")) |height_val| {
if (!height_val.isUndefinedOrNull()) {
if (!height_val.isNumber()) {
return globalThis.throwInvalidArguments("PTY height must be a number", .{});
}
const height = height_val.toInt32();
if (height <= 0 or height > std.math.maxInt(u16)) {
return globalThis.throwInvalidArguments("PTY height must be a positive integer <= 65535", .{});
}
pty_opts.height = @intCast(height);
}
}
out_stdio.* = Stdio{ .pty = pty_opts };
return;
}
}
}
}
return globalThis.throwInvalidArguments("stdio must be an array of 'inherit', 'pipe', 'ignore', 'pty', Bun.file(pathOrFd), number, or null", .{});
}
pub fn extractBlob(stdio: *Stdio, globalThis: *jsc.JSGlobalObject, blob: jsc.WebCore.Blob.Any, i: i32) bun.JSError!void {

View File

@@ -282,6 +282,7 @@ pub fn getStdin(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSVa
}
pub fn getStdout(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
log("getStdout: subprocess={*}, stdout ptr={*}, stdout state={s}", .{ this, &this.stdout, @tagName(this.stdout) });
this.observable_getters.insert(.stdout);
// NOTE: ownership of internal buffers is transferred to the JSValue, which
// gets cached on JSSubprocess (created via bindgen). This makes it

View File

@@ -1,3 +1,5 @@
const log = Output.scoped(.Readable, .hidden);
pub const Readable = union(enum) {
fd: bun.FileDescriptor,
memfd: bun.FileDescriptor,
@@ -57,7 +59,8 @@ pub const Readable = union(enum) {
}
}
return switch (stdio) {
log("Readable.init: stdio={s}, result={?d}, subprocess={*}, stdout state={s}", .{ @tagName(stdio), if (comptime Environment.isPosix) (if (result) |r| r.native() else null) else @as(?c_int, null), process, @tagName(process.stdout) });
const readable = switch (stdio) {
.inherit => Readable{ .inherit = {} },
.ignore, .ipc, .path => Readable{ .ignore = {} },
.fd => |fd| if (Environment.isPosix) Readable{ .fd = result.? } else Readable{ .fd = fd },
@@ -67,10 +70,22 @@ pub const Readable = union(enum) {
.array_buffer, .blob => Output.panic("TODO: implement ArrayBuffer & Blob support in Stdio readable", .{}),
.capture => Output.panic("TODO: implement capture support in Stdio readable", .{}),
.readable_stream => Readable{ .ignore = {} }, // ReadableStream is handled separately
.pty => if (Environment.isPosix and result == null) blk: {
// When stdout and stderr both use PTY, they share the same master FD.
// stderr's result will be null - ignore it since stdout handles reading.
log("PTY with null result -> ignore", .{});
break :blk Readable{ .ignore = {} };
} else blk: {
log("PTY with result -> creating pipe reader", .{});
break :blk Readable{ .pipe = PipeReader.createForPty(event_loop, process, result, max_size) }; // PTY master - use read() not recv()
},
};
log("Readable.init returning: {s}", .{@tagName(readable)});
return readable;
}
pub fn onClose(this: *Readable, _: ?bun.sys.Error) void {
pub fn onClose(this: *Readable, err: ?bun.sys.Error) void {
log("onClose called, current state={s}, err={?s}", .{ @tagName(this.*), if (err) |e| @tagName(e.getErrno()) else null });
this.* = .closed;
}
@@ -116,6 +131,7 @@ pub const Readable = union(enum) {
pub fn toJS(this: *Readable, globalThis: *jsc.JSGlobalObject, exited: bool) bun.JSError!JSValue {
_ = exited; // autofix
log("Readable.toJS: this={*}, state={s}", .{ this, @tagName(this.*) });
switch (this.*) {
// should only be reachable when the entire output is buffered.
.memfd => return this.toBufferedValue(globalThis),
@@ -139,6 +155,7 @@ pub const Readable = union(enum) {
return jsc.WebCore.ReadableStream.fromOwnedSlice(globalThis, own, 0);
},
else => {
log("Readable.toJS returning undefined for state={s}", .{@tagName(this.*)});
return .js_undefined;
},
}

View File

@@ -14,6 +14,8 @@ state: union(enum) {
err: bun.sys.Error,
} = .{ .pending = {} },
stdio_result: StdioResult,
/// True if this is a PTY master (character device, not socket - use read() not recv())
is_pty: bool = false,
pub const IOReader = bun.io.BufferedReader;
pub const Poll = IOReader;
@@ -34,12 +36,21 @@ pub fn detach(this: *PipeReader) void {
}
pub fn create(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf) *PipeReader {
return createWithOptions(event_loop, process, result, limit, false);
}
pub fn createForPty(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf) *PipeReader {
return createWithOptions(event_loop, process, result, limit, true);
}
fn createWithOptions(event_loop: *jsc.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf, is_pty: bool) *PipeReader {
var this = bun.new(PipeReader, .{
.ref_count = .init(),
.process = process,
.reader = IOReader.init(@This()),
.event_loop = event_loop,
.stdio_result = result,
.is_pty = is_pty,
});
MaxBuf.addToPipereader(limit, &this.reader.maxbuf);
if (Environment.isWindows) {
@@ -63,6 +74,13 @@ pub fn start(this: *PipeReader, process: *Subprocess, event_loop: *jsc.EventLoop
return this.reader.startWithCurrentPipe();
}
// Set PTY flag BEFORE start() so that onError can check it during registerPoll()
if (comptime Environment.isPosix) {
if (this.is_pty) {
this.reader.flags.is_pty = true;
}
}
switch (this.reader.start(this.stdio_result.?, true)) {
.err => |err| {
return .{ .err = err };
@@ -70,8 +88,11 @@ pub fn start(this: *PipeReader, process: *Subprocess, event_loop: *jsc.EventLoop
.result => {
if (comptime Environment.isPosix) {
const poll = this.reader.handle.poll;
poll.flags.insert(.socket);
this.reader.flags.socket = true;
// PTY is a character device, not a socket - use read() not recv()
if (!this.is_pty) {
poll.flags.insert(.socket);
this.reader.flags.socket = true;
}
this.reader.flags.nonblocking = true;
this.reader.flags.pollable = true;
poll.flags.insert(.nonblocking);
@@ -167,6 +188,13 @@ pub fn toBuffer(this: *PipeReader, globalThis: *jsc.JSGlobalObject) jsc.JSValue
}
pub fn onReaderError(this: *PipeReader, err: bun.sys.Error) void {
// For PTY, EIO is expected when the child exits (slave side closes).
// Treat it as a normal EOF, not an error.
if (this.is_pty and err.getErrno() == .IO) {
this.onReaderDone();
return;
}
if (this.state == .done) {
bun.default_allocator.free(this.state.done);
}

View File

@@ -153,6 +153,10 @@ pub const Writable = union(enum) {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// PTY stdin is not supported on Windows; return ignore
return Writable{ .ignore = {} };
},
}
}
@@ -228,6 +232,30 @@ pub const Writable = union(enum) {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// PTY uses pipe-like semantics, but with the PTY master fd
const pipe = jsc.WebCore.FileSink.create(event_loop, result.?);
switch (pipe.writer.start(pipe.fd, true)) {
.result => {},
.err => {
pipe.deref();
return error.UnexpectedCreatingStdin;
},
}
// PTY master is a character device, NOT a socket
// Do NOT set .socket flag - PTY uses write() not send()
subprocess.weak_file_sink_stdin_ptr = pipe;
subprocess.ref();
subprocess.flags.has_stdin_destructor_called = false;
subprocess.flags.deref_on_stdin_destroyed = true;
return Writable{
.pipe = pipe,
};
},
}
}

View File

@@ -94,7 +94,9 @@ const PosixBufferedReader = struct {
memfd: bool = false,
use_pread: bool = false,
is_paused: bool = false,
_: u6 = 0,
/// True if reading from PTY master - treat EIO as EOF
is_pty: bool = false,
_: u5 = 0,
};
pub fn init(comptime Type: type) PosixBufferedReader {
@@ -270,6 +272,13 @@ const PosixBufferedReader = struct {
}
pub fn onError(this: *PosixBufferedReader, err: bun.sys.Error) void {
// For PTY, EIO is expected when the child exits (slave side closes).
// Treat it as a normal EOF.
if (this.flags.is_pty and err.getErrno() == .IO) {
this.closeWithoutReporting();
this.done();
return;
}
this.vtable.onReaderError(err);
}
@@ -760,7 +769,7 @@ pub const WindowsBufferedReader = struct {
return Type.onReaderError(@as(*Type, @ptrCast(@alignCast(this))), err);
}
fn loop(this: *anyopaque) *Async.Loop {
return Type.loop(@as(*Type, @alignCast(@ptrCast(this))));
return Type.loop(@as(*Type, @ptrCast(@alignCast(this))));
}
};
return .{

View File

@@ -178,6 +178,10 @@ pub const ShellSubprocess = struct {
.ipc, .capture => {
return Writable{ .ignore = {} };
},
.pty => {
// The shell never uses PTY directly for stdin
return Writable{ .ignore = {} };
},
}
}
switch (stdio) {
@@ -217,8 +221,12 @@ pub const ShellSubprocess = struct {
return Writable{ .ignore = {} };
},
.readable_stream => {
// The shell never uses this
@panic("Unimplemented stdin readable_stream");
// The shell never uses this - fall back to ignore
return Writable{ .ignore = {} };
},
.pty => {
// The shell never uses PTY directly - fall back to ignore
return Writable{ .ignore = {} };
},
}
}
@@ -369,6 +377,7 @@ pub const ShellSubprocess = struct {
},
.capture => Readable{ .pipe = PipeReader.create(event_loop, process, result, shellio, out_type) },
.readable_stream => Readable{ .ignore = {} }, // Shell doesn't use readable_stream
.pty => Readable{ .ignore = {} }, // Shell doesn't use PTY
};
}
@@ -391,6 +400,7 @@ pub const ShellSubprocess = struct {
},
.capture => Readable{ .pipe = PipeReader.create(event_loop, process, result, shellio, out_type) },
.readable_stream => Readable{ .ignore = {} }, // Shell doesn't use readable_stream
.pty => Readable{ .ignore = {} }, // Shell doesn't use PTY
};
}

View File

@@ -221,6 +221,7 @@ pub const Tag = enum(u8) {
mmap,
munmap,
open,
openpty,
pread,
pwrite,
read,
@@ -4277,6 +4278,69 @@ pub fn dlsymWithHandle(comptime Type: type, comptime name: [:0]const u8, comptim
return Wrapper.function;
}
// =============================================================================
// PTY (Pseudo-Terminal) Support
// =============================================================================
/// Result of opening a PTY pair
pub const PtyPair = struct {
master: bun.FileDescriptor,
slave: bun.FileDescriptor,
};
/// Window size structure for terminal dimensions
pub const WinSize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16 = 0,
ws_ypixel: u16 = 0,
};
/// Opens a pseudo-terminal pair (master and slave)
/// Returns the master and slave file descriptors
pub fn openpty(winsize: ?*const WinSize) Maybe(PtyPair) {
if (comptime Environment.isWindows) {
@compileError("PTY is not supported on Windows");
}
// Use openpty() from libc which handles all the PTY setup
// On Linux it's in libutil, on macOS it's in libc
var master_fd: c_int = undefined;
var slave_fd: c_int = undefined;
// openpty is provided by libutil on Linux, libc on macOS
// Zig's std.c already links libutil on Linux when needed
const openpty_fn = @extern(*const fn (
amaster: *c_int,
aslave: *c_int,
name: ?[*:0]u8,
termp: ?*const anyopaque,
winp: ?*const WinSize,
) callconv(.c) c_int, .{ .name = "openpty" });
const rc = openpty_fn(
&master_fd,
&slave_fd,
null, // name - we don't need the slave name
null, // termios - use defaults
winsize, // window size
);
log("openpty() = {d} (master={d}, slave={d})", .{ rc, master_fd, slave_fd });
if (rc != 0) {
return .{ .err = .{
.errno = @intCast(@intFromEnum(std.posix.errno(rc))),
.syscall = .openpty,
} };
}
return .{ .result = .{
.master = bun.FileDescriptor.fromNative(master_fd),
.slave = bun.FileDescriptor.fromNative(slave_fd),
} };
}
pub const umask = switch (Environment.os) {
else => bun.c.umask,
// Using the same typedef and define for `mode_t` and `umask` as node on windows.

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
describe("Bun.spawn with PTY", () => {
test("stdout: 'pty' makes process.stdout.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdout.isTTY)"],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("true");
expect(exitCode).toBe(0);
});
test("stderr: 'pty' makes process.stderr.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.error(process.stderr.isTTY)"],
stdin: "ignore",
stdout: "inherit",
stderr: "pty",
env: bunEnv,
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr.trim()).toBe("true");
expect(exitCode).toBe(0);
});
test("stdin: 'pty' only makes process.stdin.isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdin.isTTY, process.stdout.isTTY)"],
stdin: "pty",
stdout: "pipe",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
// stdin is PTY (true), stdout is pipe (undefined - isTTY is undefined when not a TTY)
expect(stdout.trim()).toBe("true undefined");
expect(exitCode).toBe(0);
});
test("stdin: 'pty' and stdout: 'pty' makes both isTTY true", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('isTTY:', process.stdout.isTTY, process.stdin.isTTY)"],
stdin: "pty",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("isTTY: true true");
expect(exitCode).toBe(0);
});
test("stdin: 'pty', stdout: 'pty', stderr: 'pty' all share the same PTY", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log('isTTY:', process.stdout.isTTY, process.stdin.isTTY, process.stderr.isTTY)"],
stdin: "pty",
stdout: "pty",
stderr: "pty",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("isTTY: true true true");
expect(exitCode).toBe(0);
});
test("PTY object syntax with custom dimensions", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(process.stdout.columns, process.stdout.rows)"],
stdin: "ignore",
stdout: { type: "pty", width: 120, height: 40 },
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
expect(stdout.trim()).toBe("120 40");
expect(exitCode).toBe(0);
});
test("PTY enables colored output from programs that detect TTY", async () => {
// Use a simple inline script that outputs ANSI colors when stdout is a TTY
const proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
if (process.stdout.isTTY) {
console.log("\\x1b[31mred\\x1b[0m");
} else {
console.log("no-color");
}
`,
],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
});
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
// Should contain ANSI escape codes
expect(stdout).toContain("\x1b[31m");
expect(stdout).toContain("red");
expect(exitCode).toBe(0);
});
test("multiple concurrent PTY spawns work correctly", async () => {
const procs = Array.from({ length: 5 }, (_, i) =>
Bun.spawn({
cmd: [bunExe(), "-e", `console.log("proc${i}:", process.stdout.isTTY)`],
stdin: "ignore",
stdout: "pty",
stderr: "inherit",
env: bunEnv,
}),
);
const results = await Promise.all(
procs.map(async (proc, i) => {
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
return { stdout: stdout.trim(), exitCode, index: i };
}),
);
for (const result of results) {
expect(result.stdout).toBe(`proc${result.index}: true`);
expect(result.exitCode).toBe(0);
}
});
});
describe("Bun.spawnSync with PTY", () => {
test("throws error when PTY is used with spawnSync", () => {
expect(() => {
Bun.spawnSync({
cmd: ["echo", "test"],
stdout: "pty",
});
}).toThrow("PTY is not supported with spawnSync");
});
test("throws error when PTY object syntax is used with spawnSync", () => {
expect(() => {
Bun.spawnSync({
cmd: ["echo", "test"],
stdout: { type: "pty", width: 80, height: 24 },
});
}).toThrow("PTY is not supported with spawnSync");
});
});