refactor: extract shared shell spawn logic into shellRunner.ts (#572)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
135
src/tools/impl/shellRunner.ts
Normal file
135
src/tools/impl/shellRunner.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user