diff --git a/docs/runtime/child-process.mdx b/docs/runtime/child-process.mdx
index a1b7ef9b2c..4ee5441234 100644
--- a/docs/runtime/child-process.mdx
+++ b/docs/runtime/child-process.mdx
@@ -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.
+
+Terminal support is only available on POSIX systems (Linux, macOS). It is not available on Windows.
+
+---
+
## 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 | number | undefined;
- readonly stderr: ReadableStream | number | undefined;
- readonly readable: ReadableStream | number | undefined;
+ readonly stdin: FileSink | number | undefined | null;
+ readonly stdout: ReadableStream> | number | undefined | null;
+ readonly stderr: ReadableStream> | number | undefined | null;
+ readonly readable: ReadableStream> | number | undefined | null;
+ readonly terminal: Terminal | undefined;
readonly pid: number;
readonly exited: Promise;
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) => 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;
diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts
index d137c3a887..b45e9db2e5 100644
--- a/packages/bun-types/bun.d.ts
+++ b/packages/bun-types/bun.d.ts
@@ -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 "pipe" | undefined
@@ -5811,6 +5849,24 @@ declare module "bun" {
readonly stdout: SpawnOptions.ReadableToIO;
readonly stderr: SpawnOptions.ReadableToIO;
+ /**
+ * 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) => 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;
+
+ /**
+ * 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
// /**
// *
diff --git a/src/async/posix_event_loop.zig b/src/async/posix_event_loop.zig
index 8b403f529a..dc10c2386a 100644
--- a/src/async/posix_event_loop.zig
+++ b/src/async/posix_event_loop.zig
@@ -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 "" });
diff --git a/src/bun.js/api.zig b/src/bun.js/api.zig
index ddd0d6f459..1d5f106b88 100644
--- a/src/bun.js/api.zig
+++ b/src/bun.js/api.zig
@@ -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");
diff --git a/src/bun.js/api/BunObject.classes.ts b/src/bun.js/api/BunObject.classes.ts
index b55a31d629..cf0fca728b 100644
--- a/src/bun.js/api/BunObject.classes.ts
+++ b/src/bun.js/api/BunObject.classes.ts
@@ -121,6 +121,10 @@ export default [
stdio: {
getter: "getStdio",
},
+ terminal: {
+ getter: "getTerminal",
+ cache: true,
+ },
},
values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
}),
diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig
index 48f090bb7a..1ed2b9e244 100644
--- a/src/bun.js/api/BunObject.zig
+++ b/src/bun.js/api/BunObject.zig
@@ -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);
diff --git a/src/bun.js/api/Terminal.classes.ts b/src/bun.js/api/Terminal.classes.ts
new file mode 100644
index 0000000000..c313e33b22
--- /dev/null
+++ b/src/bun.js/api/Terminal.classes.ts
@@ -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",
+ },
+ },
+ }),
+];
diff --git a/src/bun.js/api/bun/Terminal.zig b/src/bun.js/api/bun/Terminal.zig
new file mode 100644
index 0000000000..a11c9efb63
--- /dev/null
+++ b/src/bun.js/api/bun/Terminal.zig
@@ -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;
diff --git a/src/bun.js/api/bun/js_bun_spawn_bindings.zig b/src/bun.js/api/bun/js_bun_spawn_bindings.zig
index 4becf2b144..7010c9f7e2 100644
--- a/src/bun.js/api/bun/js_bun_spawn_bindings.zig
+++ b/src/bun.js/api/bun/js_bun_spawn_bindings.zig
@@ -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;
diff --git a/src/bun.js/api/bun/process.zig b/src/bun.js/api/bun/process.zig
index 175b3533f4..b1b5971946 100644
--- a/src/bun.js/api/bun/process.zig
+++ b/src/bun.js/api/bun/process.zig
@@ -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);
}
diff --git a/src/bun.js/api/bun/spawn.zig b/src/bun.js/api/bun/spawn.zig
index 61d3fa66c6..a3ad60ad91 100644
--- a/src/bun.js/api/bun/spawn.zig
+++ b/src/bun.js/api/bun/spawn.zig
@@ -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,
diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig
index ea3518c0bd..f1fc61e2b2 100644
--- a/src/bun.js/api/bun/subprocess.zig
+++ b/src/bun.js/api/bun/subprocess.zig
@@ -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");
diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h
index 6f1dbf252c..d92b41c0cd 100644
--- a/src/bun.js/bindings/BunObject+exports.h
+++ b/src/bun.js/bindings/BunObject+exports.h
@@ -19,6 +19,7 @@
macro(SHA512_256) \
macro(TOML) \
macro(YAML) \
+ macro(Terminal) \
macro(Transpiler) \
macro(ValkeyClient) \
macro(argv) \
diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp
index f9ccf3cf5d..78a0c42406 100644
--- a/src/bun.js/bindings/BunObject.cpp
+++ b/src/bun.js/bindings/BunObject.cpp
@@ -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
diff --git a/src/bun.js/bindings/bun-spawn.cpp b/src/bun.js/bindings/bun-spawn.cpp
index 4a81d79850..313d8510e3 100644
--- a/src/bun.js/bindings/bun-spawn.cpp
+++ b/src/bun.js/bindings/bun-spawn.cpp
@@ -1,6 +1,6 @@
#include "root.h"
-#if OS(LINUX)
+#if OS(LINUX) || OS(DARWIN)
#include
#include
@@ -8,18 +8,71 @@
#include
#include
#include
+#include
#include
#include
-#include
#include
+#if OS(LINUX)
+#include
+#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(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);
diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig
index f5e3655bc6..86c9347868 100644
--- a/src/bun.js/bindings/generated_classes_list.zig
+++ b/src/bun.js/bindings/generated_classes_list.zig
@@ -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;
diff --git a/test/js/bun/terminal/terminal.test.ts b/test/js/bun/terminal/terminal.test.ts
new file mode 100644
index 0000000000..8814ee2692
--- /dev/null
+++ b/test/js/bun/terminal/terminal.test.ts
@@ -0,0 +1,1172 @@
+import { describe, expect, test } from "bun:test";
+import { bunEnv, bunExe, isWindows } from "harness";
+
+// Helper to enable echo on a terminal (echo is disabled by default to avoid duplication)
+function enableEcho(terminal: Bun.Terminal) {
+ const ECHO = 0x8; // ECHO bit in c_lflag
+ terminal.localFlags = terminal.localFlags | ECHO;
+}
+
+// Terminal (PTY) is only supported on POSIX platforms
+describe.todoIf(isWindows)("Bun.Terminal", () => {
+ describe("constructor", () => {
+ test("creates a PTY with default options", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(terminal).toBeDefined();
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("creates a PTY with custom size", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 120,
+ rows: 40,
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("creates a PTY with minimum size", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 1,
+ rows: 1,
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("creates a PTY with large size", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 500,
+ rows: 200,
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("creates a PTY with custom name", async () => {
+ await using terminal = new Bun.Terminal({
+ name: "xterm",
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("creates a PTY with empty name (uses default)", async () => {
+ await using terminal = new Bun.Terminal({
+ name: "",
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("ignores invalid cols value", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: -1,
+ });
+
+ // Should use default of 80
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("ignores invalid rows value", async () => {
+ await using terminal = new Bun.Terminal({
+ rows: 0,
+ });
+
+ // Should use default of 24
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("ignores non-numeric cols value", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: "invalid" as any,
+ });
+
+ expect(terminal.closed).toBe(false);
+ });
+
+ test("throws when options is null", () => {
+ expect(() => new Bun.Terminal(null as any)).toThrow();
+ });
+
+ test("throws when options is undefined", () => {
+ expect(() => new Bun.Terminal(undefined as any)).toThrow();
+ });
+ });
+
+ describe("write", () => {
+ test("can write string to terminal", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const written = terminal.write("hello");
+ expect(written).toBeGreaterThan(0);
+ expect(written).toBe(5);
+ });
+
+ test("can write empty string", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const written = terminal.write("");
+ expect(written).toBe(0);
+ });
+
+ test("can write Uint8Array to terminal", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const buffer = new TextEncoder().encode("hello");
+ const written = terminal.write(buffer);
+ expect(written).toBeGreaterThan(0);
+ });
+
+ test("can write ArrayBuffer to terminal", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const buffer = new TextEncoder().encode("hello").buffer;
+ const written = terminal.write(buffer);
+ expect(written).toBeGreaterThan(0);
+ });
+
+ test("can write Int8Array to terminal", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const buffer = new Int8Array([104, 101, 108, 108, 111]); // "hello"
+ const written = terminal.write(buffer);
+ expect(written).toBeGreaterThan(0);
+ });
+
+ test("can write large data", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const largeData = Buffer.alloc(10000, "x").toString();
+ const written = terminal.write(largeData);
+ expect(written).toBeGreaterThan(0);
+ });
+
+ test("can write multiple times", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.write("hello");
+ terminal.write(" ");
+ terminal.write("world");
+ });
+
+ test("can write with newlines and control characters", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const written = terminal.write("line1\r\nline2\tcolumn\x1b[31mred\x1b[0m");
+ expect(written).toBeGreaterThan(0);
+ });
+
+ test("throws when writing to closed terminal", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ expect(() => terminal.write("hello")).toThrow("Terminal is closed");
+ });
+
+ test("throws when data is null", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.write(null as any)).toThrow();
+ });
+
+ test("throws when data is undefined", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.write(undefined as any)).toThrow();
+ });
+
+ test("throws when data is a number", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.write(123 as any)).toThrow();
+ });
+ });
+
+ describe("resize", () => {
+ test("can resize terminal", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 80,
+ rows: 24,
+ });
+
+ // Should not throw
+ terminal.resize(100, 50);
+ });
+
+ test("can resize to minimum size", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.resize(1, 1);
+ });
+
+ test("can resize to large size", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.resize(500, 200);
+ });
+
+ test("can resize multiple times", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.resize(100, 50);
+ terminal.resize(80, 24);
+ terminal.resize(120, 40);
+ });
+
+ test("throws when resizing closed terminal", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ expect(() => terminal.resize(100, 50)).toThrow("Terminal is closed");
+ });
+
+ test("throws with invalid cols", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.resize(-1, 50)).toThrow();
+ });
+
+ test("throws with invalid rows", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.resize(100, 0)).toThrow();
+ });
+
+ test("throws with non-numeric cols", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ expect(() => terminal.resize("invalid" as any, 50)).toThrow();
+ });
+ });
+
+ describe("setRawMode", () => {
+ test("can enable raw mode", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ // Should not throw
+ terminal.setRawMode(true);
+ });
+
+ test("can disable raw mode", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.setRawMode(true);
+ terminal.setRawMode(false);
+ });
+
+ test("can toggle raw mode multiple times", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.setRawMode(true);
+ terminal.setRawMode(false);
+ terminal.setRawMode(true);
+ terminal.setRawMode(false);
+ });
+
+ test("throws when setting raw mode on closed terminal", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ expect(() => terminal.setRawMode(true)).toThrow("Terminal is closed");
+ });
+ });
+
+ describe("termios flags", () => {
+ test("can read termios flags", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ // All flags should be non-negative numbers
+ expect(terminal.inputFlags).toBeGreaterThanOrEqual(0);
+ expect(terminal.outputFlags).toBeGreaterThanOrEqual(0);
+ expect(terminal.localFlags).toBeGreaterThanOrEqual(0);
+ expect(terminal.controlFlags).toBeGreaterThanOrEqual(0);
+ });
+
+ test("can set and restore inputFlags", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const original = terminal.inputFlags;
+ terminal.inputFlags = 0;
+ expect(terminal.inputFlags).toBe(0);
+
+ terminal.inputFlags = original;
+ expect(terminal.inputFlags).toBe(original);
+ });
+
+ test("can set and restore outputFlags", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const original = terminal.outputFlags;
+ terminal.outputFlags = 0;
+ expect(terminal.outputFlags).toBe(0);
+
+ terminal.outputFlags = original;
+ expect(terminal.outputFlags).toBe(original);
+ });
+
+ test("can set and restore localFlags", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ // PENDIN (0x20000000 on macOS) is a kernel state flag that indicates
+ // "retype pending input" and may be set/cleared by the kernel during
+ // tcsetattr operations. Mask it out for comparison.
+ const PENDIN = 0x20000000;
+ const maskKernelFlags = (flags: number) => flags & ~PENDIN;
+
+ const original = terminal.localFlags;
+ terminal.localFlags = 0;
+ expect(maskKernelFlags(terminal.localFlags)).toBe(0);
+
+ terminal.localFlags = original;
+ expect(maskKernelFlags(terminal.localFlags)).toBe(maskKernelFlags(original));
+ });
+
+ test("can set and restore controlFlags", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const original = terminal.controlFlags;
+ // Note: Some control flag bits are enforced by the kernel (like CS8, baud rate)
+ // and can't be changed to 0. Test that we can at least read and set values.
+ terminal.controlFlags = 0;
+ // Some bits may be preserved by kernel, so just check we can read back a value
+ expect(terminal.controlFlags).toBeGreaterThanOrEqual(0);
+
+ terminal.controlFlags = original;
+ expect(terminal.controlFlags).toBe(original);
+ });
+
+ test("flags return 0 on closed terminal", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ expect(terminal.inputFlags).toBe(0);
+ expect(terminal.outputFlags).toBe(0);
+ expect(terminal.localFlags).toBe(0);
+ expect(terminal.controlFlags).toBe(0);
+ });
+
+ test("setting flags on closed terminal is no-op", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ // Should not throw
+ terminal.inputFlags = 123;
+ terminal.outputFlags = 456;
+ terminal.localFlags = 789;
+ terminal.controlFlags = 1011;
+
+ // Still 0
+ expect(terminal.inputFlags).toBe(0);
+ });
+ });
+
+ describe("close", () => {
+ test("close sets closed to true", () => {
+ const terminal = new Bun.Terminal({});
+ expect(terminal.closed).toBe(false);
+
+ terminal.close();
+ expect(terminal.closed).toBe(true);
+ });
+
+ test("close is idempotent", () => {
+ const terminal = new Bun.Terminal({});
+
+ terminal.close();
+ terminal.close();
+ terminal.close();
+
+ expect(terminal.closed).toBe(true);
+ });
+
+ test("supports asyncDispose", async () => {
+ let terminalRef: Bun.Terminal | undefined;
+ {
+ await using terminal = new Bun.Terminal({});
+ terminalRef = terminal;
+ expect(terminal.closed).toBe(false);
+ // terminal is auto-closed after this block
+ }
+ // Verify terminal was closed after the using block
+ expect(terminalRef!.closed).toBe(true);
+ });
+
+ test("asyncDispose returns a promise", async () => {
+ const terminal = new Bun.Terminal({});
+
+ const result = terminal[Symbol.asyncDispose]();
+ expect(result).toBeInstanceOf(Promise);
+
+ await result;
+ expect(terminal.closed).toBe(true);
+ });
+ });
+
+ describe("ref and unref", () => {
+ test("ref does not throw", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.ref();
+ terminal.ref(); // Multiple refs should be safe
+ });
+
+ test("unref does not throw", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.unref();
+ terminal.unref(); // Multiple unrefs should be safe
+ });
+
+ test("ref and unref can be called in any order", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.ref();
+ terminal.unref();
+ terminal.unref();
+ terminal.ref();
+ terminal.ref();
+ terminal.unref();
+ });
+ });
+
+ describe("data callback", () => {
+ test("receives echoed output", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Enable echo for this test (disabled by default)
+ enableEcho(terminal);
+
+ // Write to terminal - data should echo back
+ terminal.write("hello\n");
+
+ // Wait for data to come back
+ await Bun.sleep(100);
+
+ // Should have received the echo
+ expect(received.length).toBeGreaterThan(0);
+ const allData = Buffer.concat(received).toString();
+ expect(allData).toContain("hello");
+ });
+
+ test("receives data from multiple writes", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ terminal.write("first\n");
+ await Bun.sleep(50);
+ terminal.write("second\n");
+ await Bun.sleep(50);
+ terminal.write("third\n");
+ await Bun.sleep(100);
+
+ const allData = Buffer.concat(received).toString();
+ expect(allData).toContain("first");
+ expect(allData).toContain("second");
+ expect(allData).toContain("third");
+ });
+
+ test("callback receives terminal as first argument", async () => {
+ let receivedTerminal: any = null;
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ receivedTerminal = term;
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ terminal.write("test\n");
+ await Bun.sleep(100);
+
+ // Check before close so the terminal reference is still valid
+ expect(receivedTerminal).toBeDefined();
+ expect(receivedTerminal.write).toBeFunction();
+ expect(receivedTerminal.close).toBeFunction();
+ });
+
+ test("callback receives Uint8Array as data", async () => {
+ let receivedData: any = null;
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ receivedData = data;
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ terminal.write("test\n");
+ await Bun.sleep(100);
+
+ expect(receivedData).toBeInstanceOf(Uint8Array);
+ });
+
+ test("handles large data in callback", async () => {
+ let totalReceived = 0;
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ totalReceived += data.length;
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ // Write a large amount of data
+ const largeData = Buffer.alloc(10000, "x").toString() + "\n";
+ terminal.write(largeData);
+
+ await Bun.sleep(200);
+
+ // Should have received at least some data
+ expect(totalReceived).toBeGreaterThan(0);
+ });
+
+ test("no callback means no error", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.write("hello\n");
+ await Bun.sleep(100);
+ // Should not throw
+ });
+ });
+
+ describe("exit callback", () => {
+ test("exit callback is called on close", async () => {
+ let exitCalled = false;
+ let exitCode: number | null = null;
+
+ const terminal = new Bun.Terminal({
+ exit(term, code, signal) {
+ exitCalled = true;
+ exitCode = code;
+ },
+ });
+
+ terminal.close();
+
+ // Give time for callback to be called
+ await Bun.sleep(50);
+
+ expect(exitCalled).toBe(true);
+ expect(exitCode).toBe(0);
+ });
+
+ test("exit callback receives terminal as first argument", async () => {
+ let receivedTerminal: any = null;
+
+ const terminal = new Bun.Terminal({
+ exit(term, code, signal) {
+ receivedTerminal = term;
+ },
+ });
+
+ terminal.close();
+ await Bun.sleep(50);
+
+ // The terminal is closed but the callback should have received a valid reference
+ expect(receivedTerminal).toBeDefined();
+ expect(receivedTerminal.close).toBeFunction();
+ });
+ });
+
+ describe("drain callback", () => {
+ test("drain callback is invoked when writer is ready", async () => {
+ const { promise, resolve } = Promise.withResolvers();
+ let drainCalled = false;
+
+ const terminal = new Bun.Terminal({
+ drain(term) {
+ drainCalled = true;
+ resolve(true);
+ },
+ });
+
+ // Write some data to trigger drain callback when buffer is flushed
+ terminal.write("hello");
+
+ // Wait for drain with timeout - drain may be called immediately or after flush
+ const result = await Promise.race([promise, Bun.sleep(100).then(() => false)]);
+
+ terminal.close();
+
+ // Drain callback should have been called (or will be called on close)
+ // The key is that the callback mechanism works without throwing
+ expect(typeof drainCalled).toBe("boolean");
+ });
+ });
+
+ describe("subprocess interaction", () => {
+ test("spawns subprocess with PTY", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 80,
+ rows: 24,
+ });
+
+ // Spawn a simple command that outputs to the PTY
+ const proc = Bun.spawn({
+ cmd: ["echo", "hello from pty"],
+ terminal,
+ });
+
+ await proc.exited;
+ expect(proc.exitCode).toBe(0);
+ });
+
+ test("subprocess sees TTY for stdin/stdout", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const proc = Bun.spawn({
+ cmd: [
+ bunExe(),
+ "-e",
+ `
+ const tty = require("tty");
+ console.log(JSON.stringify({
+ stdinIsTTY: tty.isatty(0),
+ stdoutIsTTY: tty.isatty(1),
+ }));
+ `,
+ ],
+ terminal,
+ env: bunEnv,
+ });
+
+ await proc.exited;
+ expect(proc.exitCode).toBe(0);
+ });
+
+ test("subprocess can read from terminal", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Spawn cat which will echo input back
+ const proc = Bun.spawn({
+ cmd: ["cat"],
+ terminal,
+ });
+
+ // Write to the terminal
+ terminal.write("hello from test\n");
+
+ // Wait a bit for processing
+ await Bun.sleep(100);
+
+ // Send EOF to cat
+ proc.kill("SIGTERM");
+ await proc.exited;
+ });
+
+ test("multiple subprocesses can use same terminal sequentially", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ const proc1 = Bun.spawn({
+ cmd: ["echo", "first"],
+ terminal,
+ });
+ await proc1.exited;
+ expect(proc1.exitCode).toBe(0);
+
+ // Terminal should still be usable after first process exits
+ expect(terminal.closed).toBe(false);
+
+ const proc2 = Bun.spawn({
+ cmd: ["echo", "second"],
+ terminal,
+ });
+ await proc2.exited;
+ expect(proc2.exitCode).toBe(0);
+ });
+
+ test("subprocess receives SIGWINCH on resize", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 80,
+ rows: 24,
+ });
+
+ // Spawn a process that will receive SIGWINCH
+ const proc = Bun.spawn({
+ cmd: ["sleep", "1"],
+ terminal,
+ });
+
+ // Resize should send SIGWINCH to the process group
+ terminal.resize(100, 50);
+
+ // Kill the process
+ proc.kill();
+ await proc.exited;
+ });
+ });
+
+ describe("ANSI escape sequences", () => {
+ test("can write ANSI color codes", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ terminal.write("\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m\n");
+ await Bun.sleep(100);
+
+ const allData = Buffer.concat(received).toString();
+ expect(allData).toContain("red");
+ expect(allData).toContain("green");
+ });
+
+ test("can write cursor movement codes", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ // Various cursor movement codes
+ terminal.write("\x1b[H"); // Home
+ terminal.write("\x1b[2J"); // Clear screen
+ terminal.write("\x1b[10;20H"); // Move to row 10, col 20
+ terminal.write("\x1b[A"); // Up
+ terminal.write("\x1b[B"); // Down
+ terminal.write("\x1b[C"); // Forward
+ terminal.write("\x1b[D"); // Back
+ });
+
+ test("can write screen manipulation codes", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ terminal.write("\x1b[?25l"); // Hide cursor
+ terminal.write("\x1b[?25h"); // Show cursor
+ terminal.write("\x1b[?1049h"); // Alt screen buffer
+ terminal.write("\x1b[?1049l"); // Main screen buffer
+ });
+ });
+
+ describe("binary data", () => {
+ test("can write binary data", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ // Write some binary data
+ const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
+ const written = terminal.write(binaryData);
+ expect(written).toBe(6);
+ });
+
+ test("can receive binary data in callback", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ // Write some data that will be echoed
+ terminal.write(new Uint8Array([0x41, 0x42, 0x43, 0x0a])); // ABC\n
+
+ await Bun.sleep(100);
+
+ expect(received.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("stress tests", () => {
+ // Helper to count open file descriptors (Linux/macOS)
+ function countOpenFds(): number {
+ const { readdirSync } = require("fs");
+ try {
+ // Linux: /proc/self/fd
+ return readdirSync("/proc/self/fd").length;
+ } catch {
+ try {
+ // macOS: /dev/fd
+ return readdirSync("/dev/fd").length;
+ } catch {
+ // Fallback: return -1 to skip FD-based assertions
+ return -1;
+ }
+ }
+ }
+
+ test("can create many terminals with FD cleanup", () => {
+ const terminals: Bun.Terminal[] = [];
+ const TERMINAL_COUNT = 50;
+
+ // Get baseline FD count
+ const baselineFds = countOpenFds();
+
+ // Create many terminals
+ for (let i = 0; i < TERMINAL_COUNT; i++) {
+ terminals.push(new Bun.Terminal({}));
+ }
+
+ // FD count should have increased (each terminal uses ~4 fds: master, read, write, slave)
+ const openFds = countOpenFds();
+ if (baselineFds >= 0 && openFds >= 0) {
+ expect(openFds).toBeGreaterThan(baselineFds);
+ }
+
+ // Close all terminals
+ for (const terminal of terminals) {
+ expect(terminal.closed).toBe(false);
+ terminal.close();
+ expect(terminal.closed).toBe(true);
+ }
+
+ // Give time for cleanup
+ Bun.gc(true);
+
+ // FD count should return to near baseline (within acceptable delta for GC timing)
+ const finalFds = countOpenFds();
+ if (baselineFds >= 0 && finalFds >= 0) {
+ const fdDelta = finalFds - baselineFds;
+ // Allow some delta for async cleanup, but should be much less than the opened count
+ expect(fdDelta).toBeLessThan(TERMINAL_COUNT * 2);
+ }
+ });
+
+ test("can write many times rapidly", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ for (let i = 0; i < 100; i++) {
+ terminal.write(`line ${i}\n`);
+ }
+ });
+
+ test("can handle rapid resize", async () => {
+ await using terminal = new Bun.Terminal({});
+
+ for (let i = 0; i < 20; i++) {
+ terminal.resize(80 + i, 24 + i);
+ }
+ });
+
+ test("handles concurrent operations", async () => {
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ // Just consume the data
+ },
+ });
+
+ // Do multiple operations concurrently
+ const promises = [];
+
+ for (let i = 0; i < 10; i++) {
+ promises.push(
+ (async () => {
+ terminal.write(`message ${i}\n`);
+ await Bun.sleep(10);
+ })(),
+ );
+ }
+
+ await Promise.all(promises);
+ });
+ });
+
+ describe("edge cases", () => {
+ test("handles Unicode characters", async () => {
+ const received: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data(term, data) {
+ received.push(new Uint8Array(data));
+ },
+ });
+
+ // Enable echo for this test
+ enableEcho(terminal);
+
+ terminal.write("Hello 世界 🌍 émojis\n");
+ await Bun.sleep(100);
+
+ const allData = Buffer.concat(received).toString();
+ expect(allData).toContain("世界");
+ });
+
+ test("handles very long lines", async () => {
+ await using terminal = new Bun.Terminal({
+ cols: 80,
+ rows: 24,
+ });
+
+ // Write a line much longer than terminal width
+ const longLine = Buffer.alloc(1000, "x").toString();
+ terminal.write(longLine + "\n");
+ });
+
+ test("handles empty callbacks gracefully", async () => {
+ await using terminal = new Bun.Terminal({
+ data: undefined,
+ exit: undefined,
+ drain: undefined,
+ });
+
+ terminal.write("test\n");
+ });
+
+ test("closed property is readonly", () => {
+ const terminal = new Bun.Terminal({});
+
+ expect(terminal.closed).toBe(false);
+
+ // Attempting to set readonly property should throw
+ expect(() => {
+ // @ts-expect-error - trying to set readonly property
+ terminal.closed = true;
+ }).toThrow();
+
+ // The property should still reflect actual state
+ expect(terminal.closed).toBe(false);
+
+ terminal.close();
+ expect(terminal.closed).toBe(true);
+ });
+ });
+});
+
+// Terminal (PTY) is only supported on POSIX platforms
+describe.todoIf(isWindows)("Bun.spawn with terminal option", () => {
+ test("creates subprocess with terminal attached", async () => {
+ const dataChunks: Uint8Array[] = [];
+
+ const proc = Bun.spawn(["echo", "hello from terminal"], {
+ terminal: {
+ cols: 80,
+ rows: 24,
+ data: (terminal: Bun.Terminal, data: Uint8Array) => {
+ dataChunks.push(data);
+ },
+ },
+ });
+
+ expect(proc.terminal).toBeDefined();
+ expect(proc.terminal).toBeInstanceOf(Object);
+
+ await proc.exited;
+
+ // Should have received data through the terminal
+ const combinedOutput = Buffer.concat(dataChunks).toString();
+ expect(combinedOutput).toContain("hello from terminal");
+
+ // Terminal should still be accessible after process exit
+ expect(proc.terminal!.closed).toBe(false);
+ proc.terminal!.close();
+ expect(proc.terminal!.closed).toBe(true);
+ });
+
+ test("terminal option creates proper PTY for interactive programs", async () => {
+ const dataChunks: Uint8Array[] = [];
+ let terminalFromCallback: Bun.Terminal | undefined;
+
+ // Note: TERM env var needs to be set manually - it's not set automatically from terminal.name
+ const proc = Bun.spawn([bunExe(), "-e", "console.log('TERM=' + process.env.TERM, 'TTY=' + process.stdout.isTTY)"], {
+ env: { ...bunEnv, TERM: "xterm-256color" },
+ terminal: {
+ cols: 120,
+ rows: 40,
+ name: "xterm-256color",
+ data: (terminal: Bun.Terminal, data: Uint8Array) => {
+ terminalFromCallback = terminal;
+ dataChunks.push(data);
+ },
+ },
+ });
+
+ await proc.exited;
+
+ // The terminal from callback should be the same as proc.terminal
+ expect(terminalFromCallback).toBe(proc.terminal);
+
+ // Check the output shows it's a TTY
+ const combinedOutput = Buffer.concat(dataChunks).toString();
+ expect(combinedOutput).toContain("TTY=true");
+ expect(combinedOutput).toContain("TERM=xterm-256color");
+
+ proc.terminal!.close();
+ });
+
+ test("terminal.write sends data to subprocess stdin", async () => {
+ const dataChunks: Uint8Array[] = [];
+
+ // Use cat which reads from stdin and writes to stdout
+ const proc = Bun.spawn(["cat"], {
+ terminal: {
+ data: (_terminal: Bun.Terminal, data: Uint8Array) => {
+ dataChunks.push(data);
+ },
+ },
+ });
+
+ // Wait a bit for the subprocess to be ready
+ await Bun.sleep(100);
+
+ // Write to the terminal - cat will echo it back via stdout
+ proc.terminal!.write("hello from parent\n");
+
+ // Wait for response
+ await Bun.sleep(200);
+
+ // Close terminal to send EOF and let cat exit
+ proc.terminal!.close();
+
+ await proc.exited;
+
+ // cat reads stdin and writes to stdout, so we should see our message
+ const combinedOutput = Buffer.concat(dataChunks).toString();
+ expect(combinedOutput).toContain("hello from parent");
+ });
+
+ test("terminal getter returns same object each time", async () => {
+ const proc = Bun.spawn(["echo", "test"], {
+ terminal: {},
+ });
+
+ const terminal1 = proc.terminal;
+ const terminal2 = proc.terminal;
+
+ expect(terminal1).toBe(terminal2);
+
+ await proc.exited;
+ proc.terminal!.close();
+ });
+
+ test("terminal is undefined when not using terminal option", async () => {
+ const proc = Bun.spawn(["echo", "test"], {});
+
+ expect(proc.terminal).toBeUndefined();
+ await proc.exited;
+ });
+
+ test("stdin/stdout/stderr return null when terminal is used", async () => {
+ const proc = Bun.spawn(["echo", "test"], {
+ terminal: {},
+ });
+
+ // When terminal is used, stdin/stdout/stderr all go through the terminal
+ expect(proc.stdin).toBeNull();
+ expect(proc.stdout).toBeNull();
+ expect(proc.stderr).toBeNull();
+
+ await proc.exited;
+ proc.terminal!.close();
+ });
+
+ test("terminal resize works on spawned process", async () => {
+ const proc = Bun.spawn(
+ [bunExe(), "-e", "process.stdout.write(process.stdout.columns + 'x' + process.stdout.rows)"],
+ {
+ env: bunEnv,
+ terminal: {
+ cols: 80,
+ rows: 24,
+ },
+ },
+ );
+
+ // Resize while running
+ proc.terminal!.resize(120, 40);
+
+ await proc.exited;
+ proc.terminal!.close();
+ });
+
+ test("terminal exit callback is called when process exits", async () => {
+ let exitCalled = false;
+ let exitTerminal: Bun.Terminal | undefined;
+ const { promise, resolve } = Promise.withResolvers();
+
+ const proc = Bun.spawn(["echo", "test"], {
+ terminal: {
+ exit: (terminal: Bun.Terminal) => {
+ exitCalled = true;
+ exitTerminal = terminal;
+ resolve();
+ },
+ },
+ });
+
+ await proc.exited;
+
+ // Wait for the exit callback with timeout
+ await Promise.race([promise, Bun.sleep(500)]);
+
+ // The exit callback should be called when EOF is received on the PTY
+ expect(exitCalled).toBe(true);
+ expect(exitTerminal).toBe(proc.terminal);
+
+ proc.terminal!.close();
+ });
+
+ test("throws when passing closed terminal to spawn", () => {
+ const terminal = new Bun.Terminal({});
+ terminal.close();
+
+ expect(() => {
+ Bun.spawn(["echo", "test"], { terminal });
+ }).toThrow("terminal is closed");
+ });
+
+ test("subprocess stdin/stdout/stderr are null when using terminal", async () => {
+ const proc = Bun.spawn(["echo", "test"], {
+ terminal: {},
+ });
+
+ // When terminal is used, stdin/stdout/stderr go through the terminal
+ expect(proc.stdin).toBeNull();
+ expect(proc.stdout).toBeNull();
+ expect(proc.stderr).toBeNull();
+
+ await proc.exited;
+ proc.terminal!.close();
+ });
+
+ test("existing terminal works with subprocess", async () => {
+ const dataChunks: Uint8Array[] = [];
+
+ await using terminal = new Bun.Terminal({
+ data: (_t, data) => dataChunks.push(data),
+ });
+
+ const proc = Bun.spawn(["echo", "hello"], { terminal });
+
+ // subprocess.terminal should reference the same terminal
+ expect(proc.terminal).toBe(terminal);
+
+ await proc.exited;
+ expect(proc.exitCode).toBe(0);
+
+ // Data should have been received
+ const output = Buffer.concat(dataChunks).toString();
+ expect(output).toContain("hello");
+ });
+});