mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
feat: add Bun.Terminal API for pseudo-terminal (PTY) support (#25415)
## Summary
This PR adds a new `Bun.Terminal` API for creating and managing
pseudo-terminals (PTYs), enabling interactive terminal applications in
Bun.
### Features
- **Standalone Terminal**: Create PTYs directly with `new
Bun.Terminal(options)`
- **Spawn Integration**: Spawn processes with PTY attached via
`Bun.spawn({ terminal: options })`
- **Full PTY Control**: Write data, resize, set raw mode, and handle
callbacks
## Examples
### Basic Terminal with Spawn (Recommended)
```typescript
const proc = Bun.spawn(["bash"], {
terminal: {
cols: 80,
rows: 24,
data(terminal, data) {
// Handle output from the terminal
process.stdout.write(data);
},
exit(terminal, code, signal) {
console.log(`Process exited with code ${code}`);
},
},
});
// Write commands to the terminal
proc.terminal.write("echo Hello from PTY!\n");
proc.terminal.write("exit\n");
await proc.exited;
proc.terminal.close();
```
### Interactive Shell
```typescript
// Create an interactive shell that mirrors to stdout
const proc = Bun.spawn(["bash", "-i"], {
terminal: {
cols: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
data(term, data) {
process.stdout.write(data);
},
},
});
// Forward stdin to the terminal
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
proc.terminal.write(chunk);
}
```
### Running Interactive Programs (vim, htop, etc.)
```typescript
const proc = Bun.spawn(["vim", "file.txt"], {
terminal: {
cols: process.stdout.columns,
rows: process.stdout.rows,
data(term, data) {
process.stdout.write(data);
},
},
});
// Handle terminal resize
process.stdout.on("resize", () => {
proc.terminal.resize(process.stdout.columns, process.stdout.rows);
});
// Forward input
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
proc.terminal.write(chunk);
}
```
### Capturing Colored Output
```typescript
const chunks: Uint8Array[] = [];
const proc = Bun.spawn(["ls", "--color=always"], {
terminal: {
data(term, data) {
chunks.push(data);
},
},
});
await proc.exited;
proc.terminal.close();
// Output includes ANSI color codes
const output = Buffer.concat(chunks).toString();
console.log(output);
```
### Standalone Terminal (Advanced)
```typescript
const terminal = new Bun.Terminal({
cols: 80,
rows: 24,
data(term, data) {
console.log("Received:", data.toString());
},
});
// Use terminal.stdin as the fd for child process stdio
const proc = Bun.spawn(["bash"], {
stdin: terminal.stdin,
stdout: terminal.stdin,
stderr: terminal.stdin,
});
terminal.write("echo hello\n");
// Clean up
terminal.close();
```
### Testing TTY Detection
```typescript
const proc = Bun.spawn([
"bun", "-e",
"console.log('isTTY:', process.stdout.isTTY)"
], {
terminal: {},
});
// Output: isTTY: true
```
## API
### `Bun.spawn()` with `terminal` option
```typescript
const proc = Bun.spawn(cmd, {
terminal: {
cols?: number, // Default: 80
rows?: number, // Default: 24
name?: string, // Default: "xterm-256color"
data?: (terminal: Terminal, data: Uint8Array) => void,
exit?: (terminal: Terminal, code: number, signal: string | null) => void,
drain?: (terminal: Terminal) => void,
}
});
// Access the terminal
proc.terminal.write(data);
proc.terminal.resize(cols, rows);
proc.terminal.setRawMode(enabled);
proc.terminal.close();
// Note: proc.stdin, proc.stdout, proc.stderr return null when terminal is used
```
### `new Bun.Terminal(options)`
```typescript
const terminal = new Bun.Terminal({
cols?: number,
rows?: number,
name?: string,
data?: (terminal, data) => void,
exit?: (terminal, code, signal) => void,
drain?: (terminal) => void,
});
terminal.stdin; // Slave fd (for child process)
terminal.stdout; // Master fd (for reading)
terminal.closed; // boolean
terminal.write(data);
terminal.resize(cols, rows);
terminal.setRawMode(enabled);
terminal.ref();
terminal.unref();
terminal.close();
await terminal[Symbol.asyncDispose]();
```
## Implementation Details
- Uses `openpty()` to create pseudo-terminal pairs
- Properly manages file descriptor lifecycle with reference counting
- Integrates with Bun's event loop via `BufferedReader` and
`StreamingWriter`
- Supports `await using` syntax for automatic cleanup
- POSIX only (Linux, macOS) - not available on Windows
## Test Results
- 80 tests passing
- Covers: construction, writing, reading, resize, raw mode, callbacks,
spawn integration, error handling, GC safety
## Changes
- `src/bun.js/api/bun/Terminal.zig` - Terminal implementation
- `src/bun.js/api/bun/Terminal.classes.ts` - Class definition for
codegen
- `src/bun.js/api/bun/subprocess.zig` - Added terminal field and getter
- `src/bun.js/api/bun/js_bun_spawn_bindings.zig` - Terminal option
parsing
- `src/bun.js/api/BunObject.classes.ts` - Terminal getter on Subprocess
- `packages/bun-types/bun.d.ts` - TypeScript types
- `docs/runtime/child-process.mdx` - Documentation
- `test/js/bun/terminal/terminal.test.ts` - Comprehensive tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
@@ -315,6 +315,109 @@ if (typeof Bun !== "undefined") {
|
||||
|
||||
---
|
||||
|
||||
## Terminal (PTY) support
|
||||
|
||||
For interactive terminal applications, you can spawn a subprocess with a pseudo-terminal (PTY) attached using the `terminal` option. This makes the subprocess think it's running in a real terminal, enabling features like colored output, cursor movement, and interactive prompts.
|
||||
|
||||
```ts
|
||||
const proc = Bun.spawn(["bash"], {
|
||||
terminal: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
data(terminal, data) {
|
||||
// Called when data is received from the terminal
|
||||
process.stdout.write(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Write to the terminal
|
||||
proc.terminal.write("echo hello\n");
|
||||
|
||||
// Wait for the process to exit
|
||||
await proc.exited;
|
||||
|
||||
// Close the terminal
|
||||
proc.terminal.close();
|
||||
```
|
||||
|
||||
When the `terminal` option is provided:
|
||||
|
||||
- The subprocess sees `process.stdout.isTTY` as `true`
|
||||
- `stdin`, `stdout`, and `stderr` are all connected to the terminal
|
||||
- `proc.stdin`, `proc.stdout`, and `proc.stderr` return `null` — use the terminal instead
|
||||
- Access the terminal via `proc.terminal`
|
||||
|
||||
### Terminal options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
||||
| `cols` | Number of columns | `80` |
|
||||
| `rows` | Number of rows | `24` |
|
||||
| `name` | Terminal type for PTY configuration (set `TERM` env var separately via `env` option) | `"xterm-256color"` |
|
||||
| `data` | Callback when data is received `(terminal, data) => void` | — |
|
||||
| `exit` | Callback when PTY stream closes (EOF or error). `exitCode` is PTY lifecycle status (0=EOF, 1=error), not subprocess exit code. Use `proc.exited` for process exit. | — |
|
||||
| `drain` | Callback when ready for more data `(terminal) => void` | — |
|
||||
|
||||
### Terminal methods
|
||||
|
||||
The `Terminal` object returned by `proc.terminal` has the following methods:
|
||||
|
||||
```ts
|
||||
// Write data to the terminal
|
||||
proc.terminal.write("echo hello\n");
|
||||
|
||||
// Resize the terminal
|
||||
proc.terminal.resize(120, 40);
|
||||
|
||||
// Set raw mode (disable line buffering and echo)
|
||||
proc.terminal.setRawMode(true);
|
||||
|
||||
// Keep event loop alive while terminal is open
|
||||
proc.terminal.ref();
|
||||
proc.terminal.unref();
|
||||
|
||||
// Close the terminal
|
||||
proc.terminal.close();
|
||||
```
|
||||
|
||||
### Reusable Terminal
|
||||
|
||||
You can create a terminal independently and reuse it across multiple subprocesses:
|
||||
|
||||
```ts
|
||||
await using terminal = new Bun.Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
data(term, data) {
|
||||
process.stdout.write(data);
|
||||
},
|
||||
});
|
||||
|
||||
// Spawn first process
|
||||
const proc1 = Bun.spawn(["echo", "first"], { terminal });
|
||||
await proc1.exited;
|
||||
|
||||
// Reuse terminal for another process
|
||||
const proc2 = Bun.spawn(["echo", "second"], { terminal });
|
||||
await proc2.exited;
|
||||
|
||||
// Terminal is closed automatically by `await using`
|
||||
```
|
||||
|
||||
When passing an existing `Terminal` object:
|
||||
|
||||
- The terminal can be reused across multiple spawns
|
||||
- You control when to close the terminal
|
||||
- The `exit` callback fires when you call `terminal.close()`, not when each subprocess exits
|
||||
- Use `proc.exited` to detect individual subprocess exits
|
||||
|
||||
This is useful for running multiple commands in sequence through the same terminal session.
|
||||
|
||||
<Note>Terminal support is only available on POSIX systems (Linux, macOS). It is not available on Windows.</Note>
|
||||
|
||||
---
|
||||
|
||||
## Blocking API (`Bun.spawnSync()`)
|
||||
|
||||
Bun provides a synchronous equivalent of `Bun.spawn` called `Bun.spawnSync`. This is a blocking API that supports the same inputs and parameters as `Bun.spawn`. It returns a `SyncSubprocess` object, which differs from `Subprocess` in a few ways.
|
||||
@@ -407,6 +510,7 @@ namespace SpawnOptions {
|
||||
timeout?: number;
|
||||
killSignal?: string | number;
|
||||
maxBuffer?: number;
|
||||
terminal?: TerminalOptions; // PTY support (POSIX only)
|
||||
}
|
||||
|
||||
type Readable =
|
||||
@@ -435,10 +539,11 @@ namespace SpawnOptions {
|
||||
}
|
||||
|
||||
interface Subprocess extends AsyncDisposable {
|
||||
readonly stdin: FileSink | number | undefined;
|
||||
readonly stdout: ReadableStream<Uint8Array> | number | undefined;
|
||||
readonly stderr: ReadableStream<Uint8Array> | number | undefined;
|
||||
readonly readable: ReadableStream<Uint8Array> | number | undefined;
|
||||
readonly stdin: FileSink | number | undefined | null;
|
||||
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||
readonly readable: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||
readonly terminal: Terminal | undefined;
|
||||
readonly pid: number;
|
||||
readonly exited: Promise<number>;
|
||||
readonly exitCode: number | null;
|
||||
@@ -465,6 +570,28 @@ interface SyncSubprocess {
|
||||
pid: number;
|
||||
}
|
||||
|
||||
interface TerminalOptions {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
name?: string;
|
||||
data?: (terminal: Terminal, data: Uint8Array<ArrayBuffer>) => void;
|
||||
/** Called when PTY stream closes (EOF or error). exitCode is PTY lifecycle status (0=EOF, 1=error), not subprocess exit code. */
|
||||
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
|
||||
drain?: (terminal: Terminal) => void;
|
||||
}
|
||||
|
||||
interface Terminal extends AsyncDisposable {
|
||||
readonly stdin: number;
|
||||
readonly stdout: number;
|
||||
readonly closed: boolean;
|
||||
write(data: string | BufferSource): number;
|
||||
resize(cols: number, rows: number): void;
|
||||
setRawMode(enabled: boolean): void;
|
||||
ref(): void;
|
||||
unref(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface ResourceUsage {
|
||||
contextSwitches: {
|
||||
voluntary: number;
|
||||
|
||||
204
packages/bun-types/bun.d.ts
vendored
204
packages/bun-types/bun.d.ts
vendored
@@ -5697,6 +5697,44 @@ declare module "bun" {
|
||||
* ```
|
||||
*/
|
||||
lazy?: boolean;
|
||||
|
||||
/**
|
||||
* Spawn the subprocess with a pseudo-terminal (PTY) attached.
|
||||
*
|
||||
* When this option is provided:
|
||||
* - `stdin`, `stdout`, and `stderr` are all connected to the terminal
|
||||
* - The subprocess sees itself running in a real terminal (`isTTY = true`)
|
||||
* - Access the terminal via `subprocess.terminal`
|
||||
* - `subprocess.stdin`, `subprocess.stdout`, `subprocess.stderr` return `null`
|
||||
*
|
||||
* Only available on POSIX systems (Linux, macOS).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const proc = Bun.spawn(["bash"], {
|
||||
* terminal: {
|
||||
* cols: 80,
|
||||
* rows: 24,
|
||||
* data: (term, data) => console.log(data.toString()),
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* proc.terminal.write("echo hello\n");
|
||||
* await proc.exited;
|
||||
* proc.terminal.close();
|
||||
* ```
|
||||
*
|
||||
* You can also pass an existing `Terminal` object for reuse across multiple spawns:
|
||||
* ```ts
|
||||
* const terminal = new Bun.Terminal({ ... });
|
||||
* const proc1 = Bun.spawn(["echo", "first"], { terminal });
|
||||
* await proc1.exited;
|
||||
* const proc2 = Bun.spawn(["echo", "second"], { terminal });
|
||||
* await proc2.exited;
|
||||
* terminal.close();
|
||||
* ```
|
||||
*/
|
||||
terminal?: TerminalOptions | Terminal;
|
||||
}
|
||||
|
||||
type ReadableToIO<X extends Readable> = X extends "pipe" | undefined
|
||||
@@ -5811,6 +5849,24 @@ declare module "bun" {
|
||||
readonly stdout: SpawnOptions.ReadableToIO<Out>;
|
||||
readonly stderr: SpawnOptions.ReadableToIO<Err>;
|
||||
|
||||
/**
|
||||
* The terminal attached to this subprocess, if spawned with the `terminal` option.
|
||||
* Returns `undefined` if no terminal was attached.
|
||||
*
|
||||
* When a terminal is attached, `stdin`, `stdout`, and `stderr` return `null`.
|
||||
* Use `terminal.write()` and the `data` callback instead.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const proc = Bun.spawn(["bash"], {
|
||||
* terminal: { data: (term, data) => console.log(data.toString()) },
|
||||
* });
|
||||
*
|
||||
* proc.terminal?.write("echo hello\n");
|
||||
* ```
|
||||
*/
|
||||
readonly terminal: Terminal | undefined;
|
||||
|
||||
/**
|
||||
* Access extra file descriptors passed to the `stdio` option in the options object.
|
||||
*/
|
||||
@@ -6102,6 +6158,154 @@ declare module "bun" {
|
||||
"ignore" | "inherit" | null | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Options for creating a pseudo-terminal (PTY).
|
||||
*/
|
||||
interface TerminalOptions {
|
||||
/**
|
||||
* Number of columns for the terminal.
|
||||
* @default 80
|
||||
*/
|
||||
cols?: number;
|
||||
/**
|
||||
* Number of rows for the terminal.
|
||||
* @default 24
|
||||
*/
|
||||
rows?: number;
|
||||
/**
|
||||
* Terminal name (e.g., "xterm-256color").
|
||||
* @default "xterm-256color"
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Callback invoked when data is received from the terminal.
|
||||
* @param terminal The terminal instance
|
||||
* @param data The data received as a Uint8Array
|
||||
*/
|
||||
data?: (terminal: Terminal, data: Uint8Array<ArrayBuffer>) => void;
|
||||
/**
|
||||
* Callback invoked when the PTY stream closes (EOF or read error).
|
||||
* Note: exitCode is a PTY lifecycle status (0=clean EOF, 1=error), NOT the subprocess exit code.
|
||||
* Use Subprocess.exited or onExit callback for actual process exit information.
|
||||
* @param terminal The terminal instance
|
||||
* @param exitCode PTY lifecycle status (0 for EOF, 1 for error)
|
||||
* @param signal Reserved for future signal reporting, currently null
|
||||
*/
|
||||
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
|
||||
/**
|
||||
* Callback invoked when the terminal is ready to receive more data.
|
||||
* @param terminal The terminal instance
|
||||
*/
|
||||
drain?: (terminal: Terminal) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pseudo-terminal (PTY) that can be used to spawn interactive terminal programs.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await using terminal = new Bun.Terminal({
|
||||
* cols: 80,
|
||||
* rows: 24,
|
||||
* data(term, data) {
|
||||
* console.log("Received:", new TextDecoder().decode(data));
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Spawn a shell connected to the PTY
|
||||
* const proc = Bun.spawn(["bash"], { terminal });
|
||||
*
|
||||
* // Write to the terminal
|
||||
* terminal.write("echo hello\n");
|
||||
*
|
||||
* // Wait for process to exit
|
||||
* await proc.exited;
|
||||
*
|
||||
* // Terminal is closed automatically by `await using`
|
||||
* ```
|
||||
*/
|
||||
class Terminal implements AsyncDisposable {
|
||||
constructor(options: TerminalOptions);
|
||||
|
||||
/**
|
||||
* Whether the terminal is closed.
|
||||
*/
|
||||
readonly closed: boolean;
|
||||
|
||||
/**
|
||||
* Write data to the terminal.
|
||||
* @param data The data to write (string or BufferSource)
|
||||
* @returns The number of bytes written
|
||||
*/
|
||||
write(data: string | BufferSource): number;
|
||||
|
||||
/**
|
||||
* Resize the terminal.
|
||||
* @param cols New number of columns
|
||||
* @param rows New number of rows
|
||||
*/
|
||||
resize(cols: number, rows: number): void;
|
||||
|
||||
/**
|
||||
* Set raw mode on the terminal.
|
||||
* In raw mode, input is passed directly without processing.
|
||||
* @param enabled Whether to enable raw mode
|
||||
*/
|
||||
setRawMode(enabled: boolean): void;
|
||||
|
||||
/**
|
||||
* Reference the terminal to keep the event loop alive.
|
||||
*/
|
||||
ref(): void;
|
||||
|
||||
/**
|
||||
* Unreference the terminal to allow the event loop to exit.
|
||||
*/
|
||||
unref(): void;
|
||||
|
||||
/**
|
||||
* Close the terminal.
|
||||
*/
|
||||
close(): void;
|
||||
|
||||
/**
|
||||
* Async dispose for use with `await using`.
|
||||
*/
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
|
||||
/**
|
||||
* Terminal input flags (c_iflag from termios).
|
||||
* Controls input processing behavior like ICRNL, IXON, etc.
|
||||
* Returns 0 if terminal is closed.
|
||||
* Setting returns true on success, false on failure.
|
||||
*/
|
||||
inputFlags: number;
|
||||
|
||||
/**
|
||||
* Terminal output flags (c_oflag from termios).
|
||||
* Controls output processing behavior like OPOST, ONLCR, etc.
|
||||
* Returns 0 if terminal is closed.
|
||||
* Setting returns true on success, false on failure.
|
||||
*/
|
||||
outputFlags: number;
|
||||
|
||||
/**
|
||||
* Terminal local flags (c_lflag from termios).
|
||||
* Controls local processing like ICANON, ECHO, ISIG, etc.
|
||||
* Returns 0 if terminal is closed.
|
||||
* Setting returns true on success, false on failure.
|
||||
*/
|
||||
localFlags: number;
|
||||
|
||||
/**
|
||||
* Terminal control flags (c_cflag from termios).
|
||||
* Controls hardware characteristics like CSIZE, PARENB, etc.
|
||||
* Returns 0 if terminal is closed.
|
||||
* Setting returns true on success, false on failure.
|
||||
*/
|
||||
controlFlags: number;
|
||||
}
|
||||
|
||||
// Blocked on https://github.com/oven-sh/bun/issues/8329
|
||||
// /**
|
||||
// *
|
||||
|
||||
@@ -150,6 +150,7 @@ pub const FilePoll = struct {
|
||||
const StaticPipeWriter = Subprocess.StaticPipeWriter.Poll;
|
||||
const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll;
|
||||
const FileSink = jsc.WebCore.FileSink.Poll;
|
||||
const TerminalPoll = bun.api.Terminal.Poll;
|
||||
const DNSResolver = bun.api.dns.Resolver;
|
||||
const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest;
|
||||
const Request = bun.api.dns.internal.Request;
|
||||
@@ -181,6 +182,7 @@ pub const FilePoll = struct {
|
||||
// LifecycleScriptSubprocessOutputReader,
|
||||
Process,
|
||||
ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop
|
||||
TerminalPoll,
|
||||
});
|
||||
|
||||
pub const AllocatorType = enum {
|
||||
@@ -414,6 +416,12 @@ pub const FilePoll = struct {
|
||||
Request.MacAsyncDNS.onMachportChange(loader);
|
||||
},
|
||||
|
||||
@field(Owner.Tag, @typeName(TerminalPoll)) => {
|
||||
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) Terminal", .{poll.fd});
|
||||
var handler: *TerminalPoll = ptr.as(TerminalPoll);
|
||||
handler.onPoll(size_or_offset, poll.flags.contains(.hup));
|
||||
},
|
||||
|
||||
else => {
|
||||
const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag()));
|
||||
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) disconnected? (maybe: {s})", .{ poll.fd, possible_name orelse "<unknown>" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -121,6 +121,10 @@ export default [
|
||||
stdio: {
|
||||
getter: "getStdio",
|
||||
},
|
||||
terminal: {
|
||||
getter: "getTerminal",
|
||||
cache: true,
|
||||
},
|
||||
},
|
||||
values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
64
src/bun.js/api/Terminal.classes.ts
Normal file
64
src/bun.js/api/Terminal.classes.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { define } from "../../codegen/class-definitions";
|
||||
|
||||
export default [
|
||||
define({
|
||||
name: "Terminal",
|
||||
construct: true,
|
||||
constructNeedsThis: true,
|
||||
finalize: true,
|
||||
configurable: false,
|
||||
klass: {},
|
||||
JSType: "0b11101110",
|
||||
// Store callback references - prevents them from being GC'd while terminal is alive
|
||||
values: ["data", "drain", "exit"],
|
||||
proto: {
|
||||
write: {
|
||||
fn: "write",
|
||||
length: 1,
|
||||
},
|
||||
resize: {
|
||||
fn: "resize",
|
||||
length: 2,
|
||||
},
|
||||
setRawMode: {
|
||||
fn: "setRawMode",
|
||||
length: 1,
|
||||
},
|
||||
ref: {
|
||||
fn: "doRef",
|
||||
length: 0,
|
||||
},
|
||||
unref: {
|
||||
fn: "doUnref",
|
||||
length: 0,
|
||||
},
|
||||
close: {
|
||||
fn: "close",
|
||||
length: 0,
|
||||
},
|
||||
"@@asyncDispose": {
|
||||
fn: "asyncDispose",
|
||||
length: 0,
|
||||
},
|
||||
closed: {
|
||||
getter: "getClosed",
|
||||
},
|
||||
inputFlags: {
|
||||
getter: "getInputFlags",
|
||||
setter: "setInputFlags",
|
||||
},
|
||||
outputFlags: {
|
||||
getter: "getOutputFlags",
|
||||
setter: "setOutputFlags",
|
||||
},
|
||||
localFlags: {
|
||||
getter: "getLocalFlags",
|
||||
setter: "setLocalFlags",
|
||||
},
|
||||
controlFlags: {
|
||||
getter: "getControlFlags",
|
||||
setter: "setControlFlags",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
973
src/bun.js/api/bun/Terminal.zig
Normal file
973
src/bun.js/api/bun/Terminal.zig
Normal file
@@ -0,0 +1,973 @@
|
||||
//! Bun.Terminal - Creates a pseudo-terminal (PTY) for interactive terminal sessions.
|
||||
//!
|
||||
//! This module provides a Terminal class that creates a PTY master/slave pair,
|
||||
//! allowing JavaScript code to interact with terminal-based programs.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! - Starts with weak JSRef (allows GC if user doesn't hold reference)
|
||||
//! - Upgrades to strong when actively reading/writing
|
||||
//! - Downgrades to weak on EOF from master_fd
|
||||
//! - Callbacks are stored via `values` in classes.ts, accessed via js.gc
|
||||
|
||||
const Terminal = @This();
|
||||
|
||||
const log = bun.Output.scoped(.Terminal, .hidden);
|
||||
|
||||
// Generated bindings
|
||||
pub const js = jsc.Codegen.JSTerminal;
|
||||
pub const toJS = js.toJS;
|
||||
pub const fromJS = js.fromJS;
|
||||
pub const fromJSDirect = js.fromJSDirect;
|
||||
|
||||
// Reference counting for Terminal
|
||||
// Refs are held by:
|
||||
// 1. JS side (released in finalize)
|
||||
// 2. Reader (released in onReaderDone/onReaderError)
|
||||
// 3. Writer (released in onWriterClose)
|
||||
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
||||
pub const ref = RefCount.ref;
|
||||
pub const deref = RefCount.deref;
|
||||
|
||||
ref_count: RefCount,
|
||||
|
||||
/// The master side of the PTY (original fd, used for ioctl operations)
|
||||
master_fd: bun.FileDescriptor,
|
||||
|
||||
/// Duplicated master fd for reading
|
||||
read_fd: bun.FileDescriptor,
|
||||
|
||||
/// Duplicated master fd for writing
|
||||
write_fd: bun.FileDescriptor,
|
||||
|
||||
/// The slave side of the PTY (used by child processes)
|
||||
slave_fd: bun.FileDescriptor,
|
||||
|
||||
/// Current terminal size
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
|
||||
/// Terminal name (e.g., "xterm-256color")
|
||||
term_name: jsc.ZigString.Slice,
|
||||
|
||||
/// Event loop handle for callbacks
|
||||
event_loop_handle: jsc.EventLoopHandle,
|
||||
|
||||
/// Global object reference
|
||||
globalThis: *jsc.JSGlobalObject,
|
||||
|
||||
/// Writer for sending data to the terminal
|
||||
writer: IOWriter = .{},
|
||||
|
||||
/// Reader for receiving data from the terminal
|
||||
reader: IOReader = IOReader.init(@This()),
|
||||
|
||||
/// This value reference for GC tracking
|
||||
/// - weak: allows GC when idle
|
||||
/// - strong: prevents GC when actively connected
|
||||
this_value: jsc.JSRef = jsc.JSRef.empty(),
|
||||
|
||||
/// State flags
|
||||
flags: Flags = .{},
|
||||
|
||||
pub const Flags = packed struct(u8) {
|
||||
closed: bool = false,
|
||||
finalized: bool = false,
|
||||
raw_mode: bool = false,
|
||||
reader_started: bool = false,
|
||||
connected: bool = false,
|
||||
reader_done: bool = false,
|
||||
writer_done: bool = false,
|
||||
_: u1 = 0,
|
||||
};
|
||||
|
||||
pub const IOWriter = bun.io.StreamingWriter(@This(), struct {
|
||||
pub const onClose = Terminal.onWriterClose;
|
||||
pub const onWritable = Terminal.onWriterReady;
|
||||
pub const onError = Terminal.onWriterError;
|
||||
pub const onWrite = Terminal.onWrite;
|
||||
});
|
||||
|
||||
/// Poll type alias for FilePoll Owner registration
|
||||
pub const Poll = IOWriter;
|
||||
|
||||
pub const IOReader = bun.io.BufferedReader;
|
||||
|
||||
/// Options for creating a Terminal
|
||||
pub const Options = struct {
|
||||
cols: u16 = 80,
|
||||
rows: u16 = 24,
|
||||
term_name: jsc.ZigString.Slice = .{},
|
||||
data_callback: ?JSValue = null,
|
||||
exit_callback: ?JSValue = null,
|
||||
drain_callback: ?JSValue = null,
|
||||
|
||||
/// Maximum length for terminal name (e.g., "xterm-256color")
|
||||
/// Longest known terminfo names are ~23 chars; 128 allows for custom terminals
|
||||
pub const max_term_name_len = 128;
|
||||
|
||||
/// Parse terminal options from a JS object
|
||||
pub fn parseFromJS(globalObject: *jsc.JSGlobalObject, js_options: JSValue) bun.JSError!Options {
|
||||
var options = Options{};
|
||||
|
||||
if (try js_options.getOptional(globalObject, "cols", i32)) |n| {
|
||||
if (n > 0 and n <= 65535) options.cols = @intCast(n);
|
||||
}
|
||||
|
||||
if (try js_options.getOptional(globalObject, "rows", i32)) |n| {
|
||||
if (n > 0 and n <= 65535) options.rows = @intCast(n);
|
||||
}
|
||||
|
||||
if (try js_options.getOptional(globalObject, "name", jsc.ZigString.Slice)) |slice| {
|
||||
if (slice.len > max_term_name_len) {
|
||||
slice.deinit();
|
||||
return globalObject.throw("Terminal name too long (max {d} characters)", .{max_term_name_len});
|
||||
}
|
||||
options.term_name = slice;
|
||||
}
|
||||
|
||||
if (try js_options.getOptional(globalObject, "data", JSValue)) |v| {
|
||||
if (v.isCell() and v.isCallable()) {
|
||||
options.data_callback = v.withAsyncContextIfNeeded(globalObject);
|
||||
}
|
||||
}
|
||||
|
||||
if (try js_options.getOptional(globalObject, "exit", JSValue)) |v| {
|
||||
if (v.isCell() and v.isCallable()) {
|
||||
options.exit_callback = v.withAsyncContextIfNeeded(globalObject);
|
||||
}
|
||||
}
|
||||
|
||||
if (try js_options.getOptional(globalObject, "drain", JSValue)) |v| {
|
||||
if (v.isCell() and v.isCallable()) {
|
||||
options.drain_callback = v.withAsyncContextIfNeeded(globalObject);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Options) void {
|
||||
this.term_name.deinit();
|
||||
this.* = .{};
|
||||
}
|
||||
};
|
||||
|
||||
/// Result from creating a Terminal
|
||||
pub const CreateResult = struct {
|
||||
terminal: *Terminal,
|
||||
js_value: jsc.JSValue,
|
||||
};
|
||||
|
||||
const InitError = CreatePtyError || error{ WriterStartFailed, ReaderStartFailed };
|
||||
|
||||
/// Internal initialization - shared by constructor and createFromSpawn
|
||||
fn initTerminal(
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
options: Options,
|
||||
/// If provided, use this JSValue; otherwise create one via toJS
|
||||
existing_js_value: ?jsc.JSValue,
|
||||
) InitError!CreateResult {
|
||||
// Create PTY
|
||||
const pty_result = try createPty(options.cols, options.rows);
|
||||
|
||||
// Use default term name if empty
|
||||
const term_name = if (options.term_name.len > 0)
|
||||
options.term_name
|
||||
else
|
||||
jsc.ZigString.Slice.fromUTF8NeverFree("xterm-256color");
|
||||
|
||||
const terminal = bun.new(Terminal, .{
|
||||
.ref_count = .init(),
|
||||
.master_fd = pty_result.master,
|
||||
.read_fd = pty_result.read_fd,
|
||||
.write_fd = pty_result.write_fd,
|
||||
.slave_fd = pty_result.slave,
|
||||
.cols = options.cols,
|
||||
.rows = options.rows,
|
||||
.term_name = term_name,
|
||||
.event_loop_handle = jsc.EventLoopHandle.init(globalObject.bunVM().eventLoop()),
|
||||
.globalThis = globalObject,
|
||||
});
|
||||
|
||||
// Set reader parent
|
||||
terminal.reader.setParent(terminal);
|
||||
|
||||
// Set writer parent
|
||||
terminal.writer.parent = terminal;
|
||||
|
||||
// Start writer with the write fd - adds a ref
|
||||
switch (terminal.writer.start(pty_result.write_fd, true)) {
|
||||
.result => terminal.ref(),
|
||||
.err => return error.WriterStartFailed,
|
||||
}
|
||||
|
||||
// Start reader with the read fd - adds a ref
|
||||
switch (terminal.reader.start(pty_result.read_fd, true)) {
|
||||
.err => {
|
||||
// Reader never started but writer was started
|
||||
// Close writer (will trigger onWriterDone -> deref for writer's ref)
|
||||
terminal.writer.close();
|
||||
return error.ReaderStartFailed;
|
||||
},
|
||||
.result => {
|
||||
terminal.ref();
|
||||
if (comptime Environment.isPosix) {
|
||||
if (terminal.reader.handle == .poll) {
|
||||
const poll = terminal.reader.handle.poll;
|
||||
// PTY behaves like a pipe, not a socket
|
||||
terminal.reader.flags.nonblocking = true;
|
||||
terminal.reader.flags.pollable = true;
|
||||
poll.flags.insert(.nonblocking);
|
||||
}
|
||||
}
|
||||
terminal.flags.reader_started = true;
|
||||
},
|
||||
}
|
||||
|
||||
// Start reading data
|
||||
terminal.reader.read();
|
||||
|
||||
// Get or create the JS wrapper
|
||||
const this_value = existing_js_value orelse terminal.toJS(globalObject);
|
||||
|
||||
// Store the this_value (JSValue wrapper) - start with strong ref since we're actively reading
|
||||
// This is the JS side ref (released in finalize)
|
||||
terminal.this_value = jsc.JSRef.initStrong(this_value, globalObject);
|
||||
terminal.ref();
|
||||
|
||||
// Store callbacks via generated gc setters (prevents GC of callbacks while terminal is alive)
|
||||
if (options.data_callback) |cb| {
|
||||
if (cb.isCell() and cb.isCallable()) {
|
||||
js.gc.set(.data, this_value, globalObject, cb);
|
||||
}
|
||||
}
|
||||
if (options.exit_callback) |cb| {
|
||||
if (cb.isCell() and cb.isCallable()) {
|
||||
js.gc.set(.exit, this_value, globalObject, cb);
|
||||
}
|
||||
}
|
||||
if (options.drain_callback) |cb| {
|
||||
if (cb.isCell() and cb.isCallable()) {
|
||||
js.gc.set(.drain, this_value, globalObject, cb);
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .terminal = terminal, .js_value = this_value };
|
||||
}
|
||||
|
||||
/// Constructor for Terminal - called from JavaScript
|
||||
/// With constructNeedsThis: true, we receive the JSValue wrapper directly
|
||||
pub fn constructor(
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
this_value: jsc.JSValue,
|
||||
) bun.JSError!*Terminal {
|
||||
const args = callframe.argumentsAsArray(1);
|
||||
const js_options = args[0];
|
||||
|
||||
if (js_options.isUndefinedOrNull()) {
|
||||
return globalObject.throw("Terminal constructor requires an options object", .{});
|
||||
}
|
||||
|
||||
var options = try Options.parseFromJS(globalObject, js_options);
|
||||
|
||||
const result = initTerminal(globalObject, options, this_value) catch |err| {
|
||||
options.deinit();
|
||||
return switch (err) {
|
||||
error.OpenPtyFailed => globalObject.throw("Failed to open PTY", .{}),
|
||||
error.DupFailed => globalObject.throw("Failed to duplicate PTY file descriptor", .{}),
|
||||
error.NotSupported => globalObject.throw("PTY not supported on this platform", .{}),
|
||||
error.WriterStartFailed => globalObject.throw("Failed to start terminal writer", .{}),
|
||||
error.ReaderStartFailed => globalObject.throw("Failed to start terminal reader", .{}),
|
||||
};
|
||||
};
|
||||
|
||||
return result.terminal;
|
||||
}
|
||||
|
||||
/// Create a Terminal from Bun.spawn options (not from JS constructor)
|
||||
/// Returns the Terminal and its JS wrapper value
|
||||
/// The slave_fd should be used for the subprocess's stdin/stdout/stderr
|
||||
pub fn createFromSpawn(
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
options: Options,
|
||||
) InitError!CreateResult {
|
||||
return initTerminal(globalObject, options, null);
|
||||
}
|
||||
|
||||
/// Get the slave fd for subprocess to use
|
||||
pub fn getSlaveFd(this: *Terminal) bun.FileDescriptor {
|
||||
return this.slave_fd;
|
||||
}
|
||||
|
||||
/// Close the parent's copy of slave_fd after fork
|
||||
/// The child process has its own copy - closing the parent's ensures
|
||||
/// EOF is received on the master side when the child exits
|
||||
pub fn closeSlaveFd(this: *Terminal) void {
|
||||
if (this.slave_fd != bun.invalid_fd) {
|
||||
this.slave_fd.close();
|
||||
this.slave_fd = bun.invalid_fd;
|
||||
}
|
||||
}
|
||||
|
||||
const PtyResult = struct {
|
||||
master: bun.FileDescriptor,
|
||||
read_fd: bun.FileDescriptor,
|
||||
write_fd: bun.FileDescriptor,
|
||||
slave: bun.FileDescriptor,
|
||||
};
|
||||
|
||||
const CreatePtyError = error{ OpenPtyFailed, DupFailed, NotSupported };
|
||||
|
||||
fn createPty(cols: u16, rows: u16) CreatePtyError!PtyResult {
|
||||
if (comptime Environment.isPosix) {
|
||||
return createPtyPosix(cols, rows);
|
||||
} else {
|
||||
// Windows PTY support would go here
|
||||
return error.NotSupported;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenPtyTermios is required for the openpty() extern signature even though we pass null.
|
||||
// Kept for type correctness of the C function declaration.
|
||||
const OpenPtyTermios = extern struct {
|
||||
c_iflag: u32,
|
||||
c_oflag: u32,
|
||||
c_cflag: u32,
|
||||
c_lflag: u32,
|
||||
c_cc: [20]u8,
|
||||
c_ispeed: u32,
|
||||
c_ospeed: u32,
|
||||
};
|
||||
|
||||
const Winsize = extern struct {
|
||||
ws_row: u16,
|
||||
ws_col: u16,
|
||||
ws_xpixel: u16,
|
||||
ws_ypixel: u16,
|
||||
};
|
||||
|
||||
const OpenPtyFn = *const fn (
|
||||
amaster: *c_int,
|
||||
aslave: *c_int,
|
||||
name: ?[*]u8,
|
||||
termp: ?*const OpenPtyTermios,
|
||||
winp: ?*const Winsize,
|
||||
) callconv(.c) c_int;
|
||||
|
||||
/// Dynamic loading of openpty on Linux (it's in libutil which may not be linked)
|
||||
const LibUtil = struct {
|
||||
var handle: ?*anyopaque = null;
|
||||
var loaded: bool = false;
|
||||
|
||||
pub fn getHandle() ?*anyopaque {
|
||||
if (loaded) return handle;
|
||||
loaded = true;
|
||||
|
||||
// Try libutil.so first (most common), then libutil.so.1
|
||||
const lib_names = [_][:0]const u8{ "libutil.so", "libutil.so.1", "libc.so.6" };
|
||||
for (lib_names) |lib_name| {
|
||||
handle = bun.sys.dlopen(lib_name, .{ .LAZY = true });
|
||||
if (handle != null) return handle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getOpenPty() ?OpenPtyFn {
|
||||
return bun.sys.dlsymWithHandle(OpenPtyFn, "openpty", getHandle);
|
||||
}
|
||||
};
|
||||
|
||||
fn getOpenPtyFn() ?OpenPtyFn {
|
||||
// On macOS, openpty is in libc, so we can use it directly
|
||||
if (comptime Environment.isMac) {
|
||||
const c = struct {
|
||||
extern "c" fn openpty(
|
||||
amaster: *c_int,
|
||||
aslave: *c_int,
|
||||
name: ?[*]u8,
|
||||
termp: ?*const OpenPtyTermios,
|
||||
winp: ?*const Winsize,
|
||||
) c_int;
|
||||
};
|
||||
return &c.openpty;
|
||||
}
|
||||
|
||||
// On Linux, openpty is in libutil, which may not be linked
|
||||
// Load it dynamically via dlopen
|
||||
if (comptime Environment.isLinux) {
|
||||
return LibUtil.getOpenPty();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn createPtyPosix(cols: u16, rows: u16) CreatePtyError!PtyResult {
|
||||
const openpty_fn = getOpenPtyFn() orelse {
|
||||
return error.NotSupported;
|
||||
};
|
||||
|
||||
var master_fd: c_int = -1;
|
||||
var slave_fd: c_int = -1;
|
||||
|
||||
const winsize = Winsize{
|
||||
.ws_row = rows,
|
||||
.ws_col = cols,
|
||||
.ws_xpixel = 0,
|
||||
.ws_ypixel = 0,
|
||||
};
|
||||
|
||||
const result = openpty_fn(&master_fd, &slave_fd, null, null, &winsize);
|
||||
if (result != 0) {
|
||||
return error.OpenPtyFailed;
|
||||
}
|
||||
|
||||
const master_fd_desc = bun.FD.fromNative(master_fd);
|
||||
const slave_fd_desc = bun.FD.fromNative(slave_fd);
|
||||
|
||||
// Configure sensible terminal defaults matching node-pty behavior.
|
||||
// These are "cooked mode" defaults that most terminal applications expect.
|
||||
if (std.posix.tcgetattr(slave_fd)) |termios| {
|
||||
var t = termios;
|
||||
|
||||
// Input flags: standard terminal input processing
|
||||
t.iflag = .{
|
||||
.ICRNL = true, // Map CR to NL on input
|
||||
.IXON = true, // Enable XON/XOFF flow control on output
|
||||
.IXANY = true, // Any character restarts output
|
||||
.IMAXBEL = true, // Ring bell on input queue full
|
||||
.BRKINT = true, // Signal interrupt on break
|
||||
.IUTF8 = true, // Input is UTF-8
|
||||
};
|
||||
|
||||
// Output flags: standard terminal output processing
|
||||
t.oflag = .{
|
||||
.OPOST = true, // Enable output processing
|
||||
.ONLCR = true, // Map NL to CR-NL on output
|
||||
};
|
||||
|
||||
// Control flags: 8-bit chars, enable receiver
|
||||
t.cflag = .{
|
||||
.CREAD = true, // Enable receiver
|
||||
.CSIZE = .CS8, // 8-bit characters
|
||||
.HUPCL = true, // Hang up on last close
|
||||
};
|
||||
|
||||
// Local flags: canonical mode with echo and signals
|
||||
t.lflag = .{
|
||||
.ICANON = true, // Canonical input (line editing)
|
||||
.ISIG = true, // Enable signals (INTR, QUIT, SUSP)
|
||||
.IEXTEN = true, // Enable extended input processing
|
||||
.ECHO = true, // Echo input characters
|
||||
.ECHOE = true, // Echo erase as backspace-space-backspace
|
||||
.ECHOK = true, // Echo NL after KILL
|
||||
.ECHOKE = true, // Visual erase for KILL
|
||||
.ECHOCTL = true, // Echo control chars as ^X
|
||||
};
|
||||
|
||||
// Control characters - standard defaults
|
||||
t.cc[@intFromEnum(std.posix.V.EOF)] = 4; // Ctrl-D
|
||||
t.cc[@intFromEnum(std.posix.V.EOL)] = 0; // Disabled
|
||||
t.cc[@intFromEnum(std.posix.V.ERASE)] = 0x7f; // DEL (backspace)
|
||||
t.cc[@intFromEnum(std.posix.V.WERASE)] = 23; // Ctrl-W
|
||||
t.cc[@intFromEnum(std.posix.V.KILL)] = 21; // Ctrl-U
|
||||
t.cc[@intFromEnum(std.posix.V.REPRINT)] = 18; // Ctrl-R
|
||||
t.cc[@intFromEnum(std.posix.V.INTR)] = 3; // Ctrl-C
|
||||
t.cc[@intFromEnum(std.posix.V.QUIT)] = 0x1c; // Ctrl-backslash
|
||||
t.cc[@intFromEnum(std.posix.V.SUSP)] = 26; // Ctrl-Z
|
||||
t.cc[@intFromEnum(std.posix.V.START)] = 17; // Ctrl-Q (XON)
|
||||
t.cc[@intFromEnum(std.posix.V.STOP)] = 19; // Ctrl-S (XOFF)
|
||||
t.cc[@intFromEnum(std.posix.V.LNEXT)] = 22; // Ctrl-V
|
||||
t.cc[@intFromEnum(std.posix.V.DISCARD)] = 15; // Ctrl-O
|
||||
t.cc[@intFromEnum(std.posix.V.MIN)] = 1; // Min chars for non-canonical read
|
||||
t.cc[@intFromEnum(std.posix.V.TIME)] = 0; // Timeout for non-canonical read
|
||||
|
||||
// Set baud rate to 38400 (standard for PTYs)
|
||||
t.ispeed = .B38400;
|
||||
t.ospeed = .B38400;
|
||||
|
||||
std.posix.tcsetattr(slave_fd, .NOW, t) catch {};
|
||||
} else |err| {
|
||||
// tcgetattr failed, log in debug builds but continue without modifying termios
|
||||
if (comptime bun.Environment.allow_assert) {
|
||||
bun.sys.syslog("tcgetattr(slave_fd={d}) failed: {s}", .{ slave_fd, @errorName(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate the master fd for reading and writing separately
|
||||
// This allows independent epoll registration and closing
|
||||
const read_fd = switch (bun.sys.dup(master_fd_desc)) {
|
||||
.result => |fd| fd,
|
||||
.err => {
|
||||
master_fd_desc.close();
|
||||
slave_fd_desc.close();
|
||||
return error.DupFailed;
|
||||
},
|
||||
};
|
||||
|
||||
const write_fd = switch (bun.sys.dup(master_fd_desc)) {
|
||||
.result => |fd| fd,
|
||||
.err => {
|
||||
master_fd_desc.close();
|
||||
slave_fd_desc.close();
|
||||
read_fd.close();
|
||||
return error.DupFailed;
|
||||
},
|
||||
};
|
||||
|
||||
// Set non-blocking on master side fds (for async I/O in the event loop)
|
||||
_ = bun.sys.updateNonblocking(master_fd_desc, true);
|
||||
_ = bun.sys.updateNonblocking(read_fd, true);
|
||||
_ = bun.sys.updateNonblocking(write_fd, true);
|
||||
// Note: slave_fd stays blocking - child processes expect blocking I/O
|
||||
|
||||
// Set close-on-exec on master side fds only
|
||||
// slave_fd should NOT have close-on-exec since child needs to inherit it
|
||||
_ = bun.sys.setCloseOnExec(master_fd_desc);
|
||||
_ = bun.sys.setCloseOnExec(read_fd);
|
||||
_ = bun.sys.setCloseOnExec(write_fd);
|
||||
|
||||
return PtyResult{
|
||||
.master = master_fd_desc,
|
||||
.read_fd = read_fd,
|
||||
.write_fd = write_fd,
|
||||
.slave = slave_fd_desc,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if terminal is closed
|
||||
pub fn getClosed(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return JSValue.jsBoolean(this.flags.closed);
|
||||
}
|
||||
|
||||
fn getTermiosFlag(this: *Terminal, comptime field: enum { iflag, oflag, lflag, cflag }) JSValue {
|
||||
if (comptime !Environment.isPosix) return JSValue.jsNumber(0);
|
||||
if (this.flags.closed or this.master_fd == bun.invalid_fd) return JSValue.jsNumber(0);
|
||||
const termios_data = getTermios(this.master_fd) orelse return JSValue.jsNumber(0);
|
||||
const flag = @field(termios_data, @tagName(field));
|
||||
const Int = @typeInfo(@TypeOf(flag)).@"struct".backing_integer.?;
|
||||
return JSValue.jsNumber(@as(f64, @floatFromInt(@as(Int, @bitCast(flag)))));
|
||||
}
|
||||
|
||||
fn setTermiosFlag(this: *Terminal, globalObject: *jsc.JSGlobalObject, comptime field: enum { iflag, oflag, lflag, cflag }, value: JSValue) bun.JSError!void {
|
||||
if (comptime !Environment.isPosix) return;
|
||||
if (this.flags.closed or this.master_fd == bun.invalid_fd) return;
|
||||
const num = try value.coerce(f64, globalObject);
|
||||
var termios_data = getTermios(this.master_fd) orelse return;
|
||||
const FlagType = @TypeOf(@field(termios_data, @tagName(field)));
|
||||
const Int = @typeInfo(FlagType).@"struct".backing_integer.?;
|
||||
const max_val: f64 = @floatFromInt(std.math.maxInt(Int));
|
||||
const clamped = @max(0, @min(num, max_val));
|
||||
@field(termios_data, @tagName(field)) = @bitCast(@as(Int, @intFromFloat(clamped)));
|
||||
_ = setTermios(this.master_fd, &termios_data);
|
||||
}
|
||||
|
||||
pub fn getInputFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return this.getTermiosFlag(.iflag);
|
||||
}
|
||||
pub fn setInputFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
|
||||
try this.setTermiosFlag(globalObject, .iflag, value);
|
||||
}
|
||||
pub fn getOutputFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return this.getTermiosFlag(.oflag);
|
||||
}
|
||||
pub fn setOutputFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
|
||||
try this.setTermiosFlag(globalObject, .oflag, value);
|
||||
}
|
||||
pub fn getLocalFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return this.getTermiosFlag(.lflag);
|
||||
}
|
||||
pub fn setLocalFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
|
||||
try this.setTermiosFlag(globalObject, .lflag, value);
|
||||
}
|
||||
pub fn getControlFlags(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return this.getTermiosFlag(.cflag);
|
||||
}
|
||||
pub fn setControlFlags(this: *Terminal, globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!void {
|
||||
try this.setTermiosFlag(globalObject, .cflag, value);
|
||||
}
|
||||
|
||||
/// Write data to the terminal
|
||||
pub fn write(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.flags.closed) {
|
||||
return globalObject.throw("Terminal is closed", .{});
|
||||
}
|
||||
|
||||
const args = callframe.argumentsAsArray(1);
|
||||
const data = args[0];
|
||||
|
||||
if (data.isUndefinedOrNull()) {
|
||||
return globalObject.throw("write() requires data argument", .{});
|
||||
}
|
||||
|
||||
// Get bytes to write using StringOrBuffer
|
||||
const string_or_buffer = try jsc.Node.StringOrBuffer.fromJS(globalObject, bun.default_allocator, data) orelse {
|
||||
return globalObject.throw("write() argument must be a string or ArrayBuffer", .{});
|
||||
};
|
||||
defer string_or_buffer.deinit();
|
||||
|
||||
const bytes = string_or_buffer.slice();
|
||||
|
||||
if (bytes.len == 0) {
|
||||
return JSValue.jsNumber(0);
|
||||
}
|
||||
|
||||
// Write using the streaming writer
|
||||
const write_result = this.writer.write(bytes);
|
||||
return switch (write_result) {
|
||||
.done => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
|
||||
.wrote => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
|
||||
.pending => |amt| JSValue.jsNumber(@as(i32, @intCast(amt))),
|
||||
.err => |err| globalObject.throwValue(err.toJS(globalObject)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Resize the terminal
|
||||
pub fn resize(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.flags.closed) {
|
||||
return globalObject.throw("Terminal is closed", .{});
|
||||
}
|
||||
|
||||
const args = callframe.argumentsAsArray(2);
|
||||
|
||||
const new_cols: u16 = blk: {
|
||||
if (args[0].isNumber()) {
|
||||
const n = args[0].toInt32();
|
||||
if (n > 0 and n <= 65535) break :blk @intCast(n);
|
||||
}
|
||||
return globalObject.throw("resize() requires valid cols argument", .{});
|
||||
};
|
||||
|
||||
const new_rows: u16 = blk: {
|
||||
if (args[1].isNumber()) {
|
||||
const n = args[1].toInt32();
|
||||
if (n > 0 and n <= 65535) break :blk @intCast(n);
|
||||
}
|
||||
return globalObject.throw("resize() requires valid rows argument", .{});
|
||||
};
|
||||
|
||||
if (comptime Environment.isPosix) {
|
||||
const ioctl_c = struct {
|
||||
const TIOCSWINSZ: c_ulong = if (Environment.isMac) 0x80087467 else 0x5414;
|
||||
|
||||
const Winsize = extern struct {
|
||||
ws_row: u16,
|
||||
ws_col: u16,
|
||||
ws_xpixel: u16,
|
||||
ws_ypixel: u16,
|
||||
};
|
||||
|
||||
extern "c" fn ioctl(fd: c_int, request: c_ulong, ...) c_int;
|
||||
};
|
||||
|
||||
var winsize = ioctl_c.Winsize{
|
||||
.ws_row = new_rows,
|
||||
.ws_col = new_cols,
|
||||
.ws_xpixel = 0,
|
||||
.ws_ypixel = 0,
|
||||
};
|
||||
|
||||
const ioctl_result = ioctl_c.ioctl(this.master_fd.cast(), ioctl_c.TIOCSWINSZ, &winsize);
|
||||
if (ioctl_result != 0) {
|
||||
return globalObject.throw("Failed to resize terminal", .{});
|
||||
}
|
||||
}
|
||||
|
||||
this.cols = new_cols;
|
||||
this.rows = new_rows;
|
||||
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// Set raw mode on the terminal
|
||||
pub fn setRawMode(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.flags.closed) {
|
||||
return globalObject.throw("Terminal is closed", .{});
|
||||
}
|
||||
|
||||
const args = callframe.argumentsAsArray(1);
|
||||
const enabled = args[0].toBoolean();
|
||||
|
||||
if (comptime Environment.isPosix) {
|
||||
// Use the existing TTY mode function
|
||||
const mode: c_int = if (enabled) 1 else 0;
|
||||
const tty_result = Bun__ttySetMode(this.master_fd.cast(), mode);
|
||||
if (tty_result != 0) {
|
||||
return globalObject.throw("Failed to set raw mode", .{});
|
||||
}
|
||||
}
|
||||
|
||||
this.flags.raw_mode = enabled;
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
extern fn Bun__ttySetMode(fd: c_int, mode: c_int) c_int;
|
||||
|
||||
/// POSIX termios struct for terminal flags manipulation
|
||||
const Termios = if (Environment.isPosix) std.posix.termios else void;
|
||||
|
||||
/// Get terminal attributes using tcgetattr
|
||||
fn getTermios(fd: bun.FileDescriptor) ?Termios {
|
||||
if (comptime !Environment.isPosix) return null;
|
||||
return std.posix.tcgetattr(fd.cast()) catch null;
|
||||
}
|
||||
|
||||
/// Set terminal attributes using tcsetattr (TCSANOW = immediate)
|
||||
fn setTermios(fd: bun.FileDescriptor, termios_p: *const Termios) bool {
|
||||
if (comptime !Environment.isPosix) return false;
|
||||
std.posix.tcsetattr(fd.cast(), .NOW, termios_p.*) catch return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Reference the terminal to keep the event loop alive
|
||||
pub fn doRef(this: *Terminal, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue {
|
||||
this.updateRef(true);
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// Unreference the terminal
|
||||
pub fn doUnref(this: *Terminal, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue {
|
||||
this.updateRef(false);
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
fn updateRef(this: *Terminal, add: bool) void {
|
||||
this.reader.updateRef(add);
|
||||
this.writer.updateRef(this.event_loop_handle, add);
|
||||
}
|
||||
|
||||
/// Close the terminal
|
||||
pub fn close(
|
||||
this: *Terminal,
|
||||
_: *jsc.JSGlobalObject,
|
||||
_: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
this.closeInternal();
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// Async dispose for "using" syntax
|
||||
pub fn asyncDispose(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
_: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
this.closeInternal();
|
||||
return jsc.JSPromise.resolvedPromiseValue(globalObject, .js_undefined);
|
||||
}
|
||||
|
||||
pub fn closeInternal(this: *Terminal) void {
|
||||
if (this.flags.closed) return;
|
||||
this.flags.closed = true;
|
||||
|
||||
// Close reader (closes read_fd)
|
||||
if (this.flags.reader_started) {
|
||||
this.reader.close();
|
||||
}
|
||||
this.read_fd = bun.invalid_fd;
|
||||
|
||||
// Close writer (closes write_fd)
|
||||
this.writer.close();
|
||||
this.write_fd = bun.invalid_fd;
|
||||
|
||||
// Close master fd
|
||||
if (this.master_fd != bun.invalid_fd) {
|
||||
this.master_fd.close();
|
||||
this.master_fd = bun.invalid_fd;
|
||||
}
|
||||
|
||||
// Close slave fd
|
||||
if (this.slave_fd != bun.invalid_fd) {
|
||||
this.slave_fd.close();
|
||||
this.slave_fd = bun.invalid_fd;
|
||||
}
|
||||
}
|
||||
|
||||
// IOWriter callbacks
|
||||
fn onWriterClose(this: *Terminal) void {
|
||||
log("onWriterClose", .{});
|
||||
if (!this.flags.writer_done) {
|
||||
this.flags.writer_done = true;
|
||||
// Release writer's ref
|
||||
this.deref();
|
||||
}
|
||||
}
|
||||
|
||||
fn onWriterReady(this: *Terminal) void {
|
||||
log("onWriterReady", .{});
|
||||
// Call drain callback
|
||||
const this_jsvalue = this.this_value.tryGet() orelse return;
|
||||
if (js.gc.get(.drain, this_jsvalue)) |callback| {
|
||||
const globalThis = this.globalThis;
|
||||
globalThis.bunVM().eventLoop().runCallback(
|
||||
callback,
|
||||
globalThis,
|
||||
this_jsvalue,
|
||||
&.{this_jsvalue},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn onWriterError(this: *Terminal, err: bun.sys.Error) void {
|
||||
log("onWriterError: {any}", .{err});
|
||||
// On write error, close the terminal to prevent further operations
|
||||
// This handles cases like broken pipe when the child process exits
|
||||
if (!this.flags.closed) {
|
||||
this.closeInternal();
|
||||
}
|
||||
}
|
||||
|
||||
fn onWrite(this: *Terminal, amount: usize, status: bun.io.WriteStatus) void {
|
||||
log("onWrite: {} bytes, status: {any}", .{ amount, status });
|
||||
_ = this;
|
||||
}
|
||||
|
||||
// IOReader callbacks
|
||||
pub fn onReaderDone(this: *Terminal) void {
|
||||
log("onReaderDone", .{});
|
||||
// EOF from master - downgrade to weak ref to allow GC
|
||||
// Skip JS interactions if already finalized (happens when close() is called during finalize)
|
||||
if (!this.flags.finalized) {
|
||||
this.flags.connected = false;
|
||||
this.this_value.downgrade();
|
||||
// exit_code 0 = clean EOF on PTY stream (not subprocess exit code)
|
||||
this.callExitCallback(0, null);
|
||||
}
|
||||
// Release reader's ref (only once)
|
||||
if (!this.flags.reader_done) {
|
||||
this.flags.reader_done = true;
|
||||
this.deref();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn onReaderError(this: *Terminal, err: bun.sys.Error) void {
|
||||
log("onReaderError: {any}", .{err});
|
||||
// Error - downgrade to weak ref to allow GC
|
||||
// Skip JS interactions if already finalized
|
||||
if (!this.flags.finalized) {
|
||||
this.flags.connected = false;
|
||||
this.this_value.downgrade();
|
||||
// exit_code 1 = I/O error on PTY stream (not subprocess exit code)
|
||||
this.callExitCallback(1, null);
|
||||
}
|
||||
// Release reader's ref (only once)
|
||||
if (!this.flags.reader_done) {
|
||||
this.flags.reader_done = true;
|
||||
this.deref();
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke the exit callback with PTY lifecycle status.
|
||||
/// Note: exit_code is PTY-level (0=EOF, 1=error), NOT the subprocess exit code.
|
||||
/// The signal parameter is only populated if a signal caused the PTY close.
|
||||
fn callExitCallback(this: *Terminal, exit_code: i32, signal: ?bun.SignalCode) void {
|
||||
const this_jsvalue = this.this_value.tryGet() orelse return;
|
||||
const callback = js.gc.get(.exit, this_jsvalue) orelse return;
|
||||
|
||||
const globalThis = this.globalThis;
|
||||
const signal_value: JSValue = if (signal) |s|
|
||||
jsc.ZigString.init(s.name() orelse "unknown").toJS(globalThis)
|
||||
else
|
||||
JSValue.jsNull();
|
||||
|
||||
globalThis.bunVM().eventLoop().runCallback(
|
||||
callback,
|
||||
globalThis,
|
||||
this_jsvalue,
|
||||
&.{ this_jsvalue, JSValue.jsNumber(exit_code), signal_value },
|
||||
);
|
||||
}
|
||||
|
||||
// Called when data is available from the reader
|
||||
// Returns true to continue reading, false to pause
|
||||
pub fn onReadChunk(this: *Terminal, chunk: []const u8, has_more: bun.io.ReadState) bool {
|
||||
_ = has_more;
|
||||
log("onReadChunk: {} bytes", .{chunk.len});
|
||||
|
||||
// First data received - upgrade to strong ref (connected)
|
||||
if (!this.flags.connected) {
|
||||
this.flags.connected = true;
|
||||
this.this_value.upgrade(this.globalThis);
|
||||
}
|
||||
|
||||
const this_jsvalue = this.this_value.tryGet() orelse return true;
|
||||
const callback = js.gc.get(.data, this_jsvalue) orelse return true;
|
||||
|
||||
const globalThis = this.globalThis;
|
||||
const duped = bun.default_allocator.dupe(u8, chunk) catch |err| {
|
||||
log("Terminal data allocation OOM: chunk_size={d}, error={any}", .{ chunk.len, err });
|
||||
return true;
|
||||
};
|
||||
const data = jsc.MarkedArrayBuffer.fromBytes(
|
||||
duped,
|
||||
bun.default_allocator,
|
||||
.Uint8Array,
|
||||
).toNodeBuffer(globalThis);
|
||||
|
||||
globalThis.bunVM().eventLoop().runCallback(
|
||||
callback,
|
||||
globalThis,
|
||||
this_jsvalue,
|
||||
&.{ this_jsvalue, data },
|
||||
);
|
||||
|
||||
return true; // Continue reading
|
||||
}
|
||||
|
||||
pub fn eventLoop(this: *Terminal) jsc.EventLoopHandle {
|
||||
return this.event_loop_handle;
|
||||
}
|
||||
|
||||
pub fn loop(this: *Terminal) *bun.Async.Loop {
|
||||
if (comptime Environment.isWindows) {
|
||||
return this.event_loop_handle.loop().uv_loop;
|
||||
} else {
|
||||
return this.event_loop_handle.loop();
|
||||
}
|
||||
}
|
||||
|
||||
fn deinit(this: *Terminal) void {
|
||||
log("deinit", .{});
|
||||
// Set reader/writer done flags to prevent extra deref calls in closeInternal
|
||||
this.flags.reader_done = true;
|
||||
this.flags.writer_done = true;
|
||||
// Close all FDs if not already closed (handles constructor error paths)
|
||||
// closeInternal() checks flags.closed and returns early on subsequent calls,
|
||||
// so this is safe even if finalize() already called it
|
||||
this.closeInternal();
|
||||
this.term_name.deinit();
|
||||
this.reader.deinit();
|
||||
this.writer.deinit();
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
/// Finalize - called by GC when object is collected
|
||||
pub fn finalize(this: *Terminal) callconv(.c) void {
|
||||
log("finalize", .{});
|
||||
jsc.markBinding(@src());
|
||||
this.this_value.finalize();
|
||||
this.flags.finalized = true;
|
||||
this.closeInternal();
|
||||
this.deref();
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
const Environment = bun.Environment;
|
||||
|
||||
const jsc = bun.jsc;
|
||||
const JSGlobalObject = jsc.JSGlobalObject;
|
||||
const JSValue = jsc.JSValue;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
macro(SHA512_256) \
|
||||
macro(TOML) \
|
||||
macro(YAML) \
|
||||
macro(Terminal) \
|
||||
macro(Transpiler) \
|
||||
macro(ValkeyClient) \
|
||||
macro(argv) \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "root.h"
|
||||
|
||||
#if OS(LINUX)
|
||||
#if OS(LINUX) || OS(DARWIN)
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <cstring>
|
||||
@@ -8,18 +8,71 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
#if OS(LINUX)
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
extern char** environ;
|
||||
|
||||
#ifndef CLOSE_RANGE_CLOEXEC
|
||||
#define CLOSE_RANGE_CLOEXEC (1U << 2)
|
||||
#endif
|
||||
|
||||
#if OS(LINUX)
|
||||
extern "C" ssize_t bun_close_range(unsigned int start, unsigned int end, unsigned int flags);
|
||||
#endif
|
||||
|
||||
// Helper: get max fd from system, clamped to sane limits and optionally to 'end' parameter
|
||||
static inline int getMaxFd(int start, int end)
|
||||
{
|
||||
#if OS(LINUX)
|
||||
int maxfd = static_cast<int>(sysconf(_SC_OPEN_MAX));
|
||||
#elif OS(DARWIN)
|
||||
int maxfd = getdtablesize();
|
||||
#else
|
||||
int maxfd = 1024;
|
||||
#endif
|
||||
if (maxfd < 0 || maxfd > 65536) maxfd = 1024;
|
||||
// Respect the end parameter if it's a valid bound (not INT_MAX sentinel)
|
||||
if (end >= start && end < INT_MAX) {
|
||||
maxfd = std::min(maxfd, end + 1); // +1 because end is inclusive
|
||||
}
|
||||
return maxfd;
|
||||
}
|
||||
|
||||
// Loop-based fallback for closing/cloexec fds
|
||||
static inline void closeRangeLoop(int start, int end, bool cloexec_only)
|
||||
{
|
||||
int maxfd = getMaxFd(start, end);
|
||||
for (int fd = start; fd < maxfd; fd++) {
|
||||
if (cloexec_only) {
|
||||
int current_flags = fcntl(fd, F_GETFD);
|
||||
if (current_flags >= 0) {
|
||||
fcntl(fd, F_SETFD, current_flags | FD_CLOEXEC);
|
||||
}
|
||||
} else {
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific close range implementation
|
||||
static inline void closeRangeOrLoop(int start, int end, bool cloexec_only)
|
||||
{
|
||||
#if OS(LINUX)
|
||||
unsigned int flags = cloexec_only ? CLOSE_RANGE_CLOEXEC : 0;
|
||||
if (bun_close_range(start, end, flags) == 0) {
|
||||
return;
|
||||
}
|
||||
// Fallback for older kernels or when close_range fails
|
||||
#endif
|
||||
closeRangeLoop(start, end, cloexec_only);
|
||||
}
|
||||
|
||||
enum FileActionType : uint8_t {
|
||||
None,
|
||||
@@ -45,8 +98,21 @@ typedef struct bun_spawn_request_t {
|
||||
const char* chdir;
|
||||
bool detached;
|
||||
bun_spawn_file_action_list_t actions;
|
||||
int pty_slave_fd; // -1 if not using PTY, otherwise the slave fd to set as controlling terminal
|
||||
} bun_spawn_request_t;
|
||||
|
||||
// Raw exit syscall that doesn't go through libc.
|
||||
// This avoids potential deadlocks when forking from a multi-threaded process,
|
||||
// as _exit() may try to acquire locks held by threads that don't exist in the child.
|
||||
static inline void rawExit(int status)
|
||||
{
|
||||
#if OS(LINUX)
|
||||
syscall(__NR_exit_group, status);
|
||||
#else
|
||||
_exit(status);
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" ssize_t posix_spawn_bun(
|
||||
int* pid,
|
||||
const char* path,
|
||||
@@ -54,23 +120,65 @@ extern "C" ssize_t posix_spawn_bun(
|
||||
char* const argv[],
|
||||
char* const envp[])
|
||||
{
|
||||
volatile int status = 0;
|
||||
sigset_t blockall, oldmask;
|
||||
int res = 0, cs = 0;
|
||||
|
||||
#if OS(DARWIN)
|
||||
// On macOS, we use fork() which requires a self-pipe trick to detect exec failures.
|
||||
// Create a pipe for child-to-parent error communication.
|
||||
// The write end has O_CLOEXEC so it's automatically closed on successful exec.
|
||||
// If exec fails, child writes errno to the pipe.
|
||||
int errpipe[2];
|
||||
if (pipe(errpipe) == -1) {
|
||||
return errno;
|
||||
}
|
||||
// Set cloexec on write end so it closes on successful exec
|
||||
fcntl(errpipe[1], F_SETFD, FD_CLOEXEC);
|
||||
#endif
|
||||
|
||||
sigfillset(&blockall);
|
||||
sigprocmask(SIG_SETMASK, &blockall, &oldmask);
|
||||
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cs);
|
||||
pid_t child = vfork();
|
||||
|
||||
#if OS(LINUX)
|
||||
// On Linux, use vfork() for performance. The parent is suspended until
|
||||
// the child calls exec or _exit, so we can detect exec failure via the
|
||||
// child's exit status without needing the self-pipe trick.
|
||||
// While POSIX restricts vfork children to only calling _exit() or exec*(),
|
||||
// Linux's vfork() is more permissive and allows the setup we need
|
||||
// (setsid, ioctl, dup2, etc.) before exec.
|
||||
volatile int child_errno = 0;
|
||||
pid_t child = vfork();
|
||||
#else
|
||||
// On macOS, we must use fork() because vfork() is more strictly enforced.
|
||||
// This code path should only be used for PTY spawns on macOS.
|
||||
pid_t child = fork();
|
||||
#endif
|
||||
|
||||
#if OS(DARWIN)
|
||||
const auto childFailed = [&]() -> ssize_t {
|
||||
res = errno;
|
||||
status = res;
|
||||
bun_close_range(0, ~0U, 0);
|
||||
_exit(127);
|
||||
int err = errno;
|
||||
// Write errno to pipe so parent can read it
|
||||
(void)write(errpipe[1], &err, sizeof(err));
|
||||
close(errpipe[1]);
|
||||
closeRangeOrLoop(0, INT_MAX, false);
|
||||
rawExit(127);
|
||||
|
||||
// should never be reached
|
||||
return -1;
|
||||
};
|
||||
#else
|
||||
const auto childFailed = [&]() -> ssize_t {
|
||||
// With vfork(), we share memory with the parent, so we can communicate
|
||||
// the error directly via a volatile variable. The parent will see this
|
||||
// value after we call _exit().
|
||||
child_errno = errno;
|
||||
rawExit(127);
|
||||
|
||||
// should never be reached
|
||||
return -1;
|
||||
};
|
||||
#endif
|
||||
|
||||
const auto startChild = [&]() -> ssize_t {
|
||||
sigset_t childmask = oldmask;
|
||||
@@ -82,11 +190,19 @@ extern "C" ssize_t posix_spawn_bun(
|
||||
sigaction(i, &sa, 0);
|
||||
}
|
||||
|
||||
// Make "detached" work
|
||||
if (request->detached) {
|
||||
// Make "detached" work, or set up PTY as controlling terminal
|
||||
if (request->detached || request->pty_slave_fd >= 0) {
|
||||
setsid();
|
||||
}
|
||||
|
||||
// Set PTY slave as controlling terminal for proper job control.
|
||||
// TIOCSCTTY may fail if the terminal is already the controlling terminal
|
||||
// of another session. This is non-fatal - the process can still run,
|
||||
// just without proper job control.
|
||||
if (request->pty_slave_fd >= 0) {
|
||||
(void)ioctl(request->pty_slave_fd, TIOCSCTTY, 0);
|
||||
}
|
||||
|
||||
int current_max_fd = 0;
|
||||
|
||||
if (request->chdir) {
|
||||
@@ -165,35 +281,89 @@ extern "C" ssize_t posix_spawn_bun(
|
||||
if (!envp)
|
||||
envp = environ;
|
||||
|
||||
if (bun_close_range(current_max_fd + 1, ~0U, CLOSE_RANGE_CLOEXEC) != 0) {
|
||||
bun_close_range(current_max_fd + 1, ~0U, 0);
|
||||
}
|
||||
// Close all fds > current_max_fd, preferring cloexec if available
|
||||
closeRangeOrLoop(current_max_fd + 1, INT_MAX, true);
|
||||
|
||||
if (execve(path, argv, envp) == -1) {
|
||||
return childFailed();
|
||||
}
|
||||
_exit(127);
|
||||
rawExit(127);
|
||||
|
||||
// should never be reached.
|
||||
return -1;
|
||||
};
|
||||
|
||||
if (child == 0) {
|
||||
#if OS(DARWIN)
|
||||
// Close read end in child
|
||||
close(errpipe[0]);
|
||||
#endif
|
||||
return startChild();
|
||||
}
|
||||
|
||||
if (child != -1) {
|
||||
res = status;
|
||||
#if OS(DARWIN)
|
||||
// macOS fork() path: use self-pipe trick to detect exec failure
|
||||
// Parent: close write end
|
||||
close(errpipe[1]);
|
||||
|
||||
if (!res) {
|
||||
if (child != -1) {
|
||||
// Try to read error from child. The pipe read end is blocking.
|
||||
// - If exec succeeds: write end closes due to O_CLOEXEC, read() returns 0
|
||||
// - If exec fails: child writes errno, then exits, read() returns sizeof(int)
|
||||
int child_err = 0;
|
||||
ssize_t n;
|
||||
|
||||
// Retry read on EINTR - signals are blocked but some may still interrupt
|
||||
do {
|
||||
n = read(errpipe[0], &child_err, sizeof(child_err));
|
||||
} while (n == -1 && errno == EINTR);
|
||||
|
||||
close(errpipe[0]);
|
||||
|
||||
if (n == sizeof(child_err)) {
|
||||
// Child failed to exec - it wrote errno and exited
|
||||
// Reap the zombie child process
|
||||
waitpid(child, NULL, 0);
|
||||
res = child_err;
|
||||
} else if (n == 0) {
|
||||
// Exec succeeded (pipe closed with no data written)
|
||||
// Don't wait - the child is now running as a new process
|
||||
res = 0;
|
||||
if (pid) {
|
||||
*pid = child;
|
||||
}
|
||||
} else {
|
||||
wait4(child, 0, 0, 0);
|
||||
// read() failed or partial read - something went wrong
|
||||
// Reap child and report error
|
||||
waitpid(child, NULL, 0);
|
||||
res = (n == -1) ? errno : EIO;
|
||||
}
|
||||
} else {
|
||||
// fork() failed
|
||||
close(errpipe[0]);
|
||||
res = errno;
|
||||
}
|
||||
#else
|
||||
// Linux vfork() path: parent resumes after child calls exec or _exit
|
||||
// We can detect exec failure via the volatile child_errno variable
|
||||
if (child != -1) {
|
||||
if (child_errno != 0) {
|
||||
// Child failed to exec - it set child_errno and called _exit()
|
||||
// Reap the zombie child process
|
||||
wait4(child, NULL, 0, NULL);
|
||||
res = child_errno;
|
||||
} else {
|
||||
// Exec succeeded
|
||||
res = 0;
|
||||
if (pid) {
|
||||
*pid = child;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// vfork() failed
|
||||
res = errno;
|
||||
}
|
||||
#endif
|
||||
|
||||
sigprocmask(SIG_SETMASK, &oldmask, 0);
|
||||
pthread_setcancelstate(cs, 0);
|
||||
|
||||
@@ -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;
|
||||
|
||||
1172
test/js/bun/terminal/terminal.test.ts
Normal file
1172
test/js/bun/terminal/terminal.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user