// PTY adapter wraps pseudo-terminal processes for the process supervisor. import { createDeferredCore } from "../../../shared/deferred.js"; import { signalPtySessionTree } from "../../kill-tree.js"; import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js"; import { readPtyTerminalName, resolvePtyTerminalName, setPtyTerminalName, } from "../../pty-terminal-name.js"; import type { TerminalPtySubscription } from "../../terminal-pty.js"; import type { ManagedRunStdin, ProcessAdapterConstruction, SpawnProcessAdapter } from "../types.js"; import { toStringEnv } from "./env.js"; const FORCE_KILL_WAIT_FALLBACK_MS = 4000; declare const WORKER_DEPLOY_BUILD: boolean; type PtyAdapter = SpawnProcessAdapter; export async function createPtyAdapter( params: ProcessAdapterConstruction & { shell: string; args: string[]; cwd?: string; env?: NodeJS.ProcessEnv; cols?: number; rows?: number; name?: string; }, ): Promise { // Worker deploys are portable JavaScript artifacts; exec falls back to the child adapter // instead of binding the Gateway host's native PTY binary into the bundle. if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) { throw new Error("PTY is unavailable in the portable worker runtime"); } const { spawnTerminalPty } = await import("../../terminal-pty.js"); const baseEnv = params.env ? toStringEnv(params.env) : undefined; const preparedSpawn = prepareOomScoreAdjustedSpawn(params.shell, params.args, { env: baseEnv }); const terminalName = resolvePtyTerminalName( params.name ?? readPtyTerminalName(preparedSpawn.env, process.platform) ?? readPtyTerminalName(process.env, process.platform), ); const spawnEnv = preparedSpawn.env ? toStringEnv(preparedSpawn.env) : process.platform === "win32" ? toStringEnv(process.env) : undefined; // Unix node-pty rewrites child TERM from name; Windows forwards env unchanged. if (spawnEnv) { setPtyTerminalName({ env: spawnEnv, name: terminalName, platform: process.platform }); } params.assertCurrent?.(); if (params.abortSignal?.aborted) { throw new Error("PTY construction aborted"); } const pty = await spawnTerminalPty( { file: preparedSpawn.command, args: preparedSpawn.args, cwd: params.cwd, env: spawnEnv, name: terminalName, cols: params.cols ?? 120, rows: params.rows ?? 30, }, { abortSignal: params.abortSignal, assertCurrent: () => { params.assertCurrent?.(); params.beforeSpawn?.(); }, }, ); try { params.assertCurrent?.(); if (params.abortSignal?.aborted) { throw new Error("PTY construction aborted"); } } catch (error) { try { pty.kill(); } catch { // The stale PTY may already have exited while the ownership check ran. } throw error; } const cleanup = createDeferredCore(); void cleanup.promise.catch(() => {}); params.onSpawnCleanup?.(cleanup.promise); let dataListener: TerminalPtySubscription | null = null; let exitListener: TerminalPtySubscription | null = null; const completion = createDeferredCore<{ code: number | null; signal: NodeJS.Signals | number | null; }>(); let waitSettled = false; let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null; let stdinDestroyed = false; let stdinEnded = false; const clearForceKillWaitFallback = () => { if (!forceKillWaitFallbackTimer) { return; } clearTimeout(forceKillWaitFallbackTimer); forceKillWaitFallbackTimer = null; }; const settleWait = (value: { code: number | null; signal: NodeJS.Signals | number | null }) => { if (waitSettled) { return; } waitSettled = true; clearForceKillWaitFallback(); stdinDestroyed = true; stdinEnded = true; completion.resolve(value); }; const scheduleForceKillWaitFallback = (signal: NodeJS.Signals) => { clearForceKillWaitFallback(); // Some PTY hosts fail to emit onExit after kill; use a delayed fallback // so callers can still unblock without marking termination immediately. forceKillWaitFallbackTimer = setTimeout(() => { cleanup.reject(new Error("PTY cleanup could not be confirmed before the kill deadline")); settleWait({ code: null, signal }); }, FORCE_KILL_WAIT_FALLBACK_MS); forceKillWaitFallbackTimer.unref(); }; exitListener = pty.onExit((event) => { cleanup.resolve(); const signal = event.signal && event.signal !== 0 ? event.signal : null; settleWait({ code: event.exitCode ?? null, signal }); }) ?? null; const stdin: ManagedRunStdin = { get destroyed() { return stdinDestroyed; }, get writable() { return !stdinDestroyed && !stdinEnded; }, get writableEnded() { return stdinEnded; }, get writableFinished() { return stdinEnded; }, write: (data, cb) => { try { pty.write(data); cb?.(null); } catch (err) { cb?.(err as Error); } }, end: () => { try { stdinEnded = true; const eof = process.platform === "win32" ? "\x1a" : "\x04"; pty.write(eof); } catch { // ignore EOF errors } }, destroy: () => { stdinDestroyed = true; stdinEnded = true; }, }; const onStdout = (listener: (chunk: string) => void) => { dataListener = pty.onData((chunk) => { listener(chunk); }) ?? null; }; const onStderr = (_listener: (chunk: string) => void) => { // PTY gives a unified output stream. }; const wait = async () => await completion.promise; const kill = (signal: NodeJS.Signals = "SIGKILL") => { try { if ( (signal === "SIGKILL" || signal === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0 ) { signalPtySessionTree(pty.pid, signal); } else { pty.kill(signal); } } catch { // ignore kill errors } if (signal === "SIGKILL") { scheduleForceKillWaitFallback(signal); } }; const dispose = () => { stdinDestroyed = true; stdinEnded = true; try { dataListener?.dispose(); } catch { // ignore disposal errors } try { exitListener?.dispose(); } catch { // ignore disposal errors } clearForceKillWaitFallback(); dataListener = null; exitListener = null; settleWait({ code: null, signal: null }); }; return { pid: pty.pid || undefined, stdin, oomScoreWrapperSelected: preparedSpawn.wrapped, supportsRawOutput: false, onStdout, onStderr, wait, kill, dispose, }; }