diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 4aacc960c4..e31ffe62ab 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -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 => { for (let attempt = 0; attempt < 500; attempt += 1) { if ( @@ -21,7 +23,7 @@ const waitForFile = async (path: string): Promise => { ) ) 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 => { ); }; +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"); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 2c9dc591fd..0174e6f7ff 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -1,6 +1,7 @@ import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { execFile, + spawn, type ExecFileOptionsWithBufferEncoding, } from "node:child_process"; @@ -158,67 +159,149 @@ export const execFileBytes = ( reject(new ExecFileOutputCancelledError(signal.reason)); return; } - let stopped: "abort" | "timeout" | undefined; - let deadline: NodeJS.Timeout | undefined; - let grace: NodeJS.Timeout | undefined; - const child = execFile( + const complete = ( + error: Error | null, + stdout: Buffer, + stderr: Buffer, + ): void => { + if (error !== null) { + Reflect.set(error, "stdout", stdout.toString("utf8")); + Reflect.set(error, "stderr", stderr.toString("utf8")); + Reflect.set(error, "stdoutBytes", stdout.byteLength); + Reflect.set(error, "stderrBytes", stderr.byteLength); + reject(error); + return; + } + resolve({ stdout, stderr }); + }; + if (stopSignal === undefined) { + execFile( + command, + [...arguments_], + { + ...execOptions, + ...(signal === undefined ? {} : { signal }), + ...(timeout === undefined ? {} : { timeout }), + encoding: "buffer", + maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY, + }, + (error, stdout, stderr) => + complete( + error?.name === "AbortError" && error.code === "ABORT_ERR" + ? new ExecFileOutputCancelledError(error.cause) + : error, + stdout, + stderr, + ), + ); + return; + } + startStoppableCommand( command, - [...arguments_], - { - ...execOptions, - // A stoppable command's abort and timeout are handled below, because - // execFile neither waits for the exit on abort nor kills a process - // that ignores its timeout signal. - ...(stopSignal === undefined - ? { - ...(signal === undefined ? {} : { signal }), - ...(timeout === undefined ? {} : { timeout }), - } - : { killSignal: stopSignal }), - encoding: "buffer", - maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY, - }, - (error, stdout, stderr) => { - signal?.removeEventListener("abort", onAbort); - clearTimeout(deadline); - clearTimeout(grace); - const failure = - stopped === "abort" && signal !== undefined - ? new ExecFileOutputCancelledError(signal.reason) - : stopped === "timeout" && timeout !== undefined - ? timeoutError(command, timeout, error) - : error?.name === "AbortError" && error.code === "ABORT_ERR" - ? new ExecFileOutputCancelledError(error.cause) - : error; - if (failure !== null) { - Reflect.set(failure, "stdout", stdout.toString("utf8")); - Reflect.set(failure, "stderr", stderr.toString("utf8")); - Reflect.set(failure, "stdoutBytes", stdout.byteLength); - Reflect.set(failure, "stderrBytes", stderr.byteLength); - reject(failure); - return; - } - resolve({ stdout, stderr }); - }, + arguments_, + { ...options, stopSignal }, + complete, ); - // The first stop request names the failure; a later one is not resent. - const stop = (reason: "abort" | "timeout"): void => { - if (stopped !== undefined) return; - stopped = reason; - child.kill(stopSignal); - grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); - }; - const onAbort = (): void => { - stop("abort"); - }; - if (stopSignal === undefined) return; - signal?.addEventListener("abort", onAbort, { once: true }); - if (timeout !== undefined && timeout > 0) - deadline = setTimeout(() => { - stop("timeout"); - }, timeout); }); +/** Own stop requests and bounded capture until the root and inherited pipes close. */ +const startStoppableCommand = ( + command: string, + arguments_: readonly string[], + options: ExecFileOutputOptions & { readonly stopSignal: NodeJS.Signals }, + complete: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, +): void => { + const { stopSignal, signal, timeout, ...execOptions } = options; + const maxBuffer = options.maxBuffer ?? Number.POSITIVE_INFINITY; + if (typeof maxBuffer !== "number" || !(maxBuffer >= 0)) { + const error = new RangeError("maxBuffer must be a nonnegative number"); + Reflect.set(error, "code", "ERR_OUT_OF_RANGE"); + throw error; + } + let stopped: "abort" | "timeout" | "output-limit" | undefined; + let processError: Error | null = null; + let outputError: Error | undefined; + let deadline: NodeJS.Timeout | undefined; + let grace: NodeJS.Timeout | undefined; + // Node's execFile destroys both pipes when maxBuffer is exceeded. Own + // bounded capture here so overflow stops the root without discarding the + // pipe lifetime of descendants still using caller-owned temporary files. + const child = spawn(command, [...arguments_], { + ...execOptions, + stdio: "pipe", + killSignal: stopSignal, + }); + // The first stop request names the failure; a later one is not resent. + const stop = (reason: "abort" | "timeout" | "output-limit"): void => { + if (stopped !== undefined) return; + stopped = reason; + child.kill(stopSignal); + grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); + }; + const onAbort = (): void => { + stop("abort"); + }; + const capture = (stream: typeof child.stdout, name: "stdout" | "stderr") => { + const chunks: Buffer[] = []; + let retained = 0; + stream.on("data", (chunk: Buffer) => { + const remaining = Math.max(0, Math.floor(maxBuffer - retained)); + if (chunk.byteLength <= remaining) { + chunks.push(chunk); + retained += chunk.byteLength; + return; + } + if (remaining > 0) { + chunks.push(Buffer.from(chunk.subarray(0, remaining))); + retained += remaining; + } + if (outputError === undefined) { + outputError = new RangeError(`${name} maxBuffer length exceeded`); + Reflect.set(outputError, "code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + stop("output-limit"); + } + }); + return () => Buffer.concat(chunks, retained); + }; + const stdout = capture(child.stdout, "stdout"); + const stderr = capture(child.stderr, "stderr"); + child.on("error", (error: Error) => { + processError ??= error; + }); + child.on("close", (code, exitSignal) => { + signal?.removeEventListener("abort", onAbort); + clearTimeout(deadline); + clearTimeout(grace); + const out = stdout(); + const diagnostic = stderr(); + const invocation = [command, ...arguments_].join(" "); + if (processError === null && (code !== 0 || exitSignal !== null)) { + processError = new Error( + `Command failed: ${invocation}\n${diagnostic.toString("utf8")}`, + ); + Reflect.set(processError, "code", code); + Reflect.set(processError, "signal", exitSignal); + Reflect.set(processError, "killed", child.killed); + } + if (processError !== null) Reflect.set(processError, "cmd", invocation); + if (outputError !== undefined) Reflect.set(outputError, "cmd", invocation); + const failure = + stopped === "abort" && signal !== undefined + ? new ExecFileOutputCancelledError(signal.reason) + : stopped === "timeout" && timeout !== undefined + ? timeoutError(command, timeout, processError) + : (outputError ?? processError); + if (failure !== null && outputError !== undefined) + Reflect.set(failure, "outputTruncated", true); + complete(failure, out, diagnostic); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (timeout !== undefined && timeout > 0) + deadline = setTimeout(() => { + stop("timeout"); + }, timeout); +}; + /** Run a shell-free command while capturing its complete UTF-8 output. */ export const execFileOutput = ( command: string,