164 lines
4.6 KiB
TypeScript
164 lines
4.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
|
|
export class ShellExecutionError extends Error {
|
|
code?: string;
|
|
executable?: string;
|
|
}
|
|
|
|
export type ShellSpawnOptions = {
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
timeoutMs: number;
|
|
signal?: AbortSignal;
|
|
onOutput?: (chunk: string, stream: "stdout" | "stderr") => void;
|
|
};
|
|
|
|
const ABORT_KILL_TIMEOUT_MS = 2000;
|
|
|
|
/**
|
|
* Spawn a command with a specific launcher.
|
|
* Returns a promise that resolves with the output or rejects with an error.
|
|
*/
|
|
export function spawnWithLauncher(
|
|
launcher: string[],
|
|
options: ShellSpawnOptions,
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
|
return new Promise((resolve, reject) => {
|
|
const [executable, ...args] = launcher;
|
|
if (!executable) {
|
|
reject(new ShellExecutionError("Executable is required"));
|
|
return;
|
|
}
|
|
|
|
const childProcess = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
shell: false,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
// On Unix, detached creates a new process group for clean termination
|
|
// On Windows, detached creates a new console window which we don't want
|
|
detached: process.platform !== "win32",
|
|
});
|
|
|
|
// Helper to kill the entire process group
|
|
const killProcessGroup = (signal: "SIGTERM" | "SIGKILL") => {
|
|
if (childProcess.pid) {
|
|
try {
|
|
if (process.platform !== "win32") {
|
|
// Unix: kill the process group using negative PID
|
|
process.kill(-childProcess.pid, signal);
|
|
} else {
|
|
// Windows: process groups work differently, just kill the child
|
|
childProcess.kill(signal);
|
|
}
|
|
} catch {
|
|
// Process may already be dead, try killing just the child
|
|
try {
|
|
childProcess.kill(signal);
|
|
} catch {
|
|
// Already dead, ignore
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
let timedOut = false;
|
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Only set timeout if timeoutMs > 0 (0 means no timeout)
|
|
const timeoutId = options.timeoutMs
|
|
? setTimeout(() => {
|
|
timedOut = true;
|
|
killProcessGroup("SIGTERM");
|
|
}, options.timeoutMs)
|
|
: null;
|
|
|
|
const abortHandler = () => {
|
|
killProcessGroup("SIGTERM");
|
|
if (!killTimer) {
|
|
killTimer = setTimeout(() => {
|
|
if (childProcess.exitCode === null && !childProcess.killed) {
|
|
killProcessGroup("SIGKILL");
|
|
}
|
|
}, ABORT_KILL_TIMEOUT_MS);
|
|
}
|
|
};
|
|
if (options.signal) {
|
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
childProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
stdoutChunks.push(chunk);
|
|
options.onOutput?.(chunk.toString("utf8"), "stdout");
|
|
});
|
|
|
|
childProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
stderrChunks.push(chunk);
|
|
options.onOutput?.(chunk.toString("utf8"), "stderr");
|
|
});
|
|
|
|
childProcess.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (killTimer) {
|
|
clearTimeout(killTimer);
|
|
killTimer = null;
|
|
}
|
|
if (options.signal) {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
}
|
|
|
|
const execError = new ShellExecutionError(
|
|
err?.code === "ENOENT"
|
|
? `Executable not found: ${executable}`
|
|
: `Failed to execute command: ${err?.message || "unknown error"}`,
|
|
);
|
|
execError.code = err?.code;
|
|
execError.executable = executable;
|
|
reject(execError);
|
|
});
|
|
|
|
childProcess.on("close", (code) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (killTimer) {
|
|
clearTimeout(killTimer);
|
|
killTimer = null;
|
|
}
|
|
if (options.signal) {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
}
|
|
|
|
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
|
|
if (timedOut) {
|
|
reject(
|
|
Object.assign(new Error("Command timed out"), {
|
|
killed: true,
|
|
signal: "SIGTERM",
|
|
stdout,
|
|
stderr,
|
|
code,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (options.signal?.aborted) {
|
|
reject(
|
|
Object.assign(new Error("The operation was aborted"), {
|
|
name: "AbortError",
|
|
code: "ABORT_ERR",
|
|
stdout,
|
|
stderr,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
resolve({ stdout, stderr, exitCode: code });
|
|
});
|
|
});
|
|
}
|