refactor: extract shared shell spawn logic into shellRunner.ts (#572)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-16 19:40:45 -08:00
committed by GitHub
parent 0430f42887
commit 00cb68689e
7 changed files with 225 additions and 215 deletions

View File

@@ -0,0 +1,135 @@
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"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let timedOut = false;
let killTimer: ReturnType<typeof setTimeout> | null = null;
const timeoutId = setTimeout(() => {
timedOut = true;
childProcess.kill("SIGTERM");
}, options.timeoutMs);
const abortHandler = () => {
childProcess.kill("SIGTERM");
if (!killTimer) {
killTimer = setTimeout(() => {
if (childProcess.exitCode === null && !childProcess.killed) {
childProcess.kill("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) => {
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) => {
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 });
});
});
}