diff --git a/docs/runtime/child-process.mdx b/docs/runtime/child-process.mdx index a1b7ef9b2cd502..da27babd1950c7 100644 --- a/docs/runtime/child-process.mdx +++ b/docs/runtime/child-process.mdx @@ -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 diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d510becbda55fe..7bfad2efd13fe4 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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 extends BaseOptions< - In, - Out, - Err - > {} - - interface SpawnOptions extends BaseOptions< - In, - Out, - Err - > { + interface SpawnSyncOptions + extends BaseOptions {} + + interface SpawnOptions + extends BaseOptions { /** * If true, stdout and stderr pipes will not automatically start reading * data. Reading will only begin when you access the `stdout` or `stderr` diff --git a/src/bun.js/api/bun/js_bun_spawn_bindings.zig b/src/bun.js/api/bun/js_bun_spawn_bindings.zig index 7b957f8929b522..8ec181719a3043 100644 --- a/src/bun.js/api/bun/js_bun_spawn_bindings.zig +++ b/src/bun.js/api/bun/js_bun_spawn_bindings.zig @@ -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"); diff --git a/src/bun.js/api/bun/process.zig b/src/bun.js/api/bun/process.zig index 175b3533f4a5ae..f896ead5577c2f 100644 --- a/src/bun.js/api/bun/process.zig +++ b/src/bun.js/api/bun/process.zig @@ -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. diff --git a/src/bun.js/api/bun/spawn/stdio.zig b/src/bun.js/api/bun/spawn/stdio.zig index 457cc34d69143c..589558c2c4bc95 100644 --- a/src/bun.js/api/bun/spawn/stdio.zig +++ b/src/bun.js/api/bun/spawn/stdio.zig @@ -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 { diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig index f81e52c1f63f39..39044a4df9edc2 100644 --- a/src/bun.js/api/bun/subprocess.zig +++ b/src/bun.js/api/bun/subprocess.zig @@ -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 diff --git a/src/bun.js/api/bun/subprocess/Readable.zig b/src/bun.js/api/bun/subprocess/Readable.zig index 50108c84f6154e..31c84996ae9ea1 100644 --- a/src/bun.js/api/bun/subprocess/Readable.zig +++ b/src/bun.js/api/bun/subprocess/Readable.zig @@ -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; }, } diff --git a/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig b/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig index aa4ec42aac0a6c..e95dbc2a0644ea 100644 --- a/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig +++ b/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig @@ -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); } diff --git a/src/bun.js/api/bun/subprocess/Writable.zig b/src/bun.js/api/bun/subprocess/Writable.zig index dde982beef4fb0..5223118b133e50 100644 --- a/src/bun.js/api/bun/subprocess/Writable.zig +++ b/src/bun.js/api/bun/subprocess/Writable.zig @@ -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, + }; + }, } } diff --git a/src/io/PipeReader.zig b/src/io/PipeReader.zig index 0641161b13d05a..adadbffb0ca46b 100644 --- a/src/io/PipeReader.zig +++ b/src/io/PipeReader.zig @@ -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 .{ diff --git a/src/shell/subproc.zig b/src/shell/subproc.zig index b4e6bb08e4686e..f2884d7b0090ce 100644 --- a/src/shell/subproc.zig +++ b/src/shell/subproc.zig @@ -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 }; } diff --git a/src/sys.zig b/src/sys.zig index acc8fbd66efc17..98a369f2265f81 100644 --- a/src/sys.zig +++ b/src/sys.zig @@ -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. diff --git a/test/js/bun/spawn/spawn-pty.test.ts b/test/js/bun/spawn/spawn-pty.test.ts new file mode 100644 index 00000000000000..14649413b9e35d --- /dev/null +++ b/test/js/bun/spawn/spawn-pty.test.ts @@ -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"); + }); +});