* fix(windows): retain native handles whose close is unconfirmed CloseHandle failure no longer drops the numeric value. A still-open handle stays owned and can be closed again; a value missing from the handle table is dropped and never closed again; unreadable liveness blocks another close until a probe says the value is open. Co-authored-by: morluto <[email protected]> * test(process): keep scratch until inherited pipes close Lock the execFileOutput settlement contract: an inherited-pipe descendant holds the caller's scratch root until it exits, and destroying the readers is not process-family completion. Co-authored-by: morluto <[email protected]> * fix(windows): retry admitted file closes before runtime removal A failed private-file close stays on the runtime that opened it. Runtime removal closes that same wrapper before the tree walk, including when the read that opened the file has already returned. Co-authored-by: morluto <[email protected]> * fix(windows): resume a close after part of the owner is released A runtime whose directory handle is already released continues with its parent handles instead of walking that invalid root. A process whose settlement was already observed retries process close alone, without kill or poll against the released job. Co-authored-by: morluto <[email protected]> * test(process): signal only an owned inherited-pipe descendant Skip the descendant SIGKILL after pipe settlement. On an earlier failure, signal only when the process command line still contains that test's path. Co-authored-by: morluto <[email protected]> * fix(windows): do not close a handle again after a failed close An open liveness probe can observe a reused numeric value. After CloseHandle fails, drop the value only when the handle table no longer contains it, and do not call CloseHandle on that number again. Co-authored-by: morluto <[email protected]> * fix(windows): stop using a close-blocked pipe for I/O A broken pipe whose CloseHandle fails stays owned, but its numeric value may have been reused. Later polls and stdin writes treat that pipe as ended instead of reading or writing the number. Co-authored-by: morluto <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: morluto <[email protected]>
508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { access, mkdir, 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("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 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);
|
|
},
|
|
);
|