Files
rea/src/process/ExecFileOutput.ts
T
morluto 9d940c2f28 fix(ghidra): preserve lifecycle ownership and failure provenance
Refs #4502, #4506, #4508, #4518, #4522, #4526.

Retain snapshot, endpoint, launch rollback and compiler cleanup owners. Join request finalization and cleanup cancellation. Preserve bounded compiler output and original byte counts through capture and Ghidra failures. Normalize execFile cancellation once at its producer.

Validation includes real macOS Ghidra TCP MCP success workflows, controlled Node compiler/process fault fixtures, focused regressions and static checks. Native Swift compiler faults, Windows execution and stdio transport were not verified. Broader startup primary classification in #4517 and native adapter propagation remain unfinished.
2026-10-17 00:44:02 +08:00

219 lines
7.6 KiB
TypeScript

import {
execFile,
type ExecFileOptionsWithBufferEncoding,
} from "node:child_process";
type ExecFileOutputOptions = Omit<
ExecFileOptionsWithBufferEncoding,
"encoding" | "maxBuffer"
> & {
readonly maxBuffer?: number;
/**
* Signal that stops the process on abort or timeout. The promise then
* settles only after the process exits, so the caller can remove files it
* writes; a process still running after a grace period is killed. Without
* it, Node's execFile sends SIGTERM on abort and rejects before the
* process exits.
*/
readonly stopSignal?: NodeJS.Signals;
};
/** How long a stopped process may take to exit before it is killed. */
const STOP_GRACE_MS = 5_000;
/** Cancellation observed by the command owner, with the caller's exact reason. */
export class ExecFileOutputCancelledError extends Error {
readonly _tag = "ExecFileOutputCancelledError";
readonly code = "ABORT_ERR";
constructor(reason: unknown) {
super("The operation was aborted", { cause: reason });
this.name = "AbortError";
}
}
/**
* A command stopped at its timeout can still exit cleanly, so the timeout is
* the failure. Like Node's execFile timeout, it reports `killed: true`.
*/
const timeoutError = (
command: string,
timeout: number,
exit: Error | null,
): Error => {
const error = new Error(
`Command timed out after ${String(timeout)} ms: ${command}`,
exit === null ? undefined : { cause: exit },
);
Reflect.set(error, "code", "ETIMEDOUT");
Reflect.set(error, "killed", true);
Reflect.set(
error,
"signal",
exit === null ? null : (Reflect.get(exit, "signal") ?? null),
);
return error;
};
/**
* The `timeout` contract for both modes, checked before spawning. A stoppable
* command keeps its own deadline, so execFile never validates it. Node 26's
* execFile also caps it at Number.MAX_SAFE_INTEGER; Node 22 and 24 accept
* larger integers, which their timers then clamp to 1 ms.
*/
const invalidTimeout = (timeout: number): RangeError | undefined => {
if (Number.isSafeInteger(timeout) && timeout >= 0) return undefined;
const error = new RangeError(
`The value of "timeout" is out of range. It must be an unsigned integer. Received ${String(timeout)}`,
);
Reflect.set(error, "code", "ERR_OUT_OF_RANGE");
return error;
};
/** Captured subprocess output read from an execFileOutput rejection. */
export interface ExecFileOutputFailure {
readonly stdout: string;
readonly stderr: string;
readonly code: string | number | null;
readonly signal: string | null;
readonly killed: boolean;
readonly outputTruncated: boolean;
readonly stdoutBytes?: number;
readonly stderrBytes?: number;
readonly collectionFailure?: boolean;
}
/** Read captured output metadata from a failed execFileOutput invocation. */
export const execFileOutputFailure = (
cause: unknown,
): ExecFileOutputFailure | undefined => {
if (!(cause instanceof Error)) return undefined;
const stdout = Reflect.get(cause, "stdout");
const stderr = Reflect.get(cause, "stderr");
const code = Reflect.get(cause, "code");
const signal = Reflect.get(cause, "signal");
const killed = Reflect.get(cause, "killed");
const outputTruncated = Reflect.get(cause, "outputTruncated");
const stdoutBytes = Reflect.get(cause, "stdoutBytes");
const stderrBytes = Reflect.get(cause, "stderrBytes");
const collectionFailure = Reflect.get(cause, "collectionFailure");
if (typeof stdout !== "string" || typeof stderr !== "string")
return undefined;
return {
stdout,
stderr,
...(typeof stdoutBytes === "number" &&
Number.isSafeInteger(stdoutBytes) &&
stdoutBytes >= 0
? { stdoutBytes }
: {}),
...(typeof stderrBytes === "number" &&
Number.isSafeInteger(stderrBytes) &&
stderrBytes >= 0
? { stderrBytes }
: {}),
code: typeof code === "string" || typeof code === "number" ? code : null,
signal: typeof signal === "string" ? signal : null,
killed: killed === true,
outputTruncated:
outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
...(collectionFailure === true ? { collectionFailure: true } : {}),
};
};
/** Run a shell-free command while capturing its complete output bytes. */
export const execFileBytes = (
command: string,
arguments_: readonly string[],
options: ExecFileOutputOptions = {},
): Promise<{ readonly stdout: Buffer; readonly stderr: Buffer }> =>
new Promise((resolve, reject) => {
const { stopSignal, signal, timeout, ...execOptions } = options;
const invalid = timeout === undefined ? undefined : invalidTimeout(timeout);
if (invalid !== undefined) {
reject(invalid);
return;
}
if (stopSignal !== undefined && signal?.aborted === true) {
reject(new ExecFileOutputCancelledError(signal.reason));
return;
}
let stopped: "abort" | "timeout" | undefined;
let deadline: NodeJS.Timeout | undefined;
let grace: NodeJS.Timeout | undefined;
const child = execFile(
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 });
},
);
// 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);
});
/** Run a shell-free command while capturing its complete UTF-8 output. */
export const execFileOutput = (
command: string,
arguments_: readonly string[],
options: ExecFileOutputOptions = {},
): Promise<{
readonly stdout: string;
readonly stderr: string;
readonly stdoutBytes: number;
readonly stderrBytes: number;
}> =>
execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({
stdout: stdout.toString("utf8"),
stderr: stderr.toString("utf8"),
stdoutBytes: stdout.byteLength,
stderrBytes: stderr.byteLength,
}));