fix(process): retain inherited pipes on output overflow

This commit is contained in:
morluto committed 2026-10-27 07:17:10 +08:00
1 parent 0dea08fb07
commit 57d0ed45f7
2 files changed
+332 -70

No files matched your search

+192 -13
View File
@@ -1,8 +1,8 @@
import { execFile } from "node:child_process";
import { access, mkdir, readFile, rm } from "node:fs/promises";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -12,6 +12,8 @@ import {
execFileOutputFailure,
} from "./ExecFileOutput.js";
const realSetTimeout = globalThis.setTimeout;
const waitForFile = async (path: string): Promise<boolean> => {
for (let attempt = 0; attempt < 500; attempt += 1) {
if (
@@ -21,7 +23,7 @@ const waitForFile = async (path: string): Promise<boolean> => {
)
)
return true;
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => realSetTimeout(resolve, 10));
}
return access(path).then(
() => true,
@@ -29,6 +31,28 @@ const waitForFile = async (path: string): Promise<boolean> => {
);
};
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: "",
});
},
);
describe("execFileOutput", () => {
it("ignores non-Error and incomplete failure metadata", () => {
expect(execFileOutputFailure("failure")).toBeUndefined();
@@ -45,17 +69,24 @@ describe("execFileOutput", () => {
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]));",
]);
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);
});
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, [
@@ -92,6 +123,83 @@ describe("execFileOutput", () => {
).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, [
@@ -331,6 +439,77 @@ const stopOwnedDescendant = async (
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");