feat: add Bun.Terminal API for pseudo-terminal (PTY) support (#25415)

## Summary

This PR adds a new `Bun.Terminal` API for creating and managing
pseudo-terminals (PTYs), enabling interactive terminal applications in
Bun.

### Features

- **Standalone Terminal**: Create PTYs directly with `new
Bun.Terminal(options)`
- **Spawn Integration**: Spawn processes with PTY attached via
`Bun.spawn({ terminal: options })`
- **Full PTY Control**: Write data, resize, set raw mode, and handle
callbacks

## Examples

### Basic Terminal with Spawn (Recommended)

```typescript
const proc = Bun.spawn(["bash"], {
  terminal: {
    cols: 80,
    rows: 24,
    data(terminal, data) {
      // Handle output from the terminal
      process.stdout.write(data);
    },
    exit(terminal, code, signal) {
      console.log(`Process exited with code ${code}`);
    },
  },
});

// Write commands to the terminal
proc.terminal.write("echo Hello from PTY!\n");
proc.terminal.write("exit\n");

await proc.exited;
proc.terminal.close();
```

### Interactive Shell

```typescript
// Create an interactive shell that mirrors to stdout
const proc = Bun.spawn(["bash", "-i"], {
  terminal: {
    cols: process.stdout.columns || 80,
    rows: process.stdout.rows || 24,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Forward stdin to the terminal
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Running Interactive Programs (vim, htop, etc.)

```typescript
const proc = Bun.spawn(["vim", "file.txt"], {
  terminal: {
    cols: process.stdout.columns,
    rows: process.stdout.rows,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Handle terminal resize
process.stdout.on("resize", () => {
  proc.terminal.resize(process.stdout.columns, process.stdout.rows);
});

// Forward input
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Capturing Colored Output

```typescript
const chunks: Uint8Array[] = [];

const proc = Bun.spawn(["ls", "--color=always"], {
  terminal: {
    data(term, data) {
      chunks.push(data);
    },
  },
});

await proc.exited;
proc.terminal.close();

// Output includes ANSI color codes
const output = Buffer.concat(chunks).toString();
console.log(output);
```

### Standalone Terminal (Advanced)

```typescript
const terminal = new Bun.Terminal({
  cols: 80,
  rows: 24,
  data(term, data) {
    console.log("Received:", data.toString());
  },
});

// Use terminal.stdin as the fd for child process stdio
const proc = Bun.spawn(["bash"], {
  stdin: terminal.stdin,
  stdout: terminal.stdin,
  stderr: terminal.stdin,
});

terminal.write("echo hello\n");

// Clean up
terminal.close();
```

### Testing TTY Detection

```typescript
const proc = Bun.spawn([
  "bun", "-e", 
  "console.log('isTTY:', process.stdout.isTTY)"
], {
  terminal: {},
});

// Output: isTTY: true
```

## API

### `Bun.spawn()` with `terminal` option

```typescript
const proc = Bun.spawn(cmd, {
  terminal: {
    cols?: number,        // Default: 80
    rows?: number,        // Default: 24  
    name?: string,        // Default: "xterm-256color"
    data?: (terminal: Terminal, data: Uint8Array) => void,
    exit?: (terminal: Terminal, code: number, signal: string | null) => void,
    drain?: (terminal: Terminal) => void,
  }
});

// Access the terminal
proc.terminal.write(data);
proc.terminal.resize(cols, rows);
proc.terminal.setRawMode(enabled);
proc.terminal.close();

// Note: proc.stdin, proc.stdout, proc.stderr return null when terminal is used
```

### `new Bun.Terminal(options)`

```typescript
const terminal = new Bun.Terminal({
  cols?: number,
  rows?: number,
  name?: string,
  data?: (terminal, data) => void,
  exit?: (terminal, code, signal) => void,
  drain?: (terminal) => void,
});

terminal.stdin;   // Slave fd (for child process)
terminal.stdout;  // Master fd (for reading)
terminal.closed;  // boolean
terminal.write(data);
terminal.resize(cols, rows);
terminal.setRawMode(enabled);
terminal.ref();
terminal.unref();
terminal.close();
await terminal[Symbol.asyncDispose]();
```

## Implementation Details

- Uses `openpty()` to create pseudo-terminal pairs
- Properly manages file descriptor lifecycle with reference counting
- Integrates with Bun's event loop via `BufferedReader` and
`StreamingWriter`
- Supports `await using` syntax for automatic cleanup
- POSIX only (Linux, macOS) - not available on Windows

## Test Results

- 80 tests passing
- Covers: construction, writing, reading, resize, raw mode, callbacks,
spawn integration, error handling, GC safety

## Changes

- `src/bun.js/api/bun/Terminal.zig` - Terminal implementation
- `src/bun.js/api/bun/Terminal.classes.ts` - Class definition for
codegen
- `src/bun.js/api/bun/subprocess.zig` - Added terminal field and getter
- `src/bun.js/api/bun/js_bun_spawn_bindings.zig` - Terminal option
parsing
- `src/bun.js/api/BunObject.classes.ts` - Terminal getter on Subprocess
- `packages/bun-types/bun.d.ts` - TypeScript types
- `docs/runtime/child-process.mdx` - Documentation
- `test/js/bun/terminal/terminal.test.ts` - Comprehensive tests

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

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
robobun
2025-12-15 12:51:13 -08:00
committed by GitHub
parent e66b4639bd
commit d865ef41e2
17 changed files with 2983 additions and 36 deletions

View File

@@ -315,6 +315,109 @@ if (typeof Bun !== "undefined") {
---
## Terminal (PTY) support
For interactive terminal applications, you can spawn a subprocess with a pseudo-terminal (PTY) attached using the `terminal` option. This makes the subprocess think it's running in a real terminal, enabling features like colored output, cursor movement, and interactive prompts.
```ts
const proc = Bun.spawn(["bash"], {
terminal: {
cols: 80,
rows: 24,
data(terminal, data) {
// Called when data is received from the terminal
process.stdout.write(data);
},
},
});
// Write to the terminal
proc.terminal.write("echo hello\n");
// Wait for the process to exit
await proc.exited;
// Close the terminal
proc.terminal.close();
```
When the `terminal` option is provided:
- The subprocess sees `process.stdout.isTTY` as `true`
- `stdin`, `stdout`, and `stderr` are all connected to the terminal
- `proc.stdin`, `proc.stdout`, and `proc.stderr` return `null` — use the terminal instead
- Access the terminal via `proc.terminal`
### Terminal options
| Option | Description | Default |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
| `cols` | Number of columns | `80` |
| `rows` | Number of rows | `24` |
| `name` | Terminal type for PTY configuration (set `TERM` env var separately via `env` option) | `"xterm-256color"` |
| `data` | Callback when data is received `(terminal, data) => void` | — |
| `exit` | Callback when PTY stream closes (EOF or error). `exitCode` is PTY lifecycle status (0=EOF, 1=error), not subprocess exit code. Use `proc.exited` for process exit. | — |
| `drain` | Callback when ready for more data `(terminal) => void` | — |
### Terminal methods
The `Terminal` object returned by `proc.terminal` has the following methods:
```ts
// Write data to the terminal
proc.terminal.write("echo hello\n");
// Resize the terminal
proc.terminal.resize(120, 40);
// Set raw mode (disable line buffering and echo)
proc.terminal.setRawMode(true);
// Keep event loop alive while terminal is open
proc.terminal.ref();
proc.terminal.unref();
// Close the terminal
proc.terminal.close();
```
### Reusable Terminal
You can create a terminal independently and reuse it across multiple subprocesses:
```ts
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
data(term, data) {
process.stdout.write(data);
},
});
// Spawn first process
const proc1 = Bun.spawn(["echo", "first"], { terminal });
await proc1.exited;
// Reuse terminal for another process
const proc2 = Bun.spawn(["echo", "second"], { terminal });
await proc2.exited;
// Terminal is closed automatically by `await using`
```
When passing an existing `Terminal` object:
- The terminal can be reused across multiple spawns
- You control when to close the terminal
- The `exit` callback fires when you call `terminal.close()`, not when each subprocess exits
- Use `proc.exited` to detect individual subprocess exits
This is useful for running multiple commands in sequence through the same terminal session.
<Note>Terminal support is only available on POSIX systems (Linux, macOS). It is not available on Windows.</Note>
---
## Blocking API (`Bun.spawnSync()`)
Bun provides a synchronous equivalent of `Bun.spawn` called `Bun.spawnSync`. This is a blocking API that supports the same inputs and parameters as `Bun.spawn`. It returns a `SyncSubprocess` object, which differs from `Subprocess` in a few ways.
@@ -407,6 +510,7 @@ namespace SpawnOptions {
timeout?: number;
killSignal?: string | number;
maxBuffer?: number;
terminal?: TerminalOptions; // PTY support (POSIX only)
}
type Readable =
@@ -435,10 +539,11 @@ namespace SpawnOptions {
}
interface Subprocess extends AsyncDisposable {
readonly stdin: FileSink | number | undefined;
readonly stdout: ReadableStream<Uint8Array> | number | undefined;
readonly stderr: ReadableStream<Uint8Array> | number | undefined;
readonly readable: ReadableStream<Uint8Array> | number | undefined;
readonly stdin: FileSink | number | undefined | null;
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly readable: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly terminal: Terminal | undefined;
readonly pid: number;
readonly exited: Promise<number>;
readonly exitCode: number | null;
@@ -465,6 +570,28 @@ interface SyncSubprocess {
pid: number;
}
interface TerminalOptions {
cols?: number;
rows?: number;
name?: string;
data?: (terminal: Terminal, data: Uint8Array<ArrayBuffer>) => void;
/** Called when PTY stream closes (EOF or error). exitCode is PTY lifecycle status (0=EOF, 1=error), not subprocess exit code. */
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
drain?: (terminal: Terminal) => void;
}
interface Terminal extends AsyncDisposable {
readonly stdin: number;
readonly stdout: number;
readonly closed: boolean;
write(data: string | BufferSource): number;
resize(cols: number, rows: number): void;
setRawMode(enabled: boolean): void;
ref(): void;
unref(): void;
close(): void;
}
interface ResourceUsage {
contextSwitches: {
voluntary: number;

View File

@@ -5697,6 +5697,44 @@ declare module "bun" {
* ```
*/
lazy?: boolean;
/**
* Spawn the subprocess with a pseudo-terminal (PTY) attached.
*
* When this option is provided:
* - `stdin`, `stdout`, and `stderr` are all connected to the terminal
* - The subprocess sees itself running in a real terminal (`isTTY = true`)
* - Access the terminal via `subprocess.terminal`
* - `subprocess.stdin`, `subprocess.stdout`, `subprocess.stderr` return `null`
*
* Only available on POSIX systems (Linux, macOS).
*
* @example
* ```ts
* const proc = Bun.spawn(["bash"], {
* terminal: {
* cols: 80,
* rows: 24,
* data: (term, data) => console.log(data.toString()),
* },
* });
*
* proc.terminal.write("echo hello\n");
* await proc.exited;
* proc.terminal.close();
* ```
*
* You can also pass an existing `Terminal` object for reuse across multiple spawns:
* ```ts
* const terminal = new Bun.Terminal({ ... });
* const proc1 = Bun.spawn(["echo", "first"], { terminal });
* await proc1.exited;
* const proc2 = Bun.spawn(["echo", "second"], { terminal });
* await proc2.exited;
* terminal.close();
* ```
*/
terminal?: TerminalOptions | Terminal;
}
type ReadableToIO<X extends Readable> = X extends "pipe" | undefined
@@ -5811,6 +5849,24 @@ declare module "bun" {
readonly stdout: SpawnOptions.ReadableToIO<Out>;
readonly stderr: SpawnOptions.ReadableToIO<Err>;
/**
* The terminal attached to this subprocess, if spawned with the `terminal` option.
* Returns `undefined` if no terminal was attached.
*
* When a terminal is attached, `stdin`, `stdout`, and `stderr` return `null`.
* Use `terminal.write()` and the `data` callback instead.
*
* @example
* ```ts
* const proc = Bun.spawn(["bash"], {
* terminal: { data: (term, data) => console.log(data.toString()) },
* });
*
* proc.terminal?.write("echo hello\n");
* ```
*/
readonly terminal: Terminal | undefined;
/**
* Access extra file descriptors passed to the `stdio` option in the options object.
*/
@@ -6102,6 +6158,154 @@ declare module "bun" {
"ignore" | "inherit" | null | undefined
>;
/**
* Options for creating a pseudo-terminal (PTY).
*/
interface TerminalOptions {
/**
* Number of columns for the terminal.
* @default 80
*/
cols?: number;
/**
* Number of rows for the terminal.
* @default 24
*/
rows?: number;
/**
* Terminal name (e.g., "xterm-256color").
* @default "xterm-256color"
*/
name?: string;
/**
* Callback invoked when data is received from the terminal.
* @param terminal The terminal instance
* @param data The data received as a Uint8Array
*/
data?: (terminal: Terminal, data: Uint8Array<ArrayBuffer>) => void;
/**
* Callback invoked when the PTY stream closes (EOF or read error).
* Note: exitCode is a PTY lifecycle status (0=clean EOF, 1=error), NOT the subprocess exit code.
* Use Subprocess.exited or onExit callback for actual process exit information.
* @param terminal The terminal instance
* @param exitCode PTY lifecycle status (0 for EOF, 1 for error)
* @param signal Reserved for future signal reporting, currently null
*/
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
/**
* Callback invoked when the terminal is ready to receive more data.
* @param terminal The terminal instance
*/
drain?: (terminal: Terminal) => void;
}
/**
* A pseudo-terminal (PTY) that can be used to spawn interactive terminal programs.
*
* @example
* ```ts
* await using terminal = new Bun.Terminal({
* cols: 80,
* rows: 24,
* data(term, data) {
* console.log("Received:", new TextDecoder().decode(data));
* },
* });
*
* // Spawn a shell connected to the PTY
* const proc = Bun.spawn(["bash"], { terminal });
*
* // Write to the terminal
* terminal.write("echo hello\n");
*
* // Wait for process to exit
* await proc.exited;
*
* // Terminal is closed automatically by `await using`
* ```
*/
class Terminal implements AsyncDisposable {
constructor(options: TerminalOptions);
/**
* Whether the terminal is closed.
*/
readonly closed: boolean;
/**
* Write data to the terminal.
* @param data The data to write (string or BufferSource)
* @returns The number of bytes written
*/
write(data: string | BufferSource): number;
/**
* Resize the terminal.
* @param cols New number of columns
* @param rows New number of rows
*/
resize(cols: number, rows: number): void;
/**
* Set raw mode on the terminal.
* In raw mode, input is passed directly without processing.
* @param enabled Whether to enable raw mode
*/
setRawMode(enabled: boolean): void;
/**
* Reference the terminal to keep the event loop alive.
*/
ref(): void;
/**
* Unreference the terminal to allow the event loop to exit.
*/
unref(): void;
/**
* Close the terminal.
*/
close(): void;
/**
* Async dispose for use with `await using`.
*/
[Symbol.asyncDispose](): Promise<void>;
/**
* Terminal input flags (c_iflag from termios).
* Controls input processing behavior like ICRNL, IXON, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
inputFlags: number;
/**
* Terminal output flags (c_oflag from termios).
* Controls output processing behavior like OPOST, ONLCR, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
outputFlags: number;
/**
* Terminal local flags (c_lflag from termios).
* Controls local processing like ICANON, ECHO, ISIG, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
localFlags: number;
/**
* Terminal control flags (c_cflag from termios).
* Controls hardware characteristics like CSIZE, PARENB, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
controlFlags: number;
}
// Blocked on https://github.com/oven-sh/bun/issues/8329
// /**
// *

View File

@@ -150,6 +150,7 @@ pub const FilePoll = struct {
const StaticPipeWriter = Subprocess.StaticPipeWriter.Poll;
const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll;
const FileSink = jsc.WebCore.FileSink.Poll;
const TerminalPoll = bun.api.Terminal.Poll;
const DNSResolver = bun.api.dns.Resolver;
const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest;
const Request = bun.api.dns.internal.Request;
@@ -181,6 +182,7 @@ pub const FilePoll = struct {
// LifecycleScriptSubprocessOutputReader,
Process,
ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop
TerminalPoll,
});
pub const AllocatorType = enum {
@@ -414,6 +416,12 @@ pub const FilePoll = struct {
Request.MacAsyncDNS.onMachportChange(loader);
},
@field(Owner.Tag, @typeName(TerminalPoll)) => {
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) Terminal", .{poll.fd});
var handler: *TerminalPoll = ptr.as(TerminalPoll);
handler.onPoll(size_or_offset, poll.flags.contains(.hup));
},
else => {
const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag()));
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) disconnected? (maybe: {s})", .{ poll.fd, possible_name orelse "<unknown>" });

View File

@@ -24,6 +24,7 @@ pub const TLSSocket = @import("./api/bun/socket.zig").TLSSocket;
pub const SocketHandlers = @import("./api/bun/socket.zig").Handlers;
pub const Subprocess = @import("./api/bun/subprocess.zig");
pub const Terminal = @import("./api/bun/Terminal.zig");
pub const HashObject = @import("./api/HashObject.zig");
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
pub const TOMLObject = @import("./api/TOMLObject.zig");

View File

@@ -121,6 +121,10 @@ export default [
stdio: {
getter: "getStdio",
},
terminal: {
getter: "getTerminal",
cache: true,
},
},
values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
}),

View File

@@ -77,6 +77,7 @@ pub const BunObject = struct {
pub const s3 = toJSLazyPropertyCallback(Bun.getS3DefaultClient);
pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor);
pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient);
pub const Terminal = toJSLazyPropertyCallback(Bun.getTerminalConstructor);
// --- Lazy property callbacks ---
// --- Getters ---
@@ -143,6 +144,7 @@ pub const BunObject = struct {
@export(&BunObject.s3, .{ .name = lazyPropertyCallbackName("s3") });
@export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") });
@export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") });
@export(&BunObject.Terminal, .{ .name = lazyPropertyCallbackName("Terminal") });
// --- Lazy property callbacks ---
// --- Callbacks ---
@@ -1315,6 +1317,10 @@ pub fn getValkeyClientConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObj
return jsc.API.Valkey.js.getConstructor(globalThis);
}
pub fn getTerminalConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.Terminal.js.getConstructor(globalThis);
}
pub fn getEmbeddedFiles(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) bun.JSError!jsc.JSValue {
const vm = globalThis.bunVM();
const graph = vm.standalone_module_graph orelse return try jsc.JSValue.createEmptyArray(globalThis, 0);

View File

@@ -0,0 +1,64 @@
import { define } from "../../codegen/class-definitions";
export default [
define({
name: "Terminal",
construct: true,
constructNeedsThis: true,
finalize: true,
configurable: false,
klass: {},
JSType: "0b11101110",
// Store callback references - prevents them from being GC'd while terminal is alive
values: ["data", "drain", "exit"],
proto: {
write: {
fn: "write",
length: 1,
},
resize: {
fn: "resize",
length: 2,
},
setRawMode: {
fn: "setRawMode",
length: 1,
},
ref: {
fn: "doRef",
length: 0,
},
unref: {
fn: "doUnref",
length: 0,
},
close: {
fn: "close",
length: 0,
},
"@@asyncDispose": {
fn: "asyncDispose",
length: 0,
},
closed: {
getter: "getClosed",
},
inputFlags: {
getter: "getInputFlags",
setter: "setInputFlags",
},
outputFlags: {
getter: "getOutputFlags",
setter: "setOutputFlags",
},
localFlags: {
getter: "getLocalFlags",
setter: "setLocalFlags",
},
controlFlags: {
getter: "getControlFlags",
setter: "setControlFlags",
},
},
}),
];

View File

@@ -0,0 +1,973 @@
//! Bun.Terminal - Creates a pseudo-terminal (PTY) for interactive terminal sessions.
//!
//! This module provides a Terminal class that creates a PTY master/slave pair,
//! allowing JavaScript code to interact with terminal-based programs.
//!
//! Lifecycle:
//! - Starts with weak JSRef (allows GC if user doesn't hold reference)
//! - Upgrades to strong when actively reading/writing
//! - Downgrades to weak on EOF from master_fd
//! - Callbacks are stored via `values` in classes.ts, accessed via js.gc
const Terminal = @This();
const log = bun.Output.scoped(.Terminal, .hidden);
// Generated bindings
pub const js = jsc.Codegen.JSTerminal;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
// Reference counting for Terminal
// Refs are held by:
// 1. JS side (released in finalize)
// 2. Reader (released in onReaderDone/onReaderError)
// 3. Writer (released in onWriterClose)
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
ref_count: RefCount,
/// The master side of the PTY (original fd, used for ioctl operations)
master_fd: bun.FileDescriptor,
/// Duplicated master fd for reading
read_fd: bun.FileDescriptor,
/// Duplicated master fd for writing
write_fd: bun.FileDescriptor,
/// The slave side of the PTY (used by child processes)
slave_fd: bun.FileDescriptor,
/// Current terminal size
cols: u16,
rows: u16,
/// Terminal name (e.g., "xterm-256color")
term_name: jsc.ZigString.Slice,
/// Event loop handle for callbacks
event_loop_handle: jsc.EventLoopHandle,
/// Global object reference
globalThis: *jsc.JSGlobalObject,
/// Writer for sending data to the terminal
writer: IOWriter = .{},
/// Reader for receiving data from the terminal
reader: IOReader = IOReader.init(@This()),
/// This value reference for GC tracking
/// - weak: allows GC when idle
/// - strong: prevents GC when actively connected
this_value: jsc.JSRef = jsc.JSRef.empty(),
/// State flags
flags: Flags = .{},
pub const Flags = packed struct(u8) {
closed: bool = false,
finalized: bool = false,
raw_mode: bool = false,
reader_started: bool = false,
connected: bool = false,
reader_done: bool = false,
writer_done: bool = false,
_: u1 = 0,
};
pub const IOWriter = bun.io.StreamingWriter(@This(), struct {
pub const onClose = Terminal.onWriterClose;
pub const onWritable = Terminal.onWriterReady;
pub const onError = Terminal.onWriterError;
pub const onWrite = Terminal.onWrite;
});
/// Poll type alias for FilePoll Owner registration
pub const Poll = IOWriter;
pub const IOReader = bun.io.BufferedReader;
/// Options for creating a Terminal
pub const Options = struct {
cols: u16 = 80,
rows: u16 = 24,
term_name: jsc.ZigString.Slice = .{},
data_callback: ?JSValue = null,
exit_callback: ?JSValue = null,
drain_callback: ?JSValue = null,
/// Maximum length for terminal name (e.g., "xterm-256color")
/// Longest known terminfo names are ~23 chars; 128 allows for custom terminals
pub const max_term_name_len = 128;
/// Parse terminal options from a JS object
pub fn parseFromJS(globalObject: *jsc.JSGlobalObject, js_options: JSValue) bun.JSError!Options {
var options = Options{};
if (try js_options.getOptional(globalObject, "cols", i32)) |n| {
if (n > 0 and n <= 65535) options.cols = @intCast(n);
}
if (try js_options.getOptional(globalObject, "rows", i32)) |n| {
if (n > 0 and n <= 65535) options.rows = @intCast(n);
}
if (try js_options.getOptional(globalObject, "name", jsc.ZigString.Slice)) |slice| {
if (slice.len > max_term_name_len) {
slice.deinit();
return globalObject.throw("Terminal name too long (max {d} characters)", .{max_term_name_len});
}
options.term_name = slice;
}
if (try js_options.getOptional(globalObject, "data", JSValue)) |v| {
if (v.isCell() and v.isCallable()) {
options.data_callback = v.withAsyncContextIfNeeded(globalObject);
}
}
if (try js_options.getOptional(globalObject, "exit", JSValue)) |v| {
if (v.isCell() and v.isCallable()) {
options.exit_callback = v.withAsyncContextIfNeeded(globalObject);
}
}
if (try js_options.getOptional(globalObject, "drain", JSValue)) |v| {
if (v.isCell() and v.isCallable()) {
options.drain_callback = v.withAsyncContextIfNeeded(globalObject);
}
}
return options;
}
pub fn deinit(this: *Options) void {
this.term_name.deinit();
this.* = .{};
}
};
/// Result from creating a Terminal
pub const CreateResult = struct {
terminal: *Terminal,
js_value: jsc.JSValue,
};
const InitError = CreatePtyError || error{ WriterStartFailed, ReaderStartFailed };
/// Internal initialization - shared by constructor and createFromSpawn
fn initTerminal(
globalObject: *jsc.JSGlobalObject,
options: Options,
/// If provided, use this JSValue; otherwise create one via toJS
existing_js_value: ?jsc.JSValue,
) InitError!CreateResult {
// Create PTY
const pty_result = try createPty(options.cols, options.rows);
// Use default term name if empty
const term_name = if (options.term_name.len > 0)
options.term_name
else
jsc.ZigString.Slice.fromUTF8NeverFree("xterm-256color");
const terminal = bun.new(Terminal, .{
.ref_count = .init(),
.master_fd = pty_result.master,
.read_fd = pty_result.read_fd,
.write_fd = pty_result.write_fd,
.slave_fd = pty_result.slave,
.cols = options.cols,
.rows = options.rows,
.term_name = term_name,
.event_loop_handle = jsc.EventLoopHandle.init(globalObject.bunVM().eventLoop()),
.globalThis = globalObject,
});
// Set reader parent
terminal.reader.setParent(terminal);
// Set writer parent
terminal.writer.parent = terminal;
// Start writer with the write fd - adds a ref
switch (terminal.writer.start(pty_result.write_fd, true)) {
.result => terminal.ref(),
.err => return error.WriterStartFailed,
}
// Start reader with the read fd - adds a ref
switch (terminal.reader.start(pty_result.read_fd, true)) {
.err => {
// Reader never started but writer was started
// Close writer (will trigger onWriterDone -> deref for writer's ref)
terminal.writer.close();
return error.ReaderStartFailed;
},
.result => {
terminal.ref();
if (comptime Environment.isPosix) {
if (terminal.reader.handle == .poll) {
const poll = terminal.reader.handle.poll;
// PTY behaves like a pipe, not a socket
terminal.reader.flags.nonblocking = true;
terminal.reader.flags.pollable = true;
poll.flags.insert(.nonblocking);
}
}
terminal.flags.reader_started = true;
},
}
// Start reading data
terminal.reader.read();
// Get or create the JS wrapper
const this_value = existing_js_value orelse terminal.toJS(globalObject);
// Store the this_value (JSValue wrapper) - start with strong ref since we're actively reading
// This is the JS side ref (released in finalize)
terminal.this_value = jsc.JSRef.initStrong(this_value, globalObject);
terminal.ref();
// Store callbacks via generated gc setters (prevents GC of callbacks while terminal is alive)
if (options.data_callback) |cb| {
if (cb.isCell() and cb.isCallable()) {
js.gc.set(.data, this_value, globalObject, cb);
}
}
if (options.exit_callback) |cb| {
if (cb.isCell() and cb.isCallable()) {
js.gc.set(.exit, this_value, globalObject, cb);
}
}
if (options.drain_callback) |cb| {
if (cb.isCell() and cb.isCallable()) {
js.gc.set(.drain, this_value, globalObject, cb);
}
}
return .{ .terminal = terminal, .js_value = this_value };
}
/// Constructor for Terminal - called from JavaScript
/// With constructNeedsThis: true, we receive the JSValue wrapper directly
pub fn constructor(
globalObject: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
this_value: jsc.JSValue,
) bun.JSError!*Terminal {
const args = callframe.argumentsAsArray(1);
const js_options = args[0];
if (js_options.isUndefinedOrNull()) {
return globalObject.throw("Terminal constructor requires an options object", .{});
}
var options = try Options.parseFromJS(globalObject, js_options);
const result = initTerminal(globalObject, options, this_value) catch |err| {
options.deinit();
return switch (err) {
error.OpenPtyFailed => globalObject.throw("Failed to open PTY", .{}),
error.DupFailed => globalObject.throw("Failed to duplicate PTY file descriptor", .{}),
error.NotSupported => globalObject.throw("PTY not supported on this platform", .{}),
error.WriterStartFailed => globalObject.throw("Failed to start terminal writer", .{}),
error.ReaderStartFailed => globalObject.throw("Failed to start terminal reader", .{}),
};
};
return result.terminal;
}
/// Create a Terminal from Bun.spawn options (not from JS constructor)
/// Returns the Terminal and its JS wrapper value
/// The slave_fd should be used for the subprocess's stdin/stdout/stderr
pub fn createFromSpawn(
globalObject: *jsc.JSGlobalObject,
options: Options,
) InitError!CreateResult {
return initTerminal(globalObject, options, null);
}
/// Get the slave fd for subprocess to use
pub fn getSlaveFd(this: *Terminal) bun.FileDescriptor {
return this.slave_fd;
}
/// Close the parent's copy of slave_fd after fork
/// The child process has its own copy - closing the parent's ensures
/// EOF is received on the master side when the child exits
pub fn closeSlaveFd(this: *Terminal) void {
if (this.slave_fd != bun.invalid_fd) {
this.slave_fd.close();
this.slave_fd = bun.invalid_fd;
}
}
const PtyResult = struct {
master: bun.FileDescriptor,
read_fd: bun.FileDescriptor,
write_fd: bun.FileDescriptor,
slave: bun.FileDescriptor,
};
const CreatePtyError = error{ OpenPtyFailed, DupFailed, NotSupported };
fn createPty(cols: u16, rows: u16) CreatePtyError!PtyResult {
if (comptime Environment.isPosix) {
return createPtyPosix(cols, rows);
} else {
// Windows PTY support would go here
return error.NotSupported;
}
}
// OpenPtyTermios is required for the openpty() extern signature even though we pass null.
// Kept for type correctness of the C function declaration.
const OpenPtyTermios = extern struct {
c_iflag: u32,
c_oflag: u32,
c_cflag: u32,
c_lflag: u32,
c_cc: [20]u8,
c_ispeed: u32,
c_ospeed: u32,
};
const Winsize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16,
ws_ypixel: u16,
};
const OpenPtyFn = *const fn (
amaster: *c_int,
aslave: *c_int,
name: ?[*]u8,
termp: ?*const OpenPtyTermios,
winp: ?*const Winsize,
) callconv(.c) c_int;
/// Dynamic loading of openpty on Linux (it's in libutil which may not be linked)
const LibUtil = struct {
var handle: ?*anyopaque = null;
var loaded: bool = false;
pub fn getHandle() ?*anyopaque {
if (loaded) return handle;
loaded = true;
// Try libutil.so first (most common), then libutil.so.1
const lib_names = [_][:0]const u8{ "libutil.so", "libutil.so.1", "libc.so.6" };
for (lib_names) |lib_name| {
handle = bun.sys.dlopen(lib_name, .{ .LAZY = true });
if (handle != null) return handle;
}
return null;
}
pub fn getOpenPty() ?OpenPtyFn {
return bun.sys.dlsymWithHandle(OpenPtyFn, "openpty", getHandle);
}
};
fn getOpenPtyFn() ?OpenPtyFn {
// On macOS, openpty is in libc, so we can use it directly
if (comptime Environment.isMac) {
const c = struct {
extern "c" fn openpty(
amaster: *c_int,
aslave: *c_int,
name: ?[*]u8,
termp: ?*const OpenPtyTermios,
winp: ?*const Winsize,
) c_int;
};
return &c.openpty;
}
// On Linux, openpty is in libutil, which may not be linked
// Load it dynamically via dlopen
if (comptime Environment.isLinux) {
return LibUtil.getOpenPty();
}
return null;
}
fn createPtyPosix(cols: u16, rows: u16) CreatePtyError!PtyResult {
const openpty_fn = getOpenPtyFn() orelse {
return error.NotSupported;
};
var master_fd: c_int = -1;
var slave_fd: c_int = -1;
const winsize = Winsize{
.ws_row = rows,
.ws_col = cols,
.ws_xpixel = 0,
.ws_ypixel = 0,
};
const result = openpty_fn(&master_fd, &slave_fd, null, null, &winsize);
if (result != 0) {
return error.OpenPtyFailed;
}
const master_fd_desc = bun.FD.fromNative(master_fd);
const slave_fd_desc = bun.FD.fromNative(slave_fd);
// Configure sensible terminal defaults matching node-pty behavior.
// These are "cooked mode" defaults that most terminal applications expect.
if (std.posix.tcgetattr(slave_fd)) |termios| {
var t = termios;
// Input flags: standard terminal input processing
t.iflag = .{
.ICRNL = true, // Map CR to NL on input
.IXON = true, // Enable XON/XOFF flow control on output
.IXANY = true, // Any character restarts output
.IMAXBEL = true, // Ring bell on input queue full
.BRKINT = true, // Signal interrupt on break
.IUTF8 = true, // Input is UTF-8
};
// Output flags: standard terminal output processing
t.oflag = .{
.OPOST = true, // Enable output processing
.ONLCR = true, // Map NL to CR-NL on output
};
// Control flags: 8-bit chars, enable receiver
t.cflag = .{
.CREAD = true, // Enable receiver
.CSIZE = .CS8, // 8-bit characters
.HUPCL = true, // Hang up on last close
};
// Local flags: canonical mode with echo and signals
t.lflag = .{
.ICANON = true, // Canonical input (line editing)
.ISIG = true, // Enable signals (INTR, QUIT, SUSP)
.IEXTEN = true, // Enable extended input processing
.ECHO = true, // Echo input characters
.ECHOE = true, // Echo erase as backspace-space-backspace
.ECHOK = true, // Echo NL after KILL
.ECHOKE = true, // Visual erase for KILL
.ECHOCTL = true, // Echo control chars as ^X
};
// Control characters - standard defaults
t.cc[@intFromEnum(std.posix.V.EOF)] = 4; // Ctrl-D
t.cc[@intFromEnum(std.posix.V.EOL)] = 0; // Disabled
t.cc[@intFromEnum(std.posix.V.ERASE)] = 0x7f; // DEL (backspace)
t.cc[@intFromEnum(std.posix.V.WERASE)] = 23; // Ctrl-W
t.cc[@intFromEnum(std.posix.V.KILL)] = 21; // Ctrl-U
t.cc[@intFromEnum(std.posix.V.REPRINT)] = 18; // Ctrl-R
t.cc[@intFromEnum(std.posix.V.INTR)] = 3; // Ctrl-C
t.cc[@intFromEnum(std.posix.V.QUIT)] = 0x1c; // Ctrl-backslash
t.cc[@intFromEnum(std.posix.V.SUSP)] = 26; // Ctrl-Z
t.cc[@intFromEnum(std.posix.V.START)] = 17; // Ctrl-Q (XON)
t.cc[@intFromEnum(std.posix.V.STOP)] = 19; // Ctrl-S (XOFF)
t.cc[@intFromEnum(std.posix.V.LNEXT)] = 22; // Ctrl-V
t.cc[@intFromEnum(std.posix.V.DISCARD)] = 15; // Ctrl-O
t.cc[@intFromEnum(std.posix.V.MIN)] = 1; // Min chars for non-canonical read
t.cc[@intFromEnum(std.posix.V.TIME)] = 0; // Timeout for non-canonical read
// Set baud rate to 38400 (standard for PTYs)
t.ispeed = .B38400;
t.ospeed = .B38400;
std.posix.tcsetattr(slave_fd, .NOW, t) catch {};
} else |err| {
// tcgetattr failed, log in debug builds but continue without modifying termios
if (comptime bun.Environment.allow_assert) {
bun.sys.syslog("tcgetattr(slave_fd={d}) failed: {s}", .{ slave_fd, @errorName(err) });
}
}
// Duplicate the master fd for reading and writing separately
// This allows independent epoll registration and closing
const read_fd = switch (bun.sys.dup(master_fd_desc)) {
.result => |fd| fd,
.err => {
master_fd_desc.close();
slave_fd_desc.close();
return error.DupFailed;
},
};
const write_fd = switch (bun.sys.dup(master_fd_desc)) {
.result => |fd| fd,
.err => {
master_fd_desc.close();
slave_fd_desc.close();
read_fd.close();
return error.DupFailed;
},
};
// Set non-blocking on master side fds (for async I/O in the event loop)
_ = bun.sys.updateNonblocking(master_fd_desc, true);
_ = bun.sys.updateNonblocking(read_fd, true);
_ = bun.sys.updateNonblocking(write_fd, true);
// Note: slave_fd stays blocking - child processes expect blocking I/O
// Set close-on-exec on master side fds only
// slave_fd should NOT have close-on-exec since child needs to inherit it
_ = bun.sys.setCloseOnExec(master_fd_desc);
_ = bun.sys.setCloseOnExec(read_fd);
_ = bun.sys.setCloseOnExec(write_fd);
return PtyResult{
.master = master_fd_desc,
.read_fd = read_fd,
.write_fd = write_fd,
.slave = slave_fd_desc,
};
}
/// Check if terminal is closed
pub fn getClosed(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
return JSValue.jsBoolean(this.flags.closed);
}
fn getTermiosFlag(this: *Terminal, comptime field: enum { iflag, oflag, lflag, cflag }) JSValue {
if (comptime !Environment.isPosix) return JSValue.jsNumber(0);
if (this.flags.closed or this.master_fd == bun.invalid_fd) return JSValue.jsNumber(0);
const termios_data = getTermios(this.master_fd) orelse return JSValue.jsNumber(0);
const flag = @field(termios_data, @tagName(field));
const Int = @typeInfo(@TypeOf(flag)).@"struct".backing_integer.?;
return JSValue.jsNumber(@as(f64, @floatFromInt(@as(Int, @bitCast(flag)))));
}
fn setTermiosFlag(this: *Terminal, globalObject: *jsc.JSGlobalObject, comptime field: enum { iflag, oflag, lflag, cflag }, value: JSValue) bun.JSError!void {
if (comptime !Environment.isPosix) return;
if (this.flags.closed or this.master_fd == bun.invalid_fd) return;
const num = try value.coerce(f64, globalObject);
var termios_data = getTermios(this.master_fd) orelse return;
const FlagType = @TypeOf(@field(termios_data, @tagName(field)));
const Int = @typeInfo(FlagType).@"struct".backing_integer.?;
const max_val: f64 = @floatFromInt(std.math.maxInt(Int));
const clamped = @max(0, @min(num, max_val));
@field(termios_data, @tagName(field)) = @bitCast(@as(Int, @intFromFloat(clamped)));
_ = setTermios(this.master_fd, &termios_data);
}
pub fn getInputFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
return this.getTermiosFlag(.iflag);
}
pub fn setInputFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
try this.setTermiosFlag(globalObject, .iflag, value);
}
pub fn getOutputFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
return this.getTermiosFlag(.oflag);
}
pub fn setOutputFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
try this.setTermiosFlag(globalObject, .oflag, value);
}
pub fn getLocalFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
return this.getTermiosFlag(.lflag);
}
pub fn setLocalFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
try this.setTermiosFlag(globalObject, .lflag, value);
}
pub fn getControlFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
return this.getTermiosFlag(.cflag);
}
pub fn setControlFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
try this.setTermiosFlag(globalObject, .cflag, value);
}
/// Write data to the terminal
pub fn write(
this: *Terminal,
globalObject: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!JSValue {
if (this.flags.closed) {
return globalObject.throw("Terminal is closed", .{});
}
const args = callframe.argumentsAsArray(1);
const data = args[0];
if (data.isUndefinedOrNull()) {
return globalObject.throw("write() requires data argument", .{});
}
// Get bytes to write using StringOrBuffer
const string_or_buffer = try jsc.Node.StringOrBuffer.fromJS(globalObject, bun.default_allocator, data) orelse {
return globalObject.throw("write() argument must be a string or ArrayBuffer", .{});
};
defer string_or_buffer.deinit();
const bytes = string_or_buffer.slice();
if (bytes.len == 0) {
return JSValue.jsNumber(0);
}
// Write using the streaming writer
const write_result = this.writer.write(bytes);
return switch (write_result) {
.done => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
.wrote => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
.pending => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
.err => |err| globalObject.throwValue(err.toJS(globalObject)),
};
}
/// Resize the terminal
pub fn resize(
this: *Terminal,
globalObject: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!JSValue {
if (this.flags.closed) {
return globalObject.throw("Terminal is closed", .{});
}
const args = callframe.argumentsAsArray(2);
const new_cols: u16 = blk: {
if (args[0].isNumber()) {
const n = args[0].toInt32();
if (n > 0 and n <= 65535) break :blk @intCast(n);
}
return globalObject.throw("resize() requires valid cols argument", .{});
};
const new_rows: u16 = blk: {
if (args[1].isNumber()) {
const n = args[1].toInt32();
if (n > 0 and n <= 65535) break :blk @intCast(n);
}
return globalObject.throw("resize() requires valid rows argument", .{});
};
if (comptime Environment.isPosix) {
const ioctl_c = struct {
const TIOCSWINSZ: c_ulong = if (Environment.isMac) 0x80087467 else 0x5414;
const Winsize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16,
ws_ypixel: u16,
};
extern "c" fn ioctl(fd: c_int, request: c_ulong, ...) c_int;
};
var winsize = ioctl_c.Winsize{
.ws_row = new_rows,
.ws_col = new_cols,
.ws_xpixel = 0,
.ws_ypixel = 0,
};
const ioctl_result = ioctl_c.ioctl(this.master_fd.cast(), ioctl_c.TIOCSWINSZ, &winsize);
if (ioctl_result != 0) {
return globalObject.throw("Failed to resize terminal", .{});
}
}
this.cols = new_cols;
this.rows = new_rows;
return .js_undefined;
}
/// Set raw mode on the terminal
pub fn setRawMode(
this: *Terminal,
globalObject: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!JSValue {
if (this.flags.closed) {
return globalObject.throw("Terminal is closed", .{});
}
const args = callframe.argumentsAsArray(1);
const enabled = args[0].toBoolean();
if (comptime Environment.isPosix) {
// Use the existing TTY mode function
const mode: c_int = if (enabled) 1 else 0;
const tty_result = Bun__ttySetMode(this.master_fd.cast(), mode);
if (tty_result != 0) {
return globalObject.throw("Failed to set raw mode", .{});
}
}
this.flags.raw_mode = enabled;
return .js_undefined;
}
extern fn Bun__ttySetMode(fd: c_int, mode: c_int) c_int;
/// POSIX termios struct for terminal flags manipulation
const Termios = if (Environment.isPosix) std.posix.termios else void;
/// Get terminal attributes using tcgetattr
fn getTermios(fd: bun.FileDescriptor) ?Termios {
if (comptime !Environment.isPosix) return null;
return std.posix.tcgetattr(fd.cast()) catch null;
}
/// Set terminal attributes using tcsetattr (TCSANOW = immediate)
fn setTermios(fd: bun.FileDescriptor, termios_p: *const Termios) bool {
if (comptime !Environment.isPosix) return false;
std.posix.tcsetattr(fd.cast(), .NOW, termios_p.*) catch return false;
return true;
}
/// Reference the terminal to keep the event loop alive
pub fn doRef(this: *Terminal, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue {
this.updateRef(true);
return .js_undefined;
}
/// Unreference the terminal
pub fn doUnref(this: *Terminal, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue {
this.updateRef(false);
return .js_undefined;
}
fn updateRef(this: *Terminal, add: bool) void {
this.reader.updateRef(add);
this.writer.updateRef(this.event_loop_handle, add);
}
/// Close the terminal
pub fn close(
this: *Terminal,
_: *jsc.JSGlobalObject,
_: *jsc.CallFrame,
) bun.JSError!JSValue {
this.closeInternal();
return .js_undefined;
}
/// Async dispose for "using" syntax
pub fn asyncDispose(
this: *Terminal,
globalObject: *jsc.JSGlobalObject,
_: *jsc.CallFrame,
) bun.JSError!JSValue {
this.closeInternal();
return jsc.JSPromise.resolvedPromiseValue(globalObject, .js_undefined);
}
pub fn closeInternal(this: *Terminal) void {
if (this.flags.closed) return;
this.flags.closed = true;
// Close reader (closes read_fd)
if (this.flags.reader_started) {
this.reader.close();
}
this.read_fd = bun.invalid_fd;
// Close writer (closes write_fd)
this.writer.close();
this.write_fd = bun.invalid_fd;
// Close master fd
if (this.master_fd != bun.invalid_fd) {
this.master_fd.close();
this.master_fd = bun.invalid_fd;
}
// Close slave fd
if (this.slave_fd != bun.invalid_fd) {
this.slave_fd.close();
this.slave_fd = bun.invalid_fd;
}
}
// IOWriter callbacks
fn onWriterClose(this: *Terminal) void {
log("onWriterClose", .{});
if (!this.flags.writer_done) {
this.flags.writer_done = true;
// Release writer's ref
this.deref();
}
}
fn onWriterReady(this: *Terminal) void {
log("onWriterReady", .{});
// Call drain callback
const this_jsvalue = this.this_value.tryGet() orelse return;
if (js.gc.get(.drain, this_jsvalue)) |callback| {
const globalThis = this.globalThis;
globalThis.bunVM().eventLoop().runCallback(
callback,
globalThis,
this_jsvalue,
&.{this_jsvalue},
);
}
}
fn onWriterError(this: *Terminal, err: bun.sys.Error) void {
log("onWriterError: {any}", .{err});
// On write error, close the terminal to prevent further operations
// This handles cases like broken pipe when the child process exits
if (!this.flags.closed) {
this.closeInternal();
}
}
fn onWrite(this: *Terminal, amount: usize, status: bun.io.WriteStatus) void {
log("onWrite: {} bytes, status: {any}", .{ amount, status });
_ = this;
}
// IOReader callbacks
pub fn onReaderDone(this: *Terminal) void {
log("onReaderDone", .{});
// EOF from master - downgrade to weak ref to allow GC
// Skip JS interactions if already finalized (happens when close() is called during finalize)
if (!this.flags.finalized) {
this.flags.connected = false;
this.this_value.downgrade();
// exit_code 0 = clean EOF on PTY stream (not subprocess exit code)
this.callExitCallback(0, null);
}
// Release reader's ref (only once)
if (!this.flags.reader_done) {
this.flags.reader_done = true;
this.deref();
}
}
pub fn onReaderError(this: *Terminal, err: bun.sys.Error) void {
log("onReaderError: {any}", .{err});
// Error - downgrade to weak ref to allow GC
// Skip JS interactions if already finalized
if (!this.flags.finalized) {
this.flags.connected = false;
this.this_value.downgrade();
// exit_code 1 = I/O error on PTY stream (not subprocess exit code)
this.callExitCallback(1, null);
}
// Release reader's ref (only once)
if (!this.flags.reader_done) {
this.flags.reader_done = true;
this.deref();
}
}
/// Invoke the exit callback with PTY lifecycle status.
/// Note: exit_code is PTY-level (0=EOF, 1=error), NOT the subprocess exit code.
/// The signal parameter is only populated if a signal caused the PTY close.
fn callExitCallback(this: *Terminal, exit_code: i32, signal: ?bun.SignalCode) void {
const this_jsvalue = this.this_value.tryGet() orelse return;
const callback = js.gc.get(.exit, this_jsvalue) orelse return;
const globalThis = this.globalThis;
const signal_value: JSValue = if (signal) |s|
jsc.ZigString.init(s.name() orelse "unknown").toJS(globalThis)
else
JSValue.jsNull();
globalThis.bunVM().eventLoop().runCallback(
callback,
globalThis,
this_jsvalue,
&.{ this_jsvalue, JSValue.jsNumber(exit_code), signal_value },
);
}
// Called when data is available from the reader
// Returns true to continue reading, false to pause
pub fn onReadChunk(this: *Terminal, chunk: []const u8, has_more: bun.io.ReadState) bool {
_ = has_more;
log("onReadChunk: {} bytes", .{chunk.len});
// First data received - upgrade to strong ref (connected)
if (!this.flags.connected) {
this.flags.connected = true;
this.this_value.upgrade(this.globalThis);
}
const this_jsvalue = this.this_value.tryGet() orelse return true;
const callback = js.gc.get(.data, this_jsvalue) orelse return true;
const globalThis = this.globalThis;
const duped = bun.default_allocator.dupe(u8, chunk) catch |err| {
log("Terminal data allocation OOM: chunk_size={d}, error={any}", .{ chunk.len, err });
return true;
};
const data = jsc.MarkedArrayBuffer.fromBytes(
duped,
bun.default_allocator,
.Uint8Array,
).toNodeBuffer(globalThis);
globalThis.bunVM().eventLoop().runCallback(
callback,
globalThis,
this_jsvalue,
&.{ this_jsvalue, data },
);
return true; // Continue reading
}
pub fn eventLoop(this: *Terminal) jsc.EventLoopHandle {
return this.event_loop_handle;
}
pub fn loop(this: *Terminal) *bun.Async.Loop {
if (comptime Environment.isWindows) {
return this.event_loop_handle.loop().uv_loop;
} else {
return this.event_loop_handle.loop();
}
}
fn deinit(this: *Terminal) void {
log("deinit", .{});
// Set reader/writer done flags to prevent extra deref calls in closeInternal
this.flags.reader_done = true;
this.flags.writer_done = true;
// Close all FDs if not already closed (handles constructor error paths)
// closeInternal() checks flags.closed and returns early on subsequent calls,
// so this is safe even if finalize() already called it
this.closeInternal();
this.term_name.deinit();
this.reader.deinit();
this.writer.deinit();
bun.destroy(this);
}
/// Finalize - called by GC when object is collected
pub fn finalize(this: *Terminal) callconv(.c) void {
log("finalize", .{});
jsc.markBinding(@src());
this.this_value.finalize();
this.flags.finalized = true;
this.closeInternal();
this.deref();
}
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;

View File

@@ -142,11 +142,18 @@ pub fn spawnMaybeSync(
var windows_hide: bool = false;
var windows_verbatim_arguments: bool = false;
var abort_signal: ?*jsc.WebCore.AbortSignal = null;
var terminal_info: ?Terminal.CreateResult = null;
var existing_terminal: ?*Terminal = null; // Existing terminal passed by user
var terminal_js_value: jsc.JSValue = .zero;
defer {
// Ensure we clean it up on error.
if (abort_signal) |signal| {
signal.unref();
}
// If we created a new terminal but spawn failed, clean it up
if (terminal_info) |info| {
info.terminal.closeInternal();
info.terminal.deref();
}
}
{
@@ -184,6 +191,13 @@ pub fn spawnMaybeSync(
}
if (args != .zero and args.isObject()) {
// Reject terminal option on spawnSync
if (comptime is_sync) {
if (try args.getTruthy(globalThis, "terminal")) |_| {
return globalThis.throwInvalidArguments("terminal option is only supported for Bun.spawn, not Bun.spawnSync", .{});
}
}
// This must run before the stdio parsing happens
if (!is_sync) {
if (try args.getTruthy(globalThis, "ipc")) |val| {
@@ -353,6 +367,47 @@ pub fn spawnMaybeSync(
}
}
}
if (comptime !is_sync) {
if (try args.getTruthy(globalThis, "terminal")) |terminal_val| {
if (comptime !Environment.isPosix) {
return globalThis.throwInvalidArguments("terminal option is not supported on this platform", .{});
}
// Check if it's an existing Terminal object
if (Terminal.fromJS(terminal_val)) |terminal| {
if (terminal.flags.closed) {
return globalThis.throwInvalidArguments("terminal is closed", .{});
}
if (terminal.slave_fd == bun.invalid_fd) {
return globalThis.throwInvalidArguments("terminal slave fd is no longer valid", .{});
}
existing_terminal = terminal;
terminal_js_value = terminal_val;
} else if (terminal_val.isObject()) {
// Create a new terminal from options
var term_options = try Terminal.Options.parseFromJS(globalThis, terminal_val);
terminal_info = Terminal.createFromSpawn(globalThis, term_options) catch |err| {
term_options.deinit();
return switch (err) {
error.OpenPtyFailed => globalThis.throw("Failed to open PTY", .{}),
error.DupFailed => globalThis.throw("Failed to duplicate PTY file descriptor", .{}),
error.NotSupported => globalThis.throw("PTY not supported on this platform", .{}),
error.WriterStartFailed => globalThis.throw("Failed to start terminal writer", .{}),
error.ReaderStartFailed => globalThis.throw("Failed to start terminal reader", .{}),
};
};
} else {
return globalThis.throwInvalidArguments("terminal must be a Terminal object or options object", .{});
}
const terminal = existing_terminal orelse terminal_info.?.terminal;
const slave_fd = terminal.getSlaveFd();
stdio[0] = .{ .fd = slave_fd };
stdio[1] = .{ .fd = slave_fd };
stdio[2] = .{ .fd = slave_fd };
}
}
} else {
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
}
@@ -504,6 +559,12 @@ pub fn spawnMaybeSync(
.extra_fds = extra_fds.items,
.argv0 = argv0,
.can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = can_block_entire_thread_to_reduce_cpu_usage_in_fast_path,
// Only pass pty_slave_fd for newly created terminals (for setsid+TIOCSCTTY setup).
// For existing terminals, the session is already set up - child just uses the fd as stdio.
.pty_slave_fd = if (Environment.isPosix) blk: {
if (terminal_info) |ti| break :blk ti.terminal.getSlaveFd().native();
break :blk -1;
} else {},
.windows = if (Environment.isWindows) .{
.hide_window = windows_hide,
@@ -633,8 +694,18 @@ pub fn spawnMaybeSync(
.killSignal = killSignal,
.stderr_maxbuf = subprocess.stderr_maxbuf,
.stdout_maxbuf = subprocess.stdout_maxbuf,
.terminal = existing_terminal orelse if (terminal_info) |info| info.terminal else null,
};
// For inline terminal options: close parent's slave_fd so EOF is received when child exits
// For existing terminal: keep slave_fd open so terminal can be reused for more spawns
if (terminal_info) |info| {
terminal_js_value = info.js_value;
info.terminal.closeSlaveFd();
terminal_info = null;
}
// existing_terminal: don't close slave_fd - user manages lifecycle and can reuse
subprocess.process.setExitHandler(subprocess);
promise_for_stream.ensureStillAlive();
@@ -732,6 +803,11 @@ pub fn spawnMaybeSync(
jsc.Codegen.JSSubprocess.stdinSetCached(out, globalThis, stdio[0].readable_stream.value);
}
// Cache the terminal JS value if a terminal was created
if (terminal_js_value != .zero) {
jsc.Codegen.JSSubprocess.terminalSetCached(out, globalThis, terminal_js_value);
}
switch (subprocess.process.watch()) {
.result => {},
.err => {
@@ -1001,6 +1077,7 @@ const log = Output.scoped(.Subprocess, .hidden);
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
const IPC = @import("../../ipc.zig");
const Terminal = @import("./Terminal.zig");
const std = @import("std");
const Allocator = std.mem.Allocator;

View File

@@ -994,6 +994,8 @@ pub const PosixSpawnOptions = struct {
/// for stdout. This is used to preserve
/// consistent shell semantics.
no_sigpipe: bool = true,
/// PTY slave fd for controlling terminal setup (-1 if not using PTY).
pty_slave_fd: i32 = -1,
pub const Stdio = union(enum) {
path: []const u8,
@@ -1062,6 +1064,8 @@ pub const WindowsSpawnOptions = struct {
stream: bool = true,
use_execve_on_macos: bool = false,
can_block_entire_thread_to_reduce_cpu_usage_in_fast_path: bool = false,
/// PTY not supported on Windows - this is a void placeholder for struct compatibility
pty_slave_fd: void = {},
pub const WindowsOptions = struct {
verbatim_arguments: bool = false,
hide_window: bool = true,
@@ -1257,6 +1261,9 @@ pub fn spawnProcessPosix(
flags |= bun.c.POSIX_SPAWN_SETSID;
}
// Pass PTY slave fd to attr for controlling terminal setup
attr.pty_slave_fd = options.pty_slave_fd;
if (options.cwd.len > 0) {
try actions.chdir(options.cwd);
}

View File

@@ -92,6 +92,9 @@ pub const BunSpawn = struct {
pub const Attr = struct {
detached: bool = false,
pty_slave_fd: i32 = -1,
flags: u16 = 0,
reset_signals: bool = false,
pub fn init() !Attr {
return Attr{};
@@ -100,21 +103,16 @@ pub const BunSpawn = struct {
pub fn deinit(_: *Attr) void {}
pub fn get(self: Attr) !u16 {
var flags: c_int = 0;
if (self.detached) {
flags |= bun.C.POSIX_SPAWN_SETSID;
}
return @intCast(flags);
return self.flags;
}
pub fn set(self: *Attr, flags: u16) !void {
self.flags = flags;
self.detached = (flags & bun.c.POSIX_SPAWN_SETSID) != 0;
}
pub fn resetSignals(this: *Attr) !void {
_ = this;
pub fn resetSignals(self: *Attr) !void {
self.reset_signals = true;
}
};
};
@@ -128,6 +126,8 @@ pub const PosixSpawn = struct {
pub const PosixSpawnAttr = struct {
attr: system.posix_spawnattr_t,
detached: bool = false,
pty_slave_fd: i32 = -1,
pub fn init() !PosixSpawnAttr {
var attr: system.posix_spawnattr_t = undefined;
@@ -259,13 +259,17 @@ pub const PosixSpawn = struct {
}
};
pub const Actions = if (Environment.isLinux) BunSpawn.Actions else PosixSpawnActions;
pub const Attr = if (Environment.isLinux) BunSpawn.Attr else PosixSpawnAttr;
// Use BunSpawn types on POSIX (both Linux and macOS) for PTY support via posix_spawn_bun.
// Windows uses different spawn mechanisms.
pub const Actions = if (Environment.isPosix) BunSpawn.Actions else PosixSpawnActions;
pub const Attr = if (Environment.isPosix) BunSpawn.Attr else PosixSpawnAttr;
/// Used for Linux spawns and macOS PTY spawns via posix_spawn_bun.
const BunSpawnRequest = extern struct {
chdir_buf: ?[*:0]u8 = null,
detached: bool = false,
actions: ActionsList = .{},
pty_slave_fd: i32 = -1,
const ActionsList = extern struct {
ptr: ?[*]const BunSpawn.Action = null,
@@ -318,7 +322,18 @@ pub const PosixSpawn = struct {
argv: [*:null]?[*:0]const u8,
envp: [*:null]?[*:0]const u8,
) Maybe(pid_t) {
if (comptime Environment.isLinux) {
const pty_slave_fd = if (attr) |a| a.pty_slave_fd else -1;
const detached = if (attr) |a| a.detached else false;
// Use posix_spawn_bun when:
// - Linux: always (uses vfork which is fast and safe)
// - macOS: only for PTY spawns (pty_slave_fd >= 0) because PTY setup requires
// setsid() + ioctl(TIOCSCTTY) before exec, which system posix_spawn can't do.
// For non-PTY spawns on macOS, we use system posix_spawn which is safer
// (Apple's posix_spawn uses a kernel fast-path that avoids fork() entirely).
const use_bun_spawn = Environment.isLinux or (Environment.isMac and pty_slave_fd >= 0);
if (use_bun_spawn) {
return BunSpawnRequest.spawn(
path,
.{
@@ -330,13 +345,106 @@ pub const PosixSpawn = struct {
.len = 0,
},
.chdir_buf = if (actions) |a| a.chdir_buf else null,
.detached = if (attr) |a| a.detached else false,
.detached = detached,
.pty_slave_fd = pty_slave_fd,
},
argv,
envp,
);
}
// macOS without PTY: use system posix_spawn
// Need to convert BunSpawn.Actions to PosixSpawnActions for system posix_spawn
if (comptime Environment.isMac) {
var posix_actions = PosixSpawnActions.init() catch return Maybe(pid_t){
.err = .{
.errno = @intFromEnum(std.c.E.NOMEM),
.syscall = .posix_spawn,
},
};
defer posix_actions.deinit();
var posix_attr = PosixSpawnAttr.init() catch return Maybe(pid_t){
.err = .{
.errno = @intFromEnum(std.c.E.NOMEM),
.syscall = .posix_spawn,
},
};
defer posix_attr.deinit();
// Pass through all flags from the BunSpawn.Attr
if (attr) |a| {
if (a.flags != 0) {
posix_attr.set(a.flags) catch {};
}
if (a.reset_signals) {
posix_attr.resetSignals() catch {};
}
}
// Convert actions
if (actions) |act| {
for (act.actions.items) |action| {
switch (action.kind) {
.close => posix_actions.close(bun.FD.fromNative(action.fds[0])) catch |err| {
if (comptime bun.Environment.allow_assert) {
bun.sys.syslog("posix_spawn_file_actions_addclose({d}) failed: {s}", .{ action.fds[0], @errorName(err) });
}
},
.dup2 => posix_actions.dup2(bun.FD.fromNative(action.fds[0]), bun.FD.fromNative(action.fds[1])) catch |err| {
if (comptime bun.Environment.allow_assert) {
bun.sys.syslog("posix_spawn_file_actions_adddup2({d}, {d}) failed: {s}", .{ action.fds[0], action.fds[1], @errorName(err) });
}
},
.open => posix_actions.openZ(bun.FD.fromNative(action.fds[0]), action.path.?, @intCast(action.flags), @intCast(action.mode)) catch |err| {
if (comptime bun.Environment.allow_assert) {
bun.sys.syslog("posix_spawn_file_actions_addopen({d}, {s}, {d}, {d}) failed: {s}", .{ action.fds[0], action.path.?, action.flags, action.mode, @errorName(err) });
}
},
.none => {},
}
}
// Handle chdir
if (act.chdir_buf) |chdir_path| {
posix_actions.chdir(bun.span(chdir_path)) catch |err| {
if (comptime bun.Environment.allow_assert) {
bun.sys.syslog("posix_spawn_file_actions_addchdir({s}) failed: {s}", .{ chdir_path, @errorName(err) });
}
};
}
}
var pid: pid_t = undefined;
const rc = system.posix_spawn(
&pid,
path,
&posix_actions.actions,
&posix_attr.attr,
argv,
envp,
);
if (comptime bun.Environment.allow_assert)
bun.sys.syslog("posix_spawn({s}) = {d} ({d})", .{
path,
rc,
pid,
});
if (rc == 0) {
return Maybe(pid_t){ .result = pid };
}
return Maybe(pid_t){
.err = .{
.errno = @as(bun.sys.Error.Int, @truncate(@intFromEnum(@as(std.c.E, @enumFromInt(rc))))),
.syscall = .posix_spawn,
.path = bun.asByteSlice(path),
},
};
}
// Windows path (uses different mechanism)
var pid: pid_t = undefined;
const rc = system.posix_spawn(
&pid,

View File

@@ -20,6 +20,9 @@ stderr: Readable,
stdio_pipes: if (Environment.isWindows) std.ArrayListUnmanaged(StdioResult) else std.ArrayListUnmanaged(bun.FileDescriptor) = .{},
pid_rusage: ?Rusage = null,
/// Terminal attached to this subprocess (if spawned with terminal option)
terminal: ?*Terminal = null,
globalThis: *jsc.JSGlobalObject,
observable_getters: std.enums.EnumSet(enum {
stdin,
@@ -269,16 +272,28 @@ pub const PipeReader = @import("./subprocess/SubprocessPipeReader.zig");
pub const Readable = @import("./subprocess/Readable.zig").Readable;
pub fn getStderr(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
// When terminal is used, stderr goes through the terminal
if (this.terminal != null) {
return .null;
}
this.observable_getters.insert(.stderr);
return this.stderr.toJS(globalThis, this.hasExited());
}
pub fn getStdin(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
// When terminal is used, stdin goes through the terminal
if (this.terminal != null) {
return .null;
}
this.observable_getters.insert(.stdin);
return this.stdin.toJS(globalThis, this);
}
pub fn getStdout(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
// When terminal is used, stdout goes through the terminal
if (this.terminal != null) {
return .null;
}
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
@@ -286,6 +301,13 @@ pub fn getStdout(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSV
return this.stdout.toJS(globalThis, this.hasExited());
}
pub fn getTerminal(this: *Subprocess, globalThis: *JSGlobalObject) JSValue {
if (this.terminal) |terminal| {
return terminal.toJS(globalThis);
}
return .js_undefined;
}
pub fn asyncDispose(this: *Subprocess, global: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
if (this.process.hasExited()) {
// rely on GC to clean everything up in this case
@@ -896,6 +918,7 @@ pub const spawnSync = js_bun_spawn_bindings.spawnSync;
pub const spawn = js_bun_spawn_bindings.spawn;
const IPC = @import("../../ipc.zig");
const Terminal = @import("./Terminal.zig");
const js_bun_spawn_bindings = @import("./js_bun_spawn_bindings.zig");
const node_cluster_binding = @import("../../node/node_cluster_binding.zig");
const std = @import("std");

View File

@@ -19,6 +19,7 @@
macro(SHA512_256) \
macro(TOML) \
macro(YAML) \
macro(Terminal) \
macro(Transpiler) \
macro(ValkeyClient) \
macro(argv) \

View File

@@ -800,6 +800,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
which BunObject_callback_which DontDelete|Function 1

View File

@@ -1,6 +1,6 @@
#include "root.h"
#if OS(LINUX)
#if OS(LINUX) || OS(DARWIN)
#include <fcntl.h>
#include <cstring>
@@ -8,18 +8,71 @@
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/syscall.h>
#include <sys/resource.h>
#if OS(LINUX)
#include <sys/syscall.h>
#endif
extern char** environ;
#ifndef CLOSE_RANGE_CLOEXEC
#define CLOSE_RANGE_CLOEXEC (1U << 2)
#endif
#if OS(LINUX)
extern "C" ssize_t bun_close_range(unsigned int start, unsigned int end, unsigned int flags);
#endif
// Helper: get max fd from system, clamped to sane limits and optionally to 'end' parameter
static inline int getMaxFd(int start, int end)
{
#if OS(LINUX)
int maxfd = static_cast<int>(sysconf(_SC_OPEN_MAX));
#elif OS(DARWIN)
int maxfd = getdtablesize();
#else
int maxfd = 1024;
#endif
if (maxfd < 0 || maxfd > 65536) maxfd = 1024;
// Respect the end parameter if it's a valid bound (not INT_MAX sentinel)
if (end >= start && end < INT_MAX) {
maxfd = std::min(maxfd, end + 1); // +1 because end is inclusive
}
return maxfd;
}
// Loop-based fallback for closing/cloexec fds
static inline void closeRangeLoop(int start, int end, bool cloexec_only)
{
int maxfd = getMaxFd(start, end);
for (int fd = start; fd < maxfd; fd++) {
if (cloexec_only) {
int current_flags = fcntl(fd, F_GETFD);
if (current_flags >= 0) {
fcntl(fd, F_SETFD, current_flags | FD_CLOEXEC);
}
} else {
close(fd);
}
}
}
// Platform-specific close range implementation
static inline void closeRangeOrLoop(int start, int end, bool cloexec_only)
{
#if OS(LINUX)
unsigned int flags = cloexec_only ? CLOSE_RANGE_CLOEXEC : 0;
if (bun_close_range(start, end, flags) == 0) {
return;
}
// Fallback for older kernels or when close_range fails
#endif
closeRangeLoop(start, end, cloexec_only);
}
enum FileActionType : uint8_t {
None,
@@ -45,8 +98,21 @@ typedef struct bun_spawn_request_t {
const char* chdir;
bool detached;
bun_spawn_file_action_list_t actions;
int pty_slave_fd; // -1 if not using PTY, otherwise the slave fd to set as controlling terminal
} bun_spawn_request_t;
// Raw exit syscall that doesn't go through libc.
// This avoids potential deadlocks when forking from a multi-threaded process,
// as _exit() may try to acquire locks held by threads that don't exist in the child.
static inline void rawExit(int status)
{
#if OS(LINUX)
syscall(__NR_exit_group, status);
#else
_exit(status);
#endif
}
extern "C" ssize_t posix_spawn_bun(
int* pid,
const char* path,
@@ -54,23 +120,65 @@ extern "C" ssize_t posix_spawn_bun(
char* const argv[],
char* const envp[])
{
volatile int status = 0;
sigset_t blockall, oldmask;
int res = 0, cs = 0;
#if OS(DARWIN)
// On macOS, we use fork() which requires a self-pipe trick to detect exec failures.
// Create a pipe for child-to-parent error communication.
// The write end has O_CLOEXEC so it's automatically closed on successful exec.
// If exec fails, child writes errno to the pipe.
int errpipe[2];
if (pipe(errpipe) == -1) {
return errno;
}
// Set cloexec on write end so it closes on successful exec
fcntl(errpipe[1], F_SETFD, FD_CLOEXEC);
#endif
sigfillset(&blockall);
sigprocmask(SIG_SETMASK, &blockall, &oldmask);
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cs);
pid_t child = vfork();
#if OS(LINUX)
// On Linux, use vfork() for performance. The parent is suspended until
// the child calls exec or _exit, so we can detect exec failure via the
// child's exit status without needing the self-pipe trick.
// While POSIX restricts vfork children to only calling _exit() or exec*(),
// Linux's vfork() is more permissive and allows the setup we need
// (setsid, ioctl, dup2, etc.) before exec.
volatile int child_errno = 0;
pid_t child = vfork();
#else
// On macOS, we must use fork() because vfork() is more strictly enforced.
// This code path should only be used for PTY spawns on macOS.
pid_t child = fork();
#endif
#if OS(DARWIN)
const auto childFailed = [&]() -> ssize_t {
res = errno;
status = res;
bun_close_range(0, ~0U, 0);
_exit(127);
int err = errno;
// Write errno to pipe so parent can read it
(void)write(errpipe[1], &err, sizeof(err));
close(errpipe[1]);
closeRangeOrLoop(0, INT_MAX, false);
rawExit(127);
// should never be reached
return -1;
};
#else
const auto childFailed = [&]() -> ssize_t {
// With vfork(), we share memory with the parent, so we can communicate
// the error directly via a volatile variable. The parent will see this
// value after we call _exit().
child_errno = errno;
rawExit(127);
// should never be reached
return -1;
};
#endif
const auto startChild = [&]() -> ssize_t {
sigset_t childmask = oldmask;
@@ -82,11 +190,19 @@ extern "C" ssize_t posix_spawn_bun(
sigaction(i, &sa, 0);
}
// Make "detached" work
if (request->detached) {
// Make "detached" work, or set up PTY as controlling terminal
if (request->detached || request->pty_slave_fd >= 0) {
setsid();
}
// Set PTY slave as controlling terminal for proper job control.
// TIOCSCTTY may fail if the terminal is already the controlling terminal
// of another session. This is non-fatal - the process can still run,
// just without proper job control.
if (request->pty_slave_fd >= 0) {
(void)ioctl(request->pty_slave_fd, TIOCSCTTY, 0);
}
int current_max_fd = 0;
if (request->chdir) {
@@ -165,35 +281,89 @@ extern "C" ssize_t posix_spawn_bun(
if (!envp)
envp = environ;
if (bun_close_range(current_max_fd + 1, ~0U, CLOSE_RANGE_CLOEXEC) != 0) {
bun_close_range(current_max_fd + 1, ~0U, 0);
}
// Close all fds > current_max_fd, preferring cloexec if available
closeRangeOrLoop(current_max_fd + 1, INT_MAX, true);
if (execve(path, argv, envp) == -1) {
return childFailed();
}
_exit(127);
rawExit(127);
// should never be reached.
return -1;
};
if (child == 0) {
#if OS(DARWIN)
// Close read end in child
close(errpipe[0]);
#endif
return startChild();
}
if (child != -1) {
res = status;
#if OS(DARWIN)
// macOS fork() path: use self-pipe trick to detect exec failure
// Parent: close write end
close(errpipe[1]);
if (!res) {
if (child != -1) {
// Try to read error from child. The pipe read end is blocking.
// - If exec succeeds: write end closes due to O_CLOEXEC, read() returns 0
// - If exec fails: child writes errno, then exits, read() returns sizeof(int)
int child_err = 0;
ssize_t n;
// Retry read on EINTR - signals are blocked but some may still interrupt
do {
n = read(errpipe[0], &child_err, sizeof(child_err));
} while (n == -1 && errno == EINTR);
close(errpipe[0]);
if (n == sizeof(child_err)) {
// Child failed to exec - it wrote errno and exited
// Reap the zombie child process
waitpid(child, NULL, 0);
res = child_err;
} else if (n == 0) {
// Exec succeeded (pipe closed with no data written)
// Don't wait - the child is now running as a new process
res = 0;
if (pid) {
*pid = child;
}
} else {
wait4(child, 0, 0, 0);
// read() failed or partial read - something went wrong
// Reap child and report error
waitpid(child, NULL, 0);
res = (n == -1) ? errno : EIO;
}
} else {
// fork() failed
close(errpipe[0]);
res = errno;
}
#else
// Linux vfork() path: parent resumes after child calls exec or _exit
// We can detect exec failure via the volatile child_errno variable
if (child != -1) {
if (child_errno != 0) {
// Child failed to exec - it set child_errno and called _exit()
// Reap the zombie child process
wait4(child, NULL, 0, NULL);
res = child_errno;
} else {
// Exec succeeded
res = 0;
if (pid) {
*pid = child;
}
}
} else {
// vfork() failed
res = errno;
}
#endif
sigprocmask(SIG_SETMASK, &oldmask, 0);
pthread_setcancelstate(cs, 0);

View File

@@ -48,6 +48,7 @@ pub const Classes = struct {
pub const ServerWebSocket = api.ServerWebSocket;
pub const Subprocess = api.Subprocess;
pub const ResourceUsage = api.Subprocess.ResourceUsage;
pub const Terminal = api.Terminal;
pub const TCPSocket = api.TCPSocket;
pub const TLSSocket = api.TLSSocket;
pub const UDPSocket = api.UDPSocket;

File diff suppressed because it is too large Load Diff