diff options
Diffstat (limited to 'packages/exec-backend/src/local.ts')
| -rw-r--r-- | packages/exec-backend/src/local.ts | 146 |
1 files changed, 146 insertions, 0 deletions
diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts new file mode 100644 index 0000000..ca88a11 --- /dev/null +++ b/packages/exec-backend/src/local.ts @@ -0,0 +1,146 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { access, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; + +/** + * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`. + * + * Behavior is IDENTICAL to today's local tools: + * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same + * `sh -c` invocation, detached process-group kill on abort/timeout, + * close-based resolution, and spawn-error → `{ exitCode: 1 }`. + * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises` + * calls (utf8, `withFileTypes`) the tools make inline today, and throw the + * same node errors (carrying `.code`) so the tools' existing error branches + * work unchanged. + * - `exists` swallows all errors and returns `false` (an existence check). + * + * This factors the inline node calls out behind the `ExecBackend` interface so + * a remote (SshExecBackend) can swap in transparently. Stateless — safe to + * share as a singleton. + */ +export function createLocalExecBackend(): ExecBackend { + return { + spawn: localSpawn, + + readFile: (path) => readFile(path, "utf8"), + + writeFile: (path, content) => writeFile(path, content, "utf8"), + + stat: async (path): Promise<StatResult> => { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + + readdir: async (path): Promise<readonly DirEntry[]> => { + const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); + return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); + }, + + exists: async (path): Promise<boolean> => { + try { + await access(path); + return true; + } catch { + return false; + } + }, + }; +} + +/** Default singleton — stateless, safe to share across calls. */ +export const localExecBackend: ExecBackend = createLocalExecBackend(); + +/** + * Run a shell command locally via `node:child_process`. + * + * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so + * behavior is byte-identical: `sh -c <command>`, `detached: true` (own process + * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild + * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid + * listener/timer leaks. + */ +function localSpawn(params: SpawnParams): Promise<ExecResult> { + return new Promise<ExecResult>((resolve) => { + // detached: true puts the child in its own process group (pgid = child.pid). + // This lets us kill the entire group (child + any grandchildren that inherit + // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a + // backgrounded grandchild can't keep the stdio pipes open and stall the + // promise on child.on("close"). + const child = nodeSpawn("sh", ["-c", params.command], { + cwd: params.cwd, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let settled = false; + let timedOut = false; + let timer: ReturnType<typeof setTimeout> | undefined; + + /** Kill the entire child process group (best-effort — group may be gone). */ + const killGroup = () => { + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Process group may already be gone — ignore. + } + } + }; + + /** Remove the abort listener and clear the timeout timer (no leaks). */ + const cleanup = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + params.signal.removeEventListener("abort", onAbort); + }; + + /** Resolve once, then clean up so listeners/timers never leak. */ + const settle = (result: ExecResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const onAbort = () => { + if (settled) return; + killGroup(); + // Resolve immediately — do NOT wait for child.on("close"), which may + // never fire if a grandchild holds the pipes open. + settle({ exitCode: null, timedOut: false, aborted: true }); + }; + params.signal.addEventListener("abort", onAbort, { once: true }); + + timer = setTimeout(() => { + if (settled) return; + timedOut = true; + killGroup(); + // Resolve immediately — same reasoning as abort. + settle({ exitCode: null, timedOut: true, aborted: false }); + }, params.timeout); + + child.stdout.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stdout"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stderr"); + }); + + // Normal-completion path: wait for "close" so all stdout/stderr is captured. + // If abort/timeout already settled, this is a no-op (settled === true). + child.on("close", (code) => { + settle({ exitCode: code, timedOut, aborted: false }); + }); + + // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case + // and resolve — never leave the promise pending. + child.on("error", () => { + killGroup(); + settle({ exitCode: 1, timedOut: false, aborted: false }); + }); + }); +} |
