From d865ef41e2232bb55ef0ef7131df649ed930fd09 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 15 Dec 2025 12:51:13 -0800 Subject: [PATCH] feat: add Bun.Terminal API for pseudo-terminal (PTY) support (#25415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds a new `Bun.Terminal` API for creating and managing pseudo-terminals (PTYs), enabling interactive terminal applications in Bun. ### Features - **Standalone Terminal**: Create PTYs directly with `new Bun.Terminal(options)` - **Spawn Integration**: Spawn processes with PTY attached via `Bun.spawn({ terminal: options })` - **Full PTY Control**: Write data, resize, set raw mode, and handle callbacks ## Examples ### Basic Terminal with Spawn (Recommended) ```typescript const proc = Bun.spawn(["bash"], { terminal: { cols: 80, rows: 24, data(terminal, data) { // Handle output from the terminal process.stdout.write(data); }, exit(terminal, code, signal) { console.log(`Process exited with code ${code}`); }, }, }); // Write commands to the terminal proc.terminal.write("echo Hello from PTY!\n"); proc.terminal.write("exit\n"); await proc.exited; proc.terminal.close(); ``` ### Interactive Shell ```typescript // Create an interactive shell that mirrors to stdout const proc = Bun.spawn(["bash", "-i"], { terminal: { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24, data(term, data) { process.stdout.write(data); }, }, }); // Forward stdin to the terminal process.stdin.setRawMode(true); for await (const chunk of process.stdin) { proc.terminal.write(chunk); } ``` ### Running Interactive Programs (vim, htop, etc.) ```typescript const proc = Bun.spawn(["vim", "file.txt"], { terminal: { cols: process.stdout.columns, rows: process.stdout.rows, data(term, data) { process.stdout.write(data); }, }, }); // Handle terminal resize process.stdout.on("resize", () => { proc.terminal.resize(process.stdout.columns, process.stdout.rows); }); // Forward input process.stdin.setRawMode(true); for await (const chunk of process.stdin) { proc.terminal.write(chunk); } ``` ### Capturing Colored Output ```typescript const chunks: Uint8Array[] = []; const proc = Bun.spawn(["ls", "--color=always"], { terminal: { data(term, data) { chunks.push(data); }, }, }); await proc.exited; proc.terminal.close(); // Output includes ANSI color codes const output = Buffer.concat(chunks).toString(); console.log(output); ``` ### Standalone Terminal (Advanced) ```typescript const terminal = new Bun.Terminal({ cols: 80, rows: 24, data(term, data) { console.log("Received:", data.toString()); }, }); // Use terminal.stdin as the fd for child process stdio const proc = Bun.spawn(["bash"], { stdin: terminal.stdin, stdout: terminal.stdin, stderr: terminal.stdin, }); terminal.write("echo hello\n"); // Clean up terminal.close(); ``` ### Testing TTY Detection ```typescript const proc = Bun.spawn([ "bun", "-e", "console.log('isTTY:', process.stdout.isTTY)" ], { terminal: {}, }); // Output: isTTY: true ``` ## API ### `Bun.spawn()` with `terminal` option ```typescript const proc = Bun.spawn(cmd, { terminal: { cols?: number, // Default: 80 rows?: number, // Default: 24 name?: string, // Default: "xterm-256color" data?: (terminal: Terminal, data: Uint8Array) => void, exit?: (terminal: Terminal, code: number, signal: string | null) => void, drain?: (terminal: Terminal) => void, } }); // Access the terminal proc.terminal.write(data); proc.terminal.resize(cols, rows); proc.terminal.setRawMode(enabled); proc.terminal.close(); // Note: proc.stdin, proc.stdout, proc.stderr return null when terminal is used ``` ### `new Bun.Terminal(options)` ```typescript const terminal = new Bun.Terminal({ cols?: number, rows?: number, name?: string, data?: (terminal, data) => void, exit?: (terminal, code, signal) => void, drain?: (terminal) => void, }); terminal.stdin; // Slave fd (for child process) terminal.stdout; // Master fd (for reading) terminal.closed; // boolean terminal.write(data); terminal.resize(cols, rows); terminal.setRawMode(enabled); terminal.ref(); terminal.unref(); terminal.close(); await terminal[Symbol.asyncDispose](); ``` ## Implementation Details - Uses `openpty()` to create pseudo-terminal pairs - Properly manages file descriptor lifecycle with reference counting - Integrates with Bun's event loop via `BufferedReader` and `StreamingWriter` - Supports `await using` syntax for automatic cleanup - POSIX only (Linux, macOS) - not available on Windows ## Test Results - 80 tests passing - Covers: construction, writing, reading, resize, raw mode, callbacks, spawn integration, error handling, GC safety ## Changes - `src/bun.js/api/bun/Terminal.zig` - Terminal implementation - `src/bun.js/api/bun/Terminal.classes.ts` - Class definition for codegen - `src/bun.js/api/bun/subprocess.zig` - Added terminal field and getter - `src/bun.js/api/bun/js_bun_spawn_bindings.zig` - Terminal option parsing - `src/bun.js/api/BunObject.classes.ts` - Terminal getter on Subprocess - `packages/bun-types/bun.d.ts` - TypeScript types - `docs/runtime/child-process.mdx` - Documentation - `test/js/bun/terminal/terminal.test.ts` - Comprehensive tests πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner --- docs/runtime/child-process.mdx | 135 +- packages/bun-types/bun.d.ts | 204 +++ src/async/posix_event_loop.zig | 8 + src/bun.js/api.zig | 1 + src/bun.js/api/BunObject.classes.ts | 4 + src/bun.js/api/BunObject.zig | 6 + src/bun.js/api/Terminal.classes.ts | 64 + src/bun.js/api/bun/Terminal.zig | 973 ++++++++++++++ src/bun.js/api/bun/js_bun_spawn_bindings.zig | 79 +- src/bun.js/api/bun/process.zig | 7 + src/bun.js/api/bun/spawn.zig | 134 +- src/bun.js/api/bun/subprocess.zig | 23 + src/bun.js/bindings/BunObject+exports.h | 1 + src/bun.js/bindings/BunObject.cpp | 1 + src/bun.js/bindings/bun-spawn.cpp | 206 ++- .../bindings/generated_classes_list.zig | 1 + test/js/bun/terminal/terminal.test.ts | 1172 +++++++++++++++++ 17 files changed, 2983 insertions(+), 36 deletions(-) create mode 100644 src/bun.js/api/Terminal.classes.ts create mode 100644 src/bun.js/api/bun/Terminal.zig create mode 100644 test/js/bun/terminal/terminal.test.ts 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"); + }); +});