Project admitted output facts beside the command owner and remove adapter-local copies. Preserve byte-count omission, explicit signal and exit facts, and caller-specific truncation and failure policies. Update ADB timeout regressions to assert the existing prerequisite frame and original process cause, including captured output on both frames.
239 lines
8.4 KiB
TypeScript
239 lines
8.4 KiB
TypeScript
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
|
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 that process's stdio pipes close, so a caller can
|
|
* remove files the command wrote. A descendant that inherited those pipes
|
|
* keeps them open until it exits; destroying the readers is not
|
|
* process-family completion and must not unblock this settlement. A process
|
|
* still running after a grace period is killed. Without this signal, 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 } : {}),
|
|
};
|
|
};
|
|
|
|
/** Project admitted command failure facts without reinterpreting failure policy. */
|
|
export const execFileFailureCapturedOutput = (
|
|
failure: ExecFileOutputFailure,
|
|
): AnalysisCapturedOutput => ({
|
|
stdout: failure.stdout,
|
|
stderr: failure.stderr,
|
|
...(failure.stdoutBytes === undefined
|
|
? {}
|
|
: { stdout_bytes: failure.stdoutBytes }),
|
|
...(failure.stderrBytes === undefined
|
|
? {}
|
|
: { stderr_bytes: failure.stderrBytes }),
|
|
exit_code: typeof failure.code === "number" ? failure.code : null,
|
|
signal: failure.signal,
|
|
truncated: failure.outputTruncated,
|
|
});
|
|
|
|
/** 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,
|
|
}));
|