Files
rea/src/process/ExecFileOutput.test.ts
T

769 lines
27 KiB
TypeScript

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<boolean> => {
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<string> => {
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<unknown> => {
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;
}
},
);