mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
fix(process): retain inherited pipes on output overflow
This commit is contained in:
1 parent
0dea08fb07
commit
57d0ed45f7
2 files changed
+332
-70
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user