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()`)
|
## 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.
|
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;
|
timeout?: number;
|
||||||
killSignal?: string | number;
|
killSignal?: string | number;
|
||||||
maxBuffer?: number;
|
maxBuffer?: number;
|
||||||
|
terminal?: TerminalOptions; // PTY support (POSIX only)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Readable =
|
type Readable =
|
||||||
@@ -435,10 +539,11 @@ namespace SpawnOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Subprocess extends AsyncDisposable {
|
interface Subprocess extends AsyncDisposable {
|
||||||
readonly stdin: FileSink | number | undefined;
|
readonly stdin: FileSink | number | undefined | null;
|
||||||
readonly stdout: ReadableStream<Uint8Array> | number | undefined;
|
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||||
readonly stderr: ReadableStream<Uint8Array> | number | undefined;
|
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||||
readonly readable: ReadableStream<Uint8Array> | number | undefined;
|
readonly readable: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
|
||||||
|
readonly terminal: Terminal | undefined;
|
||||||
readonly pid: number;
|
readonly pid: number;
|
||||||
readonly exited: Promise<number>;
|
readonly exited: Promise<number>;
|
||||||
readonly exitCode: number | null;
|
readonly exitCode: number | null;
|
||||||
@@ -465,6 +570,28 @@ interface SyncSubprocess {
|
|||||||
pid: number;
|
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 {
|
interface ResourceUsage {
|
||||||
contextSwitches: {
|
contextSwitches: {
|
||||||
voluntary: number;
|
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;
|
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
|
type ReadableToIO<X extends Readable> = X extends "pipe" | undefined
|
||||||
@@ -5811,6 +5849,24 @@ declare module "bun" {
|
|||||||
readonly stdout: SpawnOptions.ReadableToIO<Out>;
|
readonly stdout: SpawnOptions.ReadableToIO<Out>;
|
||||||
readonly stderr: SpawnOptions.ReadableToIO<Err>;
|
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.
|
* Access extra file descriptors passed to the `stdio` option in the options object.
|
||||||
*/
|
*/
|
||||||
@@ -6102,6 +6158,154 @@ declare module "bun" {
|
|||||||
"ignore" | "inherit" | null | undefined
|
"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
|
// Blocked on https://github.com/oven-sh/bun/issues/8329
|
||||||
// /**
|
// /**
|
||||||
// *
|
// *
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pub const FilePoll = struct {
|
|||||||
const StaticPipeWriter = Subprocess.StaticPipeWriter.Poll;
|
const StaticPipeWriter = Subprocess.StaticPipeWriter.Poll;
|
||||||
const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll;
|
const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll;
|
||||||
const FileSink = jsc.WebCore.FileSink.Poll;
|
const FileSink = jsc.WebCore.FileSink.Poll;
|
||||||
|
const TerminalPoll = bun.api.Terminal.Poll;
|
||||||
const DNSResolver = bun.api.dns.Resolver;
|
const DNSResolver = bun.api.dns.Resolver;
|
||||||
const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest;
|
const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest;
|
||||||
const Request = bun.api.dns.internal.Request;
|
const Request = bun.api.dns.internal.Request;
|
||||||
@@ -181,6 +182,7 @@ pub const FilePoll = struct {
|
|||||||
// LifecycleScriptSubprocessOutputReader,
|
// LifecycleScriptSubprocessOutputReader,
|
||||||
Process,
|
Process,
|
||||||
ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop
|
ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop
|
||||||
|
TerminalPoll,
|
||||||
});
|
});
|
||||||
|
|
||||||
pub const AllocatorType = enum {
|
pub const AllocatorType = enum {
|
||||||
@@ -414,6 +416,12 @@ pub const FilePoll = struct {
|
|||||||
Request.MacAsyncDNS.onMachportChange(loader);
|
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 => {
|
else => {
|
||||||
const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag()));
|
const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag()));
|
||||||
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) disconnected? (maybe: {s})", .{ poll.fd, possible_name orelse "<unknown>" });
|
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 SocketHandlers = @import("./api/bun/socket.zig").Handlers;
|
||||||
|
|
||||||
pub const Subprocess = @import("./api/bun/subprocess.zig");
|
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 HashObject = @import("./api/HashObject.zig");
|
||||||
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
|
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
|
||||||
pub const TOMLObject = @import("./api/TOMLObject.zig");
|
pub const TOMLObject = @import("./api/TOMLObject.zig");
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export default [
|
|||||||
stdio: {
|
stdio: {
|
||||||
getter: "getStdio",
|
getter: "getStdio",
|
||||||
},
|
},
|
||||||
|
terminal: {
|
||||||
|
getter: "getTerminal",
|
||||||
|
cache: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
|
values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub const BunObject = struct {
|
|||||||
pub const s3 = toJSLazyPropertyCallback(Bun.getS3DefaultClient);
|
pub const s3 = toJSLazyPropertyCallback(Bun.getS3DefaultClient);
|
||||||
pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor);
|
pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor);
|
||||||
pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient);
|
pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient);
|
||||||
|
pub const Terminal = toJSLazyPropertyCallback(Bun.getTerminalConstructor);
|
||||||
// --- Lazy property callbacks ---
|
// --- Lazy property callbacks ---
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
@@ -143,6 +144,7 @@ pub const BunObject = struct {
|
|||||||
@export(&BunObject.s3, .{ .name = lazyPropertyCallbackName("s3") });
|
@export(&BunObject.s3, .{ .name = lazyPropertyCallbackName("s3") });
|
||||||
@export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") });
|
@export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") });
|
||||||
@export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") });
|
@export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") });
|
||||||
|
@export(&BunObject.Terminal, .{ .name = lazyPropertyCallbackName("Terminal") });
|
||||||
// --- Lazy property callbacks ---
|
// --- Lazy property callbacks ---
|
||||||
|
|
||||||
// --- Callbacks ---
|
// --- Callbacks ---
|
||||||
@@ -1315,6 +1317,10 @@ pub fn getValkeyClientConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObj
|
|||||||
return jsc.API.Valkey.js.getConstructor(globalThis);
|
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 {
|
pub fn getEmbeddedFiles(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) bun.JSError!jsc.JSValue {
|
||||||
const vm = globalThis.bunVM();
|
const vm = globalThis.bunVM();
|
||||||
const graph = vm.standalone_module_graph orelse return try jsc.JSValue.createEmptyArray(globalThis, 0);
|
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_hide: bool = false;
|
||||||
var windows_verbatim_arguments: bool = false;
|
var windows_verbatim_arguments: bool = false;
|
||||||
var abort_signal: ?*jsc.WebCore.AbortSignal = null;
|
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 {
|
defer {
|
||||||
// Ensure we clean it up on error.
|
|
||||||
if (abort_signal) |signal| {
|
if (abort_signal) |signal| {
|
||||||
signal.unref();
|
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()) {
|
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
|
// This must run before the stdio parsing happens
|
||||||
if (!is_sync) {
|
if (!is_sync) {
|
||||||
if (try args.getTruthy(globalThis, "ipc")) |val| {
|
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 {
|
} else {
|
||||||
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
|
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
|
||||||
}
|
}
|
||||||
@@ -504,6 +559,12 @@ pub fn spawnMaybeSync(
|
|||||||
.extra_fds = extra_fds.items,
|
.extra_fds = extra_fds.items,
|
||||||
.argv0 = argv0,
|
.argv0 = argv0,
|
||||||
.can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = can_block_entire_thread_to_reduce_cpu_usage_in_fast_path,
|
.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) .{
|
.windows = if (Environment.isWindows) .{
|
||||||
.hide_window = windows_hide,
|
.hide_window = windows_hide,
|
||||||
@@ -633,8 +694,18 @@ pub fn spawnMaybeSync(
|
|||||||
.killSignal = killSignal,
|
.killSignal = killSignal,
|
||||||
.stderr_maxbuf = subprocess.stderr_maxbuf,
|
.stderr_maxbuf = subprocess.stderr_maxbuf,
|
||||||
.stdout_maxbuf = subprocess.stdout_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);
|
subprocess.process.setExitHandler(subprocess);
|
||||||
|
|
||||||
promise_for_stream.ensureStillAlive();
|
promise_for_stream.ensureStillAlive();
|
||||||
@@ -732,6 +803,11 @@ pub fn spawnMaybeSync(
|
|||||||
jsc.Codegen.JSSubprocess.stdinSetCached(out, globalThis, stdio[0].readable_stream.value);
|
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()) {
|
switch (subprocess.process.watch()) {
|
||||||
.result => {},
|
.result => {},
|
||||||
.err => {
|
.err => {
|
||||||
@@ -1001,6 +1077,7 @@ const log = Output.scoped(.Subprocess, .hidden);
|
|||||||
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
|
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
|
||||||
|
|
||||||
const IPC = @import("../../ipc.zig");
|
const IPC = @import("../../ipc.zig");
|
||||||
|
const Terminal = @import("./Terminal.zig");
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
|||||||
@@ -994,6 +994,8 @@ pub const PosixSpawnOptions = struct {
|
|||||||
/// for stdout. This is used to preserve
|
/// for stdout. This is used to preserve
|
||||||
/// consistent shell semantics.
|
/// consistent shell semantics.
|
||||||
no_sigpipe: bool = true,
|
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) {
|
pub const Stdio = union(enum) {
|
||||||
path: []const u8,
|
path: []const u8,
|
||||||
@@ -1062,6 +1064,8 @@ pub const WindowsSpawnOptions = struct {
|
|||||||
stream: bool = true,
|
stream: bool = true,
|
||||||
use_execve_on_macos: bool = false,
|
use_execve_on_macos: bool = false,
|
||||||
can_block_entire_thread_to_reduce_cpu_usage_in_fast_path: 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 {
|
pub const WindowsOptions = struct {
|
||||||
verbatim_arguments: bool = false,
|
verbatim_arguments: bool = false,
|
||||||
hide_window: bool = true,
|
hide_window: bool = true,
|
||||||
@@ -1257,6 +1261,9 @@ pub fn spawnProcessPosix(
|
|||||||
flags |= bun.c.POSIX_SPAWN_SETSID;
|
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) {
|
if (options.cwd.len > 0) {
|
||||||
try actions.chdir(options.cwd);
|
try actions.chdir(options.cwd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ pub const BunSpawn = struct {
|
|||||||
|
|
||||||
pub const Attr = struct {
|
pub const Attr = struct {
|
||||||
detached: bool = false,
|
detached: bool = false,
|
||||||
|
pty_slave_fd: i32 = -1,
|
||||||
|
flags: u16 = 0,
|
||||||
|
reset_signals: bool = false,
|
||||||
|
|
||||||
pub fn init() !Attr {
|
pub fn init() !Attr {
|
||||||
return Attr{};
|
return Attr{};
|
||||||
@@ -100,21 +103,16 @@ pub const BunSpawn = struct {
|
|||||||
pub fn deinit(_: *Attr) void {}
|
pub fn deinit(_: *Attr) void {}
|
||||||
|
|
||||||
pub fn get(self: Attr) !u16 {
|
pub fn get(self: Attr) !u16 {
|
||||||
var flags: c_int = 0;
|
return self.flags;
|
||||||
|
|
||||||
if (self.detached) {
|
|
||||||
flags |= bun.C.POSIX_SPAWN_SETSID;
|
|
||||||
}
|
|
||||||
|
|
||||||
return @intCast(flags);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(self: *Attr, flags: u16) !void {
|
pub fn set(self: *Attr, flags: u16) !void {
|
||||||
|
self.flags = flags;
|
||||||
self.detached = (flags & bun.c.POSIX_SPAWN_SETSID) != 0;
|
self.detached = (flags & bun.c.POSIX_SPAWN_SETSID) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resetSignals(this: *Attr) !void {
|
pub fn resetSignals(self: *Attr) !void {
|
||||||
_ = this;
|
self.reset_signals = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -128,6 +126,8 @@ pub const PosixSpawn = struct {
|
|||||||
|
|
||||||
pub const PosixSpawnAttr = struct {
|
pub const PosixSpawnAttr = struct {
|
||||||
attr: system.posix_spawnattr_t,
|
attr: system.posix_spawnattr_t,
|
||||||
|
detached: bool = false,
|
||||||
|
pty_slave_fd: i32 = -1,
|
||||||
|
|
||||||
pub fn init() !PosixSpawnAttr {
|
pub fn init() !PosixSpawnAttr {
|
||||||
var attr: system.posix_spawnattr_t = undefined;
|
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;
|
// Use BunSpawn types on POSIX (both Linux and macOS) for PTY support via posix_spawn_bun.
|
||||||
pub const Attr = if (Environment.isLinux) BunSpawn.Attr else PosixSpawnAttr;
|
// 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 {
|
const BunSpawnRequest = extern struct {
|
||||||
chdir_buf: ?[*:0]u8 = null,
|
chdir_buf: ?[*:0]u8 = null,
|
||||||
detached: bool = false,
|
detached: bool = false,
|
||||||
actions: ActionsList = .{},
|
actions: ActionsList = .{},
|
||||||
|
pty_slave_fd: i32 = -1,
|
||||||
|
|
||||||
const ActionsList = extern struct {
|
const ActionsList = extern struct {
|
||||||
ptr: ?[*]const BunSpawn.Action = null,
|
ptr: ?[*]const BunSpawn.Action = null,
|
||||||
@@ -318,7 +322,18 @@ pub const PosixSpawn = struct {
|
|||||||
argv: [*:null]?[*:0]const u8,
|
argv: [*:null]?[*:0]const u8,
|
||||||
envp: [*:null]?[*:0]const u8,
|
envp: [*:null]?[*:0]const u8,
|
||||||
) Maybe(pid_t) {
|
) 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(
|
return BunSpawnRequest.spawn(
|
||||||
path,
|
path,
|
||||||
.{
|
.{
|
||||||
@@ -330,13 +345,106 @@ pub const PosixSpawn = struct {
|
|||||||
.len = 0,
|
.len = 0,
|
||||||
},
|
},
|
||||||
.chdir_buf = if (actions) |a| a.chdir_buf else null,
|
.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,
|
argv,
|
||||||
envp,
|
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;
|
var pid: pid_t = undefined;
|
||||||
const rc = system.posix_spawn(
|
const rc = system.posix_spawn(
|
||||||
&pid,
|
&pid,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ stderr: Readable,
|
|||||||
stdio_pipes: if (Environment.isWindows) std.ArrayListUnmanaged(StdioResult) else std.ArrayListUnmanaged(bun.FileDescriptor) = .{},
|
stdio_pipes: if (Environment.isWindows) std.ArrayListUnmanaged(StdioResult) else std.ArrayListUnmanaged(bun.FileDescriptor) = .{},
|
||||||
pid_rusage: ?Rusage = null,
|
pid_rusage: ?Rusage = null,
|
||||||
|
|
||||||
|
/// Terminal attached to this subprocess (if spawned with terminal option)
|
||||||
|
terminal: ?*Terminal = null,
|
||||||
|
|
||||||
globalThis: *jsc.JSGlobalObject,
|
globalThis: *jsc.JSGlobalObject,
|
||||||
observable_getters: std.enums.EnumSet(enum {
|
observable_getters: std.enums.EnumSet(enum {
|
||||||
stdin,
|
stdin,
|
||||||
@@ -269,16 +272,28 @@ pub const PipeReader = @import("./subprocess/SubprocessPipeReader.zig");
|
|||||||
pub const Readable = @import("./subprocess/Readable.zig").Readable;
|
pub const Readable = @import("./subprocess/Readable.zig").Readable;
|
||||||
|
|
||||||
pub fn getStderr(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
|
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);
|
this.observable_getters.insert(.stderr);
|
||||||
return this.stderr.toJS(globalThis, this.hasExited());
|
return this.stderr.toJS(globalThis, this.hasExited());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getStdin(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
|
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);
|
this.observable_getters.insert(.stdin);
|
||||||
return this.stdin.toJS(globalThis, this);
|
return this.stdin.toJS(globalThis, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getStdout(this: *Subprocess, globalThis: *JSGlobalObject) bun.JSError!JSValue {
|
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);
|
this.observable_getters.insert(.stdout);
|
||||||
// NOTE: ownership of internal buffers is transferred to the JSValue, which
|
// NOTE: ownership of internal buffers is transferred to the JSValue, which
|
||||||
// gets cached on JSSubprocess (created via bindgen). This makes it
|
// 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());
|
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 {
|
pub fn asyncDispose(this: *Subprocess, global: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
|
||||||
if (this.process.hasExited()) {
|
if (this.process.hasExited()) {
|
||||||
// rely on GC to clean everything up in this case
|
// 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;
|
pub const spawn = js_bun_spawn_bindings.spawn;
|
||||||
|
|
||||||
const IPC = @import("../../ipc.zig");
|
const IPC = @import("../../ipc.zig");
|
||||||
|
const Terminal = @import("./Terminal.zig");
|
||||||
const js_bun_spawn_bindings = @import("./js_bun_spawn_bindings.zig");
|
const js_bun_spawn_bindings = @import("./js_bun_spawn_bindings.zig");
|
||||||
const node_cluster_binding = @import("../../node/node_cluster_binding.zig");
|
const node_cluster_binding = @import("../../node/node_cluster_binding.zig");
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
macro(SHA512_256) \
|
macro(SHA512_256) \
|
||||||
macro(TOML) \
|
macro(TOML) \
|
||||||
macro(YAML) \
|
macro(YAML) \
|
||||||
|
macro(Terminal) \
|
||||||
macro(Transpiler) \
|
macro(Transpiler) \
|
||||||
macro(ValkeyClient) \
|
macro(ValkeyClient) \
|
||||||
macro(argv) \
|
macro(argv) \
|
||||||
|
|||||||
@@ -800,6 +800,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
|
|||||||
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
|
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
|
||||||
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
|
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
|
||||||
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
|
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
|
||||||
|
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
|
||||||
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
|
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
|
||||||
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
|
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
|
||||||
which BunObject_callback_which DontDelete|Function 1
|
which BunObject_callback_which DontDelete|Function 1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "root.h"
|
#include "root.h"
|
||||||
|
|
||||||
#if OS(LINUX)
|
#if OS(LINUX) || OS(DARWIN)
|
||||||
|
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -8,18 +8,71 @@
|
|||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <sys/syscall.h>
|
|
||||||
#include <sys/resource.h>
|
#include <sys/resource.h>
|
||||||
|
|
||||||
|
#if OS(LINUX)
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
extern char** environ;
|
extern char** environ;
|
||||||
|
|
||||||
#ifndef CLOSE_RANGE_CLOEXEC
|
#ifndef CLOSE_RANGE_CLOEXEC
|
||||||
#define CLOSE_RANGE_CLOEXEC (1U << 2)
|
#define CLOSE_RANGE_CLOEXEC (1U << 2)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if OS(LINUX)
|
||||||
extern "C" ssize_t bun_close_range(unsigned int start, unsigned int end, unsigned int flags);
|
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 {
|
enum FileActionType : uint8_t {
|
||||||
None,
|
None,
|
||||||
@@ -45,8 +98,21 @@ typedef struct bun_spawn_request_t {
|
|||||||
const char* chdir;
|
const char* chdir;
|
||||||
bool detached;
|
bool detached;
|
||||||
bun_spawn_file_action_list_t actions;
|
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;
|
} 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(
|
extern "C" ssize_t posix_spawn_bun(
|
||||||
int* pid,
|
int* pid,
|
||||||
const char* path,
|
const char* path,
|
||||||
@@ -54,23 +120,65 @@ extern "C" ssize_t posix_spawn_bun(
|
|||||||
char* const argv[],
|
char* const argv[],
|
||||||
char* const envp[])
|
char* const envp[])
|
||||||
{
|
{
|
||||||
volatile int status = 0;
|
|
||||||
sigset_t blockall, oldmask;
|
sigset_t blockall, oldmask;
|
||||||
int res = 0, cs = 0;
|
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);
|
sigfillset(&blockall);
|
||||||
sigprocmask(SIG_SETMASK, &blockall, &oldmask);
|
sigprocmask(SIG_SETMASK, &blockall, &oldmask);
|
||||||
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cs);
|
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 {
|
const auto childFailed = [&]() -> ssize_t {
|
||||||
res = errno;
|
int err = errno;
|
||||||
status = res;
|
// Write errno to pipe so parent can read it
|
||||||
bun_close_range(0, ~0U, 0);
|
(void)write(errpipe[1], &err, sizeof(err));
|
||||||
_exit(127);
|
close(errpipe[1]);
|
||||||
|
closeRangeOrLoop(0, INT_MAX, false);
|
||||||
|
rawExit(127);
|
||||||
|
|
||||||
// should never be reached
|
// should never be reached
|
||||||
return -1;
|
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 {
|
const auto startChild = [&]() -> ssize_t {
|
||||||
sigset_t childmask = oldmask;
|
sigset_t childmask = oldmask;
|
||||||
@@ -82,11 +190,19 @@ extern "C" ssize_t posix_spawn_bun(
|
|||||||
sigaction(i, &sa, 0);
|
sigaction(i, &sa, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make "detached" work
|
// Make "detached" work, or set up PTY as controlling terminal
|
||||||
if (request->detached) {
|
if (request->detached || request->pty_slave_fd >= 0) {
|
||||||
setsid();
|
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;
|
int current_max_fd = 0;
|
||||||
|
|
||||||
if (request->chdir) {
|
if (request->chdir) {
|
||||||
@@ -165,35 +281,89 @@ extern "C" ssize_t posix_spawn_bun(
|
|||||||
if (!envp)
|
if (!envp)
|
||||||
envp = environ;
|
envp = environ;
|
||||||
|
|
||||||
if (bun_close_range(current_max_fd + 1, ~0U, CLOSE_RANGE_CLOEXEC) != 0) {
|
// Close all fds > current_max_fd, preferring cloexec if available
|
||||||
bun_close_range(current_max_fd + 1, ~0U, 0);
|
closeRangeOrLoop(current_max_fd + 1, INT_MAX, true);
|
||||||
}
|
|
||||||
if (execve(path, argv, envp) == -1) {
|
if (execve(path, argv, envp) == -1) {
|
||||||
return childFailed();
|
return childFailed();
|
||||||
}
|
}
|
||||||
_exit(127);
|
rawExit(127);
|
||||||
|
|
||||||
// should never be reached.
|
// should never be reached.
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (child == 0) {
|
if (child == 0) {
|
||||||
|
#if OS(DARWIN)
|
||||||
|
// Close read end in child
|
||||||
|
close(errpipe[0]);
|
||||||
|
#endif
|
||||||
return startChild();
|
return startChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child != -1) {
|
#if OS(DARWIN)
|
||||||
res = status;
|
// 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) {
|
if (pid) {
|
||||||
*pid = child;
|
*pid = child;
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
|
// fork() failed
|
||||||
|
close(errpipe[0]);
|
||||||
res = errno;
|
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);
|
sigprocmask(SIG_SETMASK, &oldmask, 0);
|
||||||
pthread_setcancelstate(cs, 0);
|
pthread_setcancelstate(cs, 0);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub const Classes = struct {
|
|||||||
pub const ServerWebSocket = api.ServerWebSocket;
|
pub const ServerWebSocket = api.ServerWebSocket;
|
||||||
pub const Subprocess = api.Subprocess;
|
pub const Subprocess = api.Subprocess;
|
||||||
pub const ResourceUsage = api.Subprocess.ResourceUsage;
|
pub const ResourceUsage = api.Subprocess.ResourceUsage;
|
||||||
|
pub const Terminal = api.Terminal;
|
||||||
pub const TCPSocket = api.TCPSocket;
|
pub const TCPSocket = api.TCPSocket;
|
||||||
pub const TLSSocket = api.TLSSocket;
|
pub const TLSSocket = api.TLSSocket;
|
||||||
pub const UDPSocket = api.UDPSocket;
|
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