diff options
Diffstat (limited to 'packages/exec-backend/src/local.ts')
| -rw-r--r-- | packages/exec-backend/src/local.ts | 216 |
1 files changed, 108 insertions, 108 deletions
diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts index ca88a11..1812e5d 100644 --- a/packages/exec-backend/src/local.ts +++ b/packages/exec-backend/src/local.ts @@ -20,32 +20,32 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from * 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; - } - }, - }; + 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. */ @@ -61,86 +61,86 @@ export const localExecBackend: ExecBackend = createLocalExecBackend(); * 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 }); - }); - }); + 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 }); + }); + }); } |
