execFileOutput settles only when every output pipe closes. When a stoppable command printed, exited, and left a descendant holding its stdout, the deadline and SIGKILL grace had no process left to stop, so callers such as the Kuna doctor version probe waited for the descendant instead of settling within the deadline plus grace. Destroy the command's output streams when the grace expires so the timeout is reported on time. Fixes #5557 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { access, readFile, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
|
|
import {
|
|
execFileBytes,
|
|
execFileOutput,
|
|
execFileOutputFailure,
|
|
} from "./ExecFileOutput.js";
|
|
|
|
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) => setTimeout(resolve, 10));
|
|
}
|
|
return access(path).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
};
|
|
|
|
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("preserves captured bytes for callers that own the output format", async () => {
|
|
const result = await execFileBytes(process.execPath, [
|
|
"-e",
|
|
"process.stdout.write(Buffer.from([0x00, 0xff, 0x41])); process.stderr.write(Buffer.from([0xfe]));",
|
|
]);
|
|
|
|
expect(result.stdout).toEqual(Buffer.from([0x00, 0xff, 0x41]));
|
|
expect(result.stderr).toEqual(Buffer.from([0xfe]));
|
|
});
|
|
|
|
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("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("settles after the grace when an exited command's descendant holds its output open", async () => {
|
|
const directory = await createTestTempDirectory("rea-exec-descendant-");
|
|
const pidFile = join(directory, "descendant.pid");
|
|
// The command prints, then exits at once, leaving a descendant that
|
|
// inherited stdout and stderr and keeps both pipes open for 60 s.
|
|
const script = [
|
|
'const { spawn } = require("node:child_process");',
|
|
'const fs = require("node:fs");',
|
|
'const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: ["ignore", "inherit", "inherit"] });',
|
|
`fs.writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`,
|
|
"child.unref();",
|
|
'process.stdout.write("kuna 1.833\\n");',
|
|
].join("\n");
|
|
const started = Date.now();
|
|
try {
|
|
await expect(
|
|
execFileOutput(process.execPath, ["-e", script], {
|
|
timeout: 500,
|
|
stopSignal: "SIGTERM",
|
|
}),
|
|
).rejects.toMatchObject({ code: "ETIMEDOUT", killed: true });
|
|
// Deadline plus the 5 s stop grace, with scheduling slack.
|
|
expect(Date.now() - started).toBeLessThan(15_000);
|
|
} finally {
|
|
const pid = Number(await readFile(pidFile, "utf8").catch(() => ""));
|
|
if (Number.isSafeInteger(pid) && pid > 0)
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// The descendant may already have exited.
|
|
}
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
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",
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
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);
|
|
},
|
|
);
|