mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
209 lines
5.4 KiB
TypeScript
209 lines
5.4 KiB
TypeScript
import { killProcessTree } from "../../kill-tree.js";
|
|
import type { ManagedRunStdin } from "../types.js";
|
|
import { toStringEnv } from "./env.js";
|
|
|
|
const FORCE_KILL_WAIT_FALLBACK_MS = 4000;
|
|
|
|
type PtyExitEvent = { exitCode: number; signal?: number };
|
|
type PtyDisposable = { dispose: () => void };
|
|
type PtySpawnHandle = {
|
|
pid: number;
|
|
write: (data: string | Buffer) => void;
|
|
onData: (listener: (value: string) => void) => PtyDisposable | void;
|
|
onExit: (listener: (event: PtyExitEvent) => void) => PtyDisposable | void;
|
|
kill: (signal?: string) => void;
|
|
};
|
|
type PtySpawn = (
|
|
file: string,
|
|
args: string[] | string,
|
|
options: {
|
|
name?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
},
|
|
) => PtySpawnHandle;
|
|
|
|
type PtyModule = {
|
|
spawn?: PtySpawn;
|
|
default?: {
|
|
spawn?: PtySpawn;
|
|
};
|
|
};
|
|
|
|
export type PtyAdapter = {
|
|
pid?: number;
|
|
stdin?: ManagedRunStdin;
|
|
onStdout: (listener: (chunk: string) => void) => void;
|
|
onStderr: (listener: (chunk: string) => void) => void;
|
|
wait: () => Promise<{ code: number | null; signal: NodeJS.Signals | number | null }>;
|
|
kill: (signal?: NodeJS.Signals) => void;
|
|
dispose: () => void;
|
|
};
|
|
|
|
export async function createPtyAdapter(params: {
|
|
shell: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
cols?: number;
|
|
rows?: number;
|
|
name?: string;
|
|
}): Promise<PtyAdapter> {
|
|
const module = (await import("@lydell/node-pty")) as unknown as PtyModule;
|
|
const spawn = module.spawn ?? module.default?.spawn;
|
|
if (!spawn) {
|
|
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
|
}
|
|
const pty = spawn(params.shell, params.args, {
|
|
cwd: params.cwd,
|
|
env: params.env ? toStringEnv(params.env) : undefined,
|
|
name: params.name ?? process.env.TERM ?? "xterm-256color",
|
|
cols: params.cols ?? 120,
|
|
rows: params.rows ?? 30,
|
|
});
|
|
|
|
let dataListener: PtyDisposable | null = null;
|
|
let exitListener: PtyDisposable | null = null;
|
|
let waitResult: { code: number | null; signal: NodeJS.Signals | number | null } | null = null;
|
|
let resolveWait:
|
|
| ((value: { code: number | null; signal: NodeJS.Signals | number | null }) => void)
|
|
| null = null;
|
|
let waitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | number | null }> | null =
|
|
null;
|
|
let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null;
|
|
|
|
const clearForceKillWaitFallback = () => {
|
|
if (!forceKillWaitFallbackTimer) {
|
|
return;
|
|
}
|
|
clearTimeout(forceKillWaitFallbackTimer);
|
|
forceKillWaitFallbackTimer = null;
|
|
};
|
|
|
|
const settleWait = (value: { code: number | null; signal: NodeJS.Signals | number | null }) => {
|
|
if (waitResult) {
|
|
return;
|
|
}
|
|
clearForceKillWaitFallback();
|
|
waitResult = value;
|
|
if (resolveWait) {
|
|
const resolve = resolveWait;
|
|
resolveWait = null;
|
|
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(() => {
|
|
settleWait({ code: null, signal });
|
|
}, FORCE_KILL_WAIT_FALLBACK_MS);
|
|
forceKillWaitFallbackTimer.unref();
|
|
};
|
|
|
|
exitListener =
|
|
pty.onExit((event) => {
|
|
const signal = event.signal && event.signal !== 0 ? event.signal : null;
|
|
settleWait({ code: event.exitCode ?? null, signal });
|
|
}) ?? null;
|
|
|
|
const stdin: ManagedRunStdin = {
|
|
destroyed: false,
|
|
write: (data, cb) => {
|
|
try {
|
|
pty.write(data);
|
|
cb?.(null);
|
|
} catch (err) {
|
|
cb?.(err as Error);
|
|
}
|
|
},
|
|
end: () => {
|
|
try {
|
|
const eof = process.platform === "win32" ? "\x1a" : "\x04";
|
|
pty.write(eof);
|
|
} catch {
|
|
// ignore EOF errors
|
|
}
|
|
},
|
|
};
|
|
|
|
const onStdout = (listener: (chunk: string) => void) => {
|
|
dataListener =
|
|
pty.onData((chunk) => {
|
|
listener(chunk.toString());
|
|
}) ?? null;
|
|
};
|
|
|
|
const onStderr = (_listener: (chunk: string) => void) => {
|
|
// PTY gives a unified output stream.
|
|
};
|
|
|
|
const wait = async () => {
|
|
if (waitResult) {
|
|
return waitResult;
|
|
}
|
|
if (!waitPromise) {
|
|
waitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | number | null }>(
|
|
(resolve) => {
|
|
resolveWait = resolve;
|
|
if (waitResult) {
|
|
const settled = waitResult;
|
|
resolveWait = null;
|
|
resolve(settled);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
return waitPromise;
|
|
};
|
|
|
|
const kill = (signal: NodeJS.Signals = "SIGKILL") => {
|
|
try {
|
|
if (signal === "SIGKILL" && typeof pty.pid === "number" && pty.pid > 0) {
|
|
killProcessTree(pty.pid);
|
|
} else if (process.platform === "win32") {
|
|
pty.kill();
|
|
} else {
|
|
pty.kill(signal);
|
|
}
|
|
} catch {
|
|
// ignore kill errors
|
|
}
|
|
|
|
if (signal === "SIGKILL") {
|
|
scheduleForceKillWaitFallback(signal);
|
|
}
|
|
};
|
|
|
|
const dispose = () => {
|
|
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,
|
|
onStdout,
|
|
onStderr,
|
|
wait,
|
|
kill,
|
|
dispose,
|
|
};
|
|
}
|