import { execFile } from "node:child_process"; import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { execFileBytes, execFileOutput, execFileOutputFailure, } from "./ExecFileOutput.js"; const realSetTimeout = globalThis.setTimeout; const waitForFile = async (path: string): Promise => { for (let attempt = 0; attempt < 500; attempt += 1) { if ( await access(path).then( () => true, () => false, ) ) return true; await new Promise((resolve) => realSetTimeout(resolve, 10)); } return access(path).then( () => true, () => false, ); }; it.each([undefined, "SIGTERM"] as const)( "retains native spawn error facts with stop signal %s", async (stopSignal) => { const directory = await createTestTempDirectory("rea-exec-missing-"); const command = join(directory, "missing-executable"); await expect( execFileBytes( command, ["operand"], stopSignal === undefined ? {} : { stopSignal }, ), ).rejects.toMatchObject({ code: "ENOENT", path: command, spawnargs: ["operand"], cmd: `${command} operand`, stdout: "", stderr: "", }); }, ); it.each([undefined, "SIGTERM"] as const)( "closes unused command input with stop signal %s", async (stopSignal) => { const result = await execFileOutput( process.execPath, [ "-e", 'process.stdin.resume(); process.stdin.on("end", () => process.stdout.write("input closed"));', ], { timeout: 2_000, ...(stopSignal === undefined ? {} : { stopSignal }) }, ); expect(result.stdout).toBe("input closed"); }, ); describe("execFileOutput", () => { it("ignores non-Error and incomplete failure metadata", () => { expect(execFileOutputFailure("failure")).toBeUndefined(); expect(execFileOutputFailure(new Error("failure"))).toBeUndefined(); }); it("recognizes the execFile output limit code as truncated output", () => { const failure = Object.assign(new Error("output limit"), { stdout: "partial stdout", stderr: "partial stderr", code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", }); expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true); }); it.each([undefined, "SIGTERM"] as const)( "preserves captured bytes with stop signal %s", async (stopSignal) => { const result = await execFileBytes( process.execPath, [ "-e", "process.stdout.write(Buffer.from([0x00, 0xff, 0x41])); process.stderr.write(Buffer.from([0xfe]));", ], stopSignal === undefined ? {} : { stopSignal }, ); expect( Buffer.compare(result.stdout, Buffer.from([0x00, 0xff, 0x41])), ).toBe(0); expect(Buffer.compare(result.stderr, Buffer.from([0xfe]))).toBe(0); }, ); it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => { const result = await execFileOutput(process.execPath, [ "-e", "process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac]));", ]); expect(result).toMatchObject({ stdout: "a�", stderr: "€", stdoutBytes: 2, stderrBytes: 3, }); }); it("captures output larger than Node's default execFile limit", async () => { const marker = "tail-marker"; const result = await execFileOutput(process.execPath, [ "-e", `process.stdout.write("x".repeat(2 * 1024 * 1024)); process.stdout.write(${JSON.stringify(marker)});`, ]); expect(result.stdout).toHaveLength(2 * 1024 * 1024 + marker.length); expect(result.stdout.endsWith(marker)).toBe(true); }); it("enforces a caller supplied output bound", async () => { await expect( execFileOutput( process.execPath, ["-e", 'process.stdout.write("x".repeat(1024))'], { maxBuffer: 64 }, ), ).rejects.toMatchObject({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }); }); it.skipIf(process.platform === "win32").each(["stdout", "stderr"] as const)( "escalates %s overflow and preserves capacity failure when the timeout follows", async (stream) => { const directory = await createTestTempDirectory("rea-exec-overflow-"); const identity = join(directory, "pid"); const overflowSignal = join(directory, "overflow-signal"); const script = [ 'const fs = require("node:fs");', `process.on("SIGTERM", () => fs.writeFileSync(${JSON.stringify(overflowSignal)}, ""));`, `fs.writeFileSync(${JSON.stringify(identity)}, String(process.pid));`, "setInterval(() => {}, 1000);", `process.${stream}.write("x".repeat(4096));`, ].join("\n"); // Hold the parent's deadline until the real child confirms overflow's // stop signal. Startup speed cannot reorder the tested interruptions. vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const execution = execFileOutput(process.execPath, ["-e", script], { maxBuffer: 64, stopSignal: "SIGTERM", timeout: 1_000, }).catch((cause: unknown) => cause); try { expect(await waitForFile(overflowSignal)).toBe(true); await vi.advanceTimersByTimeAsync(1_000); await vi.runAllTimersAsync(); const failure: unknown = await execution; expect(execFileOutputFailure(failure)).toMatchObject({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", outputTruncated: true, [`${stream}Bytes`]: 64, }); const pid = Number(await readFile(identity, "utf8")); expect(() => process.kill(pid, 0)).toThrow(); } finally { await vi.runAllTimersAsync(); vi.useRealTimers(); await execution; } }, 10_000, ); it.skipIf(process.platform === "win32")( "preserves earlier cancellation and reports output truncated during stop", async () => { const directory = await createTestTempDirectory( "rea-exec-abort-overflow-", ); const ready = join(directory, "ready"); const script = [ 'const fs = require("node:fs");', 'process.on("SIGTERM", () => { process.stdout.write("x".repeat(4096)); process.exit(0); });', `fs.writeFileSync(${JSON.stringify(ready)}, "");`, "setInterval(() => {}, 1000);", ].join("\n"); const controller = new AbortController(); const reason = new Error("caller stopped before overflow"); const execution = execFileOutput(process.execPath, ["-e", script], { maxBuffer: 64, stopSignal: "SIGTERM", signal: controller.signal, }).catch((cause: unknown) => cause); const readyObserved = await waitForFile(ready); controller.abort(reason); const failure: unknown = await execution; expect(readyObserved).toBe(true); expect(failure).toMatchObject({ _tag: "ExecFileOutputCancelledError", cause: reason, }); expect(execFileOutputFailure(failure)).toMatchObject({ outputTruncated: true, stdoutBytes: 64, }); }, ); it("retains both captured streams on a failed child process", async () => { await expect( execFileOutput(process.execPath, [ "-e", 'process.stdout.write("out"); process.stderr.write("err"); process.exitCode = 7;', ]), ).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" }); }); it("uses the built-in execFile timeout when no stop signal is requested", async () => { await expect( execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeout: 100, }), ).rejects.toMatchObject({ killed: true }); }); }); describe("execFileOutput cancellation lifecycle", () => { it .skipIf(process.platform === "win32") .each([ null, "stop after readiness", new Error("stop after readiness"), ] as const)( "stops an aborted process with exact %s reason and settles after it exits", async (reason) => { const directory = await createTestTempDirectory("rea-exec-stop-"); const ready = join(directory, "ready"); const stopped = join(directory, "stopped"); // The child records the signal it received only after a cleanup delay. const script = [ 'const fs = require("node:fs");', 'for (const name of ["SIGINT", "SIGTERM"])', ` process.on(name, () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(stopped)}, name); process.exit(0); }, 200));`, `fs.writeFileSync(${JSON.stringify(ready)}, "");`, "setInterval(() => {}, 1000);", ].join("\n"); const controller = new AbortController(); const execution = execFileOutput(process.execPath, ["-e", script], { signal: controller.signal, stopSignal: "SIGINT", }).then( () => undefined, async (cause: unknown) => ({ cause, stoppedBeforeSettling: await readFile(stopped, "utf8").catch( () => null, ), }), ); const readyObserved = await waitForFile(ready); controller.abort(reason); const outcome = await execution; expect(readyObserved).toBe(true); expect(outcome?.cause).toMatchObject({ name: "AbortError", _tag: "ExecFileOutputCancelledError", }); if (!(outcome?.cause instanceof Error)) throw new Error("Expected cancellation failure"); expect(outcome.cause.cause).toBe(reason); expect(execFileOutputFailure(outcome.cause)).toBeDefined(); expect(outcome?.stoppedBeforeSettling).toBe("SIGINT"); }, ); it.skipIf(process.platform === "win32")( "keeps the first stop reason when timeout arrives during abort cleanup", async () => { const directory = await createTestTempDirectory("rea-exec-double-stop-"); const ready = join(directory, "ready"); const script = [ 'const fs = require("node:fs");', 'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});', `fs.writeFileSync(${JSON.stringify(ready)}, "");`, "setInterval(() => {}, 1000);", ].join("\n"); const controller = new AbortController(); const execution = execFileOutput(process.execPath, ["-e", script], { signal: controller.signal, timeout: 10_000, stopSignal: "SIGINT", }); const startedAt = Date.now(); expect(await waitForFile(ready)).toBe(true); const remainingBeforeDeadline = Math.max( 0, 9_000 - (Date.now() - startedAt), ); await new Promise((resolve) => setTimeout(resolve, remainingBeforeDeadline), ); controller.abort(); await expect(execution).rejects.toMatchObject({ name: "AbortError" }); }, ); it("rejects an already aborted stoppable command without starting it", async () => { const controller = new AbortController(); controller.abort(); await expect( execFileOutput(process.execPath, ["-e", ""], { signal: controller.signal, stopSignal: "SIGINT", }), ).rejects.toMatchObject({ name: "AbortError" }); }); }); describe.skipIf(process.platform === "win32")( "execFileOutput stop signal at a timeout", () => { it("stops a timed-out process with the caller's signal and reports the timeout", async () => { const directory = await createTestTempDirectory("rea-exec-timeout-"); const stopped = join(directory, "stopped"); // The child exits cleanly once it has recorded the signal it received. const script = [ 'const fs = require("node:fs");', 'for (const name of ["SIGINT", "SIGTERM"])', ` process.on(name, () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(stopped)}, name); process.exit(0); }, 200));`, "setInterval(() => {}, 1000);", ].join("\n"); const failure: unknown = await execFileOutput( process.execPath, ["-e", script], { timeout: 2_000, stopSignal: "SIGINT" }, ).then( () => undefined, (cause: unknown) => cause, ); expect(failure).toMatchObject({ code: "ETIMEDOUT", killed: true }); expect(await readFile(stopped, "utf8")).toBe("SIGINT"); }); it.each([-1, Number.NaN, 1.5, Number.POSITIVE_INFINITY, 2 ** 53])( "refuses a malformed timeout (%s) in both modes before starting the command", async (timeout) => { // Starting this command would fail with ENOENT instead. await expect( execFileOutput("/nonexistent/rea-command", [], { timeout, stopSignal: "SIGINT", }), ).rejects.toMatchObject({ code: "ERR_OUT_OF_RANGE" }); await expect( execFileOutput("/nonexistent/rea-command", [], { timeout }), ).rejects.toMatchObject({ code: "ERR_OUT_OF_RANGE" }); }, ); it("preserves a child failure's missing signal when reporting timeout", async () => { const directory = await createTestTempDirectory( "rea-exec-missing-signal-", ); const ready = join(directory, "ready"); const script = [ 'const fs = require("node:fs");', 'for (const name of ["SIGINT", "SIGTERM"])', " process.on(name, () => process.exit(1));", `fs.writeFileSync(${JSON.stringify(ready)}, "");`, "setInterval(() => {}, 1000);", ].join("\n"); try { const execution = execFileOutput(process.execPath, ["-e", script], { timeout: 10_000, stopSignal: "SIGINT", }); expect(await waitForFile(ready)).toBe(true); await expect(execution).rejects.toMatchObject({ code: "ETIMEDOUT", signal: null, }); } finally { await rm(directory, { recursive: true, force: true }); } }); it("kills a timed-out process that ignores the stop signal", async () => { const script = [ 'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});', "setInterval(() => {}, 1000);", ].join("\n"); await expect( execFileOutput(process.execPath, ["-e", script], { timeout: 1_000, stopSignal: "SIGINT", }), ).rejects.toMatchObject({ code: "ETIMEDOUT", killed: true, signal: "SIGKILL", }); }); }, ); const descendantCommand = async (pid: number): Promise => { if (process.platform === "linux") return readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => ""); return new Promise((resolve) => { execFile( "ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }, (error, stdout) => { resolve(error ? "" : stdout); }, ); }); }; /** Signal only a still-running descendant whose command line contains `marker`. */ const stopOwnedDescendant = async ( pid: number, marker: string, ): Promise => { if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return undefined; if (!(await descendantCommand(pid)).includes(marker)) return undefined; try { process.kill(pid, "SIGKILL"); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ESRCH") return undefined; return cause; } return undefined; }; describe.skipIf(process.platform === "win32")( "execFileOutput inherited pipes", () => { it("keeps overflowing command scratch files until its inherited-pipe descendant finishes", async () => { const directory = await createTestTempDirectory( "rea-exec-overflow-pipes-", ); const scratch = join(directory, "scratch"); const ready = join(directory, "descendant.pid"); const stopped = join(directory, "launcher-stopped"); const release = join(directory, "release"); const finished = join(directory, "finished.json"); await mkdir(scratch); const childSource = [ 'const fs = require("node:fs");', `fs.writeFileSync(${JSON.stringify(ready)}, String(process.pid));`, "setInterval(() => {", ` if (!fs.existsSync(${JSON.stringify(release)})) return;`, ` fs.writeFileSync(${JSON.stringify(finished)}, JSON.stringify({ scratchExists: fs.existsSync(${JSON.stringify(scratch)}) }));`, ' process.stdout.write("late output");', " process.exit(0);", "}, 10);", ].join("\n"); const script = [ 'const { spawn } = require("node:child_process");', 'const fs = require("node:fs");', `process.on("SIGTERM", () => { fs.writeFileSync(${JSON.stringify(stopped)}, ""); process.exit(0); });`, `spawn(process.execPath, ["-e", ${JSON.stringify(childSource)}], { stdio: "inherit" });`, "const timer = setInterval(() => {", ` if (!fs.existsSync(${JSON.stringify(ready)})) return;`, " clearInterval(timer);", ' process.stdout.write("x".repeat(4096));', "}, 10);", ].join("\n"); const controller = new AbortController(); let completed = false; const execution = execFileOutput(process.execPath, ["-e", script], { maxBuffer: 64, stopSignal: "SIGTERM", signal: controller.signal, }) .finally(async () => { completed = true; await rm(scratch, { recursive: true, force: true }); }) .catch((cause: unknown) => cause); let killFailure: unknown; try { expect(await waitForFile(stopped)).toBe(true); const pid = Number(await readFile(ready, "utf8")); expect((await descendantCommand(pid)).includes(finished)).toBe(true); expect(completed).toBe(false); await expect(access(scratch)).resolves.toBeUndefined(); await writeFile(release, ""); const failure: unknown = await execution; expect(execFileOutputFailure(failure)).toMatchObject({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", outputTruncated: true, stdoutBytes: 64, }); expect(JSON.parse(await readFile(finished, "utf8"))).toEqual({ scratchExists: true, }); await expect(access(scratch)).rejects.toMatchObject({ code: "ENOENT" }); } finally { await writeFile(release, ""); controller.abort(); await execution; const pid = Number(await readFile(ready, "utf8").catch(() => "")); killFailure = await stopOwnedDescendant(pid, finished); } if (killFailure !== undefined) throw killFailure; }, 15_000); it("keeps scratch files until an inherited-pipe descendant closes them", async () => { const directory = await createTestTempDirectory("rea-exec-inherited-"); const scratch = join(directory, "scratch"); const finished = join(directory, "finished.json"); const pidPath = join(directory, "child.pid"); await mkdir(scratch); const childSource = [ 'const fs = require("node:fs");', "setTimeout(() => {", ` fs.writeFileSync(${JSON.stringify(finished)}, JSON.stringify({ scratchExists: fs.existsSync(${JSON.stringify(scratch)}) }));`, " process.exit(0);", "}, 7000);", ].join("\n"); const script = [ 'const { spawn } = require("node:child_process");', 'const fs = require("node:fs");', `const child = spawn(process.execPath, ["-e", ${JSON.stringify(childSource)}], { stdio: "inherit" });`, `fs.writeFileSync(${JSON.stringify(pidPath)}, String(child.pid));`, "process.exit(0);", ].join("\n"); const settled = execFileOutput(process.execPath, ["-e", script], { timeout: 200, stopSignal: "SIGTERM", }) .finally(async () => { await rm(scratch, { recursive: true, force: true }); }) .then( () => ({ ok: true as const }), (cause: unknown) => ({ ok: false as const, cause }), ); let killFailure: unknown; let descendantSettled = false; try { const deadline = Date.now() + 15_000; let text: string | undefined; while (Date.now() < deadline) { text = await readFile(finished, "utf8").then( (value) => value, (cause: unknown) => { if ( cause instanceof Error && "code" in cause && cause.code === "ENOENT" ) return undefined; throw cause; }, ); if (text !== undefined) break; await new Promise((resolve) => setTimeout(resolve, 20)); } expect(text).toBeDefined(); expect(JSON.parse(text ?? "")).toEqual({ scratchExists: true }); const outcome = await settled; descendantSettled = true; expect(outcome).toMatchObject({ ok: false, cause: { code: "ETIMEDOUT", killed: true }, }); } finally { if (!descendantSettled) { const pid = Number(await readFile(pidPath, "utf8").catch(() => "")); killFailure = await stopOwnedDescendant(pid, finished); } await settled; } if (killFailure !== undefined) throw killFailure; }); }, ); describe("execFileOutput failure metadata", () => { it("preserves raw byte counts on a failed command with invalid UTF-8", async () => { const failure: unknown = await execFileOutput(process.execPath, [ "-e", "process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac])); process.exitCode = 7;", ]).then( () => undefined, (cause: unknown) => cause, ); expect(execFileOutputFailure(failure)).toMatchObject({ stdout: "a�", stderr: "€", stdoutBytes: 2, stderrBytes: 3, code: 7, }); }); it("preserves explicit truncation metadata from an owned process failure", () => { const failure = Object.assign(new Error("owned output was truncated"), { stdout: "partial stdout", stderr: "partial stderr", code: 7, outputTruncated: true, }); expect(execFileOutputFailure(failure)).toEqual({ stdout: "partial stdout", stderr: "partial stderr", code: 7, signal: null, killed: false, outputTruncated: true, }); }); }); describe("execFileOutput cancellation provenance", () => { it.each([null, "caller stopped", new Error("caller stopped")] as const)( "retains producer cancellation and exact %s reason", async (reason) => { const controller = new AbortController(); controller.abort(reason); const failure: unknown = await execFileOutput( "/must-not-start-after-cancellation", [], { signal: controller.signal, stopSignal: "SIGTERM", }, ).catch((cause: unknown) => cause); expect(failure).toMatchObject({ _tag: "ExecFileOutputCancelledError", code: "ABORT_ERR", name: "AbortError", }); if (!(failure instanceof Error)) throw new Error("Expected owned cancellation"); expect(failure.cause).toBe(reason); }, ); it("keeps a completed command failure when its consumer later aborts", async () => { const controller = new AbortController(); const failure: unknown = await execFileOutput( process.execPath, ["-e", "process.exitCode=7"], { signal: controller.signal, stopSignal: "SIGTERM", }, ).catch((cause: unknown) => { controller.abort(null); return cause; }); expect(failure).toMatchObject({ code: 7 }); expect(failure).not.toHaveProperty("_tag", "ExecFileOutputCancelledError"); }); }); it.each([ null, "native callback stopped", new Error("native callback stopped"), ] as const)( "normalizes Node callback cancellation with exact %s reason", async (reason) => { const controller = new AbortController(); controller.abort(reason); const failure: unknown = await execFileOutput( process.execPath, ["-e", "process.exit(0)"], { signal: controller.signal, }, ).catch((cause: unknown) => cause); expect(failure).toMatchObject({ _tag: "ExecFileOutputCancelledError", code: "ABORT_ERR", }); if (!(failure instanceof Error)) throw new Error("Expected Node callback cancellation"); expect(failure.cause).toBe(reason); }, ); it.skipIf(process.platform === "win32").each([ { code: 7, action: "abort" }, { code: 0, action: "abort" }, { code: 7, action: "overflow" }, ] as const)( "owns root exit $code before inherited-pipe $action", async ({ code, action }) => { const directory = await createTestTempDirectory("rea-exec-exit-pipes-"); const exited = join(directory, "root-exited"); const release = join(directory, "release"); const descendant = [ 'const fs = require("node:fs");', "const parent = Number(process.argv[1]);", "const timer = setInterval(() => {", " try { process.kill(parent, 0); } catch {", ` fs.writeFileSync(${JSON.stringify(exited)}, "");`, " }", ` if (!fs.existsSync(${JSON.stringify(release)})) return;`, ` process.stdout.write(${JSON.stringify(action === "overflow" ? "x".repeat(64) : "late")});`, " clearInterval(timer); process.exit(0);", "}, 10);", "setTimeout(() => process.exit(0), 5000).unref();", ].join("\n"); const program = [ 'const { spawn } = require("node:child_process");', `spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}, String(process.pid)], { stdio: "inherit" }).unref();`, 'process.stderr.write("root diagnostic");', `process.exit(${code});`, ].join("\n"); const controller = new AbortController(); let settled = false; const execution = execFileOutput(process.execPath, ["-e", program], { stopSignal: "SIGTERM", signal: controller.signal, maxBuffer: 32, }) .catch((cause: unknown) => cause) .finally(() => { settled = true; }); try { expect(await waitForFile(exited)).toBe(true); expect(settled).toBe(false); if (action === "abort") controller.abort("late abort"); expect(settled).toBe(false); await writeFile(release, ""); const failure = await execution; expect(failure).toMatchObject( code === 0 ? { _tag: "ExecFileOutputCancelledError", cause: "late abort" } : { code: 7 }, ); expect(execFileOutputFailure(failure)).toMatchObject({ stderr: "root diagnostic", stdout: action === "overflow" ? "x".repeat(32) : "late", outputTruncated: action === "overflow", }); } finally { await writeFile(release, ""); controller.abort(); await execution; } }, );