summaryrefslogtreecommitdiffhomepage
path: root/packages/exec-backend/src/local.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/exec-backend/src/local.ts')
-rw-r--r--packages/exec-backend/src/local.ts216
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 });
+ });
+ });
}