mirror of
https://github.com/oven-sh/bun
synced 2026-02-04 16:08:53 +00:00
Compare commits
12 Commits
dylan/pyth
...
claude/spa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31f2ef407 | ||
|
|
064b1d36d1 | ||
|
|
e67ed9de05 | ||
|
|
021dac5748 | ||
|
|
bc239627a1 | ||
|
|
0245de5c78 | ||
|
|
6d4965c79b | ||
|
|
fff68a0fb1 | ||
|
|
74bea006fd | ||
|
|
5d775066b2 | ||
|
|
f773816d02 | ||
|
|
ee4ace7159 |
@@ -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
|
||||
|
||||
37
packages/bun-types/bun.d.ts
vendored
37
packages/bun-types/bun.d.ts
vendored
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 .{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
64
src/sys.zig
64
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.
|
||||
|
||||
167
test/js/bun/spawn/spawn-pty.test.ts
Normal file
167
test/js/bun/spawn/spawn-pty.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user