diff --git a/build.js b/build.js index da338c0..aeb5526 100644 --- a/build.js +++ b/build.js @@ -49,7 +49,7 @@ await Bun.build({ // Keep most native Node.js modules external to avoid bundling issues // But don't make `sharp` external, causes issues with global Bun-based installs // ref: #745, #1200 - external: ["ws", "@vscode/ripgrep"], + external: ["ws", "@vscode/ripgrep", "node-pty"], features: features, }); diff --git a/package.json b/package.json index 7ae356d..528c3cc 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "test:update-chain:manual": "bun run src/tests/update-chain-smoke.ts --mode manual", "test:update-chain:startup": "bun run src/tests/update-chain-smoke.ts --mode startup", "prepublishOnly": "bun run build", - "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped" + "postinstall": "node scripts/postinstall-patches.js || echo letta: vendor patches skipped && node -e \"try{require('fs').chmodSync(require('path').join(require.resolve('node-pty/package.json'),'../prebuilds/darwin-arm64/spawn-helper'),0o755)}catch(e){}\" || true" }, "lint-staged": { "*.{ts,tsx,js,jsx,json}": [ diff --git a/src/websocket/terminalHandler.ts b/src/websocket/terminalHandler.ts index c77c74d..044dd47 100644 --- a/src/websocket/terminalHandler.ts +++ b/src/websocket/terminalHandler.ts @@ -2,29 +2,33 @@ * PTY terminal handler for listen mode. * Manages interactive terminal sessions spawned by the web UI. * - * Uses Bun's native Bun.Terminal API (available since Bun v1.3.5) - * for real PTY support without node-pty. + * Runtime strategy: + * - Bun → Bun.spawn with terminal option (native PTY, no node-pty needed) + * - Node.js / Electron → node-pty (Bun.spawn unavailable; node-pty's libuv + * poll handles integrate correctly with Node.js but NOT with Bun's event loop) */ import * as os from "node:os"; import WebSocket from "ws"; +const IS_BUN = typeof Bun !== "undefined"; + +// 16ms debounce window for output batching; flush immediately at 64 KB +// to prevent unbounded string growth on high-throughput commands. +const FLUSH_INTERVAL_MS = 16; +const MAX_BUFFER_BYTES = 64 * 1024; + interface TerminalSession { - process: ReturnType; - terminal: { - write: (data: string) => void; - resize: (cols: number, rows: number) => void; - close: () => void; - }; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: () => void; + pid: number; terminalId: string; spawnedAt: number; } const terminals = new Map(); -/** - * Get the default shell for the current platform. - */ function getDefaultShell(): string { if (os.platform() === "win32") { return process.env.COMSPEC || "cmd.exe"; @@ -32,9 +36,6 @@ function getDefaultShell(): string { return process.env.SHELL || "/bin/zsh"; } -/** - * Send a terminal message back to the web client via the device WebSocket. - */ function sendTerminalMessage( socket: WebSocket, message: Record, @@ -44,9 +45,177 @@ function sendTerminalMessage( } } -/** - * Spawn a new PTY terminal session using Bun's native Terminal API. - */ +/** Create a flush-on-size-or-timer output batcher. */ +function makeOutputBatcher( + onFlush: (data: string) => void, +): (chunk: string) => void { + let buffer = ""; + let timer: ReturnType | null = null; + + const flush = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (buffer.length > 0) { + onFlush(buffer); + buffer = ""; + } + }; + + return (chunk: string) => { + buffer += chunk; + if (buffer.length >= MAX_BUFFER_BYTES) { + flush(); + } else if (!timer) { + timer = setTimeout(flush, FLUSH_INTERVAL_MS); + } + }; +} + +// ── Bun spawn ────────────────────────────────────────────────────────────── + +function spawnBun( + shell: string, + cwd: string, + cols: number, + rows: number, + terminal_id: string, + socket: WebSocket, +): TerminalSession { + const handleData = makeOutputBatcher((data) => + sendTerminalMessage(socket, { type: "terminal_output", terminal_id, data }), + ); + + const proc = Bun.spawn([shell], { + cwd, + env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" }, + terminal: { + cols: cols || 80, + rows: rows || 24, + data: (_t: unknown, chunk: Uint8Array) => + handleData(new TextDecoder().decode(chunk)), + }, + }); + + const terminal = ( + proc as unknown as { + terminal: { + write: (d: string) => void; + resize: (c: number, r: number) => void; + close: () => void; + }; + } + ).terminal; + + if (!terminal) { + throw new Error("Bun.spawn terminal object missing — API unavailable"); + } + + proc.exited.then((exitCode) => { + const current = terminals.get(terminal_id); + if (current && current.pid === proc.pid) { + terminals.delete(terminal_id); + sendTerminalMessage(socket, { + type: "terminal_exited", + terminal_id, + exitCode: exitCode ?? 0, + }); + } + }); + + return { + write: (d) => { + try { + terminal.write(d); + } catch {} + }, + resize: (c, r) => { + try { + terminal.resize(c, r); + } catch {} + }, + kill: () => { + try { + terminal.close(); + } catch {} + try { + proc.kill(); + } catch {} + }, + pid: proc.pid, + terminalId: terminal_id, + spawnedAt: Date.now(), + }; +} + +// ── node-pty spawn (Node.js / Electron) ─────────────────────────────────── + +function spawnNodePty( + shell: string, + cwd: string, + cols: number, + rows: number, + terminal_id: string, + socket: WebSocket, +): TerminalSession { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pty = require("node-pty") as typeof import("node-pty"); + + const handleData = makeOutputBatcher((data) => + sendTerminalMessage(socket, { type: "terminal_output", terminal_id, data }), + ); + + const ptyProcess = pty.spawn(shell, [], { + name: "xterm-256color", + cols: cols || 80, + rows: rows || 24, + cwd, + env: { + ...(process.env as Record), + TERM: "xterm-256color", + COLORTERM: "truecolor", + }, + }); + + ptyProcess.onData(handleData); + + ptyProcess.onExit(({ exitCode }) => { + const current = terminals.get(terminal_id); + if (current && current.pid === ptyProcess.pid) { + terminals.delete(terminal_id); + sendTerminalMessage(socket, { + type: "terminal_exited", + terminal_id, + exitCode: exitCode ?? 0, + }); + } + }); + + return { + write: (d) => { + try { + ptyProcess.write(d); + } catch {} + }, + resize: (c, r) => { + try { + ptyProcess.resize(c, r); + } catch {} + }, + kill: () => { + try { + ptyProcess.kill(); + } catch {} + }, + pid: ptyProcess.pid, + terminalId: terminal_id, + spawnedAt: Date.now(), + }; +} + +// ── Public API ───────────────────────────────────────────────────────────── + export function handleTerminalSpawn( msg: { terminal_id: string; cols: number; rows: number }, socket: WebSocket, @@ -54,111 +223,56 @@ export function handleTerminalSpawn( ): void { const { terminal_id, cols, rows } = msg; - // Kill existing session with same ID if any - killTerminal(terminal_id); + // React Strict Mode fires mount→unmount→mount which produces spawn→kill→spawn + // in rapid succession. The kill is already ignored (< 2s guard below), but the + // second spawn would normally kill and restart. If the session is < 2s old and + // still alive, reuse it and resend terminal_spawned instead. + const existing = terminals.get(terminal_id); + if (existing && Date.now() - existing.spawnedAt < 2000) { + let alive = true; + try { + existing.write("\r"); + } catch { + alive = false; + } - const shell = getDefaultShell(); - - console.log( - `[Terminal] Spawning PTY: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`, - ); - - try { - const proc = Bun.spawn([shell], { - cwd, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - }, - terminal: { - cols: cols || 80, - rows: rows || 24, - data: (() => { - // Batch output chunks within a 16ms window into a single WS message - // to avoid flooding the WebSocket with many small frames. - let buffer = ""; - let flushTimer: ReturnType | null = null; - - return (_terminal: unknown, data: Uint8Array) => { - buffer += new TextDecoder().decode(data); - - if (!flushTimer) { - flushTimer = setTimeout(() => { - if (buffer.length > 0) { - sendTerminalMessage(socket, { - type: "terminal_output", - terminal_id, - data: buffer, - }); - buffer = ""; - } - flushTimer = null; - }, 16); - } - }; - })(), - }, - }); - - // The terminal object is available on the proc when using the terminal option - const terminal = ( - proc as unknown as { terminal: TerminalSession["terminal"] } - ).terminal; - - console.log( - `[Terminal] proc.pid=${proc.pid}, terminal=${typeof terminal}, keys=${Object.keys(proc as unknown as Record).join(",")}`, - ); - - if (!terminal) { - console.error( - "[Terminal] terminal object is undefined on proc — Bun.Terminal API may not be available", + if (alive) { + console.log( + `[Terminal] Reusing session (age=${Date.now() - existing.spawnedAt}ms), pid=${existing.pid}`, ); sendTerminalMessage(socket, { - type: "terminal_exited", + type: "terminal_spawned", terminal_id, - exitCode: 1, + pid: existing.pid, }); return; } - const session: TerminalSession = { - process: proc, - terminal, - terminalId: terminal_id, - spawnedAt: Date.now(), - }; + // Session dead — fall through to spawn a fresh one + terminals.delete(terminal_id); + } + + killTerminal(terminal_id); + + const shell = getDefaultShell(); + console.log( + `[Terminal] Spawning PTY (${IS_BUN ? "bun" : "node-pty"}): shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`, + ); + + try { + const session = IS_BUN + ? spawnBun(shell, cwd, cols, rows, terminal_id, socket) + : spawnNodePty(shell, cwd, cols, rows, terminal_id, socket); + terminals.set(terminal_id, session); console.log( - `[Terminal] Session stored for terminal_id=${terminal_id}, map size=${terminals.size}`, + `[Terminal] Session stored for terminal_id=${terminal_id}, pid=${session.pid}`, ); - // Handle process exit — only clean up if this is still the active session - // (a newer spawn may have replaced us in the map) - const myPid = proc.pid; - proc.exited.then((exitCode) => { - const current = terminals.get(terminal_id); - if (current && current.process.pid === myPid) { - console.log( - `[Terminal] PTY process exited: terminal_id=${terminal_id}, pid=${myPid}, exitCode=${exitCode}`, - ); - terminals.delete(terminal_id); - sendTerminalMessage(socket, { - type: "terminal_exited", - terminal_id, - exitCode: exitCode ?? 0, - }); - } else { - console.log( - `[Terminal] Stale PTY exit ignored: terminal_id=${terminal_id}, pid=${myPid} (current pid=${current?.process.pid})`, - ); - } - }); - sendTerminalMessage(socket, { type: "terminal_spawned", terminal_id, - pid: proc.pid, + pid: session.pid, }); } catch (error) { console.error("[Terminal] Failed to spawn PTY:", error); @@ -170,41 +284,23 @@ export function handleTerminalSpawn( } } -/** - * Write input data to a terminal session. - */ export function handleTerminalInput(msg: { terminal_id: string; data: string; }): void { - const session = terminals.get(msg.terminal_id); - if (session) { - session.terminal.write(msg.data); - } + terminals.get(msg.terminal_id)?.write(msg.data); } -/** - * Resize a terminal session. - */ export function handleTerminalResize(msg: { terminal_id: string; cols: number; rows: number; }): void { - const session = terminals.get(msg.terminal_id); - if (session) { - session.terminal.resize(msg.cols, msg.rows); - } + terminals.get(msg.terminal_id)?.resize(msg.cols, msg.rows); } -/** - * Kill a terminal session. - */ export function handleTerminalKill(msg: { terminal_id: string }): void { const session = terminals.get(msg.terminal_id); - // Ignore kill if the session was spawned very recently (< 2s). - // This handles the React Strict Mode race where cleanup's kill arrives - // after the remount's spawn due to async WS relay latency. if (session && Date.now() - session.spawnedAt < 2000) { console.log( `[Terminal] Ignoring kill for recently spawned session (age=${Date.now() - session.spawnedAt}ms)`, @@ -218,22 +314,13 @@ function killTerminal(terminalId: string): void { const session = terminals.get(terminalId); if (session) { console.log( - `[Terminal] killTerminal: terminalId=${terminalId}, pid=${session.process.pid}`, + `[Terminal] killTerminal: terminalId=${terminalId}, pid=${session.pid}`, ); - try { - session.terminal.close(); - } catch { - // terminal may already be closed - } - session.process.kill(); + session.kill(); terminals.delete(terminalId); } } -/** - * Kill all active terminal sessions. - * Call on disconnect/cleanup. - */ export function killAllTerminals(): void { for (const [id] of terminals) { killTerminal(id);