mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
* fix(adb): name unexecutable adb paths in readiness refusals When REA_ADB_PATH or the PATH-resolved adb is a directory or a file the host refuses to execute, the version probe fails at spawn with EACCES or EPERM. That fell through to the generic "did not report a usable version" refusal, which named neither the path nor the cause. Classify those spawn errors as binary_not_executable (provider rejection executable_missing), name the selected path and errno, and give remediation for the environment, PATH and host-policy cases. The generic fallback now names the selected path too. Fixes #5689 * fix(process): admit synchronous spawn errors as captured failures Node throws spawn errors other than EACCES, EAGAIN, EMFILE, ENFILE and ENOENT synchronously from execFile. execFileBytes called execFile inside its Promise executor, so ENOTDIR or EPERM became a rejection without capture fields and execFileOutputFailure returned undefined. Consumers that classify by failure code, such as the adb version probe, could not see the code, which left the adb EPERM branch unreachable. Catch synchronous spawn errno errors at the capture boundary and attach the same empty capture an asynchronous spawn failure carries. Argument validation errors stay unadmitted. The adb version probe now reports an ENOTDIR selected path, such as a REA_ADB_PATH whose parent is a regular file, as a missing binary with the filesystem error code instead of the generic version refusal. * fix(process): expose spawn failures from the capture owner execFileBytes knows when a command never started, but ExecFileOutputFailure did not say so, so consumers reread raw syscall and code fields and disagreed. Mark both the synchronous throw and an asynchronous `spawn <path>` error with `spawnFailed: true`, and set `path` on the synchronous error to match the asynchronous shape. Argument validation errors stay unadmitted. Consumers now use the owner field: - inspectAdbClient never reports a spawn errno as `exit_code`. ENOENT, ENOTDIR, ELOOP and ENAMETOOLONG are `binary_missing`; EACCES and EPERM stay `binary_not_executable`; any other spawn errno, such as EINVAL, is `binary_not_executable` with a path-naming "could not be started" message. Each records `filesystem_error` and `path_source`. EACCES remediation also names parent directory search permission, and EPERM remediation no longer names host policy as the only cause. - DarwinProcessRunTokenReader tags every not-started compiler as `spawn_failed`, not only ENOENT. EACCES, EPERM and ENOTDIR were previously `failed`. - NativeDmgArtifactReader uses one not-started rule for both `processNotStarted` (previously `spawn ` only, so synchronous errors were missed) and the ENOENT unavailable/io split (previously any `spawn` prefix). Its fake hosts now reject with real execFileOutput spawn failures. * fix(process): classify spawn failures consistently across consumers Admit synchronous and asynchronous spawn errnos through one helper that requires an errno and a `spawn` or `spawn <path>` syscall. The synchronous shape now also gains `spawnargs`, so both shapes match. Use the owner's `spawnFailed` fact in the remaining consumers: - inspectAdbClient reports exhausted host capacity (EAGAIN, EMFILE, ENFILE, ENOMEM) as a host failure to retry and no longer tells the caller to change REA_ADB_PATH. Path-resolution errnos come from a predicate the filesystem owner exports. - runAdbCapture says adb could not be started instead of claiming an exit code for a process that never ran. - The Darwin compiler probe words every spawn_failed outcome as a start failure, not a compilation failure. - Asset catalog inspection treats any assetutil that never started as unavailable, not only ENOENT. * fix(process): report host spawn capacity as a retryable constraint Spawn errnos that report exhausted host capacity (EAGAIN, EMFILE, ENFILE, ENOMEM) were classified by adb readiness alone. assetutil reported them as an unavailable tool, which marked inspect_asset_catalog unavailable in the session, and adb commands reported them as protocol failures. Classify them once in the process capture owner and report a retryable capacity constraint with recovery advice from assetutil and adb commands. * docs(adb): describe host capacity failures from device operations * fix(adb): report exhausted host spawn capacity as runtime unavailability A host that cannot start adb for lack of process, memory or descriptor capacity is not a configuration problem. Give it its own failure code and map it to the runtime_unavailable provider rejection instead of not_configured. * fix(adb): report probe-time host capacity as a retryable constraint A device operation whose adb version probe could not start for lack of host capacity was refused as capability_unavailable without retryable. It now reports the same retryable capacity constraint as a later adb command. * fix(adb): keep the selected adb path in probe-time capacity errors The retryable capacity constraint raised at the version probe dropped the selected_binary and path_source diagnostics the refusal used to carry. * refactor(adb): keep the spawn-failure fact to adb consumers Restore the DMG reader, Darwin compiler probe and assetutil to main. The capture owner still marks a never-started command, including the spawn errors Node throws synchronously, and only the adb readiness probe and adb commands read that fact. * test(adb): assert spawn-failure facts instead of refusal wording The new adb tests compared complete refusal sentences and banned an old phrase. They now check the errno, selected path, path source and the owner's spawn-failure marker, so wording edits do not break them. * fix(adb): own one host-capacity remediation and cover the spawn route The host-capacity constraint and the configuration failure each carried their own remediation sentence. AdbConfiguration now owns the text and both use it, so the constraint's remediation.action equals diagnostics.remediation. The synchronous spawn-throw test now runs on Node's execFile route and on the owned spawn route adb uses (stopSignal). The provider test covers inspect_adb_client under EMFILE next to list_adb_devices, and the docs say which operations report the retryable capacity constraint and which report runtime_unavailable. * fix(process): report EMFILE and ENFILE spawn failures without stdio After an EMFILE or ENFILE spawn failure Node creates no stdio, so the owned stop-signal route threw a TypeError while reading the streams and the later spawn error went unhandled. Attach the error listener first and capture absent streams as empty, so adb reports retryable host capacity. * fix(adb): keep a completed push when host capacity stops its digest check A capacity failure starting sha256sum came back as a retryable error even though adb push had already written the file. Repeating the request with overwrite=false is then refused because the destination exists. Return the receipt with an unavailable device digest and name the capacity failure in its limitations, so the caller is told the transfer is unverified rather than advised to retry. * docs(adb): list host capacity as an unverified push case --------- Co-authored-by: t <t@t>
405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
execFile,
|
|
spawn,
|
|
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;
|
|
/** Wait for inherited copies of an extra drained descriptor to close. Requires stopSignal. */
|
|
readonly lifetimePipe?: boolean;
|
|
};
|
|
|
|
/** 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;
|
|
/** The process never started; `code` is the spawn errno, not an exit. */
|
|
readonly spawnFailed?: true;
|
|
}
|
|
|
|
/** 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");
|
|
const spawnFailed = Reflect.get(cause, "spawnFailed");
|
|
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 } : {}),
|
|
...(spawnFailed === true ? { spawnFailed: true } : {}),
|
|
};
|
|
};
|
|
|
|
/** Spawn errnos reporting exhausted host capacity rather than an unusable command. */
|
|
const HOST_SPAWN_CAPACITY_CODES: ReadonlySet<string> = new Set([
|
|
"EAGAIN",
|
|
"EMFILE",
|
|
"ENFILE",
|
|
"ENOMEM",
|
|
]);
|
|
|
|
/**
|
|
* Whether the command never started because the host lacked process, memory
|
|
* or file-descriptor capacity. The command itself may be usable; a retry after
|
|
* capacity is freed can succeed.
|
|
*/
|
|
export const spawnCapacityExhausted = (
|
|
failure: ExecFileOutputFailure | undefined,
|
|
): boolean =>
|
|
failure?.spawnFailed === true &&
|
|
typeof failure.code === "string" &&
|
|
HOST_SPAWN_CAPACITY_CODES.has(failure.code);
|
|
|
|
/** 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,
|
|
});
|
|
|
|
/**
|
|
* Mark an errno failure from starting the process, which Node reports as
|
|
* `syscall: "spawn"` when thrown synchronously and `spawn <path>` when
|
|
* emitted. Argument validation errors carry no errno.
|
|
*/
|
|
const admitSpawnFailure = (error: Error): boolean => {
|
|
const syscall = Reflect.get(error, "syscall");
|
|
if (
|
|
typeof Reflect.get(error, "errno") !== "number" ||
|
|
typeof syscall !== "string" ||
|
|
!(syscall === "spawn" || syscall.startsWith("spawn "))
|
|
)
|
|
return false;
|
|
Reflect.set(error, "spawnFailed", true);
|
|
return true;
|
|
};
|
|
|
|
/** Run a shell-free command with no input 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, lifetimePipe, ...execOptions } =
|
|
options;
|
|
if (lifetimePipe === true && stopSignal === undefined) {
|
|
reject(new TypeError("lifetimePipe requires stopSignal"));
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
const complete = (
|
|
error: Error | null,
|
|
stdout: Buffer,
|
|
stderr: Buffer,
|
|
): void => {
|
|
if (error !== null) {
|
|
admitSpawnFailure(error);
|
|
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 });
|
|
};
|
|
try {
|
|
if (stopSignal === undefined) {
|
|
const child = 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,
|
|
),
|
|
);
|
|
// This promise API exposes no input writer; EOF belongs to the owner.
|
|
child.stdin?.end();
|
|
return;
|
|
}
|
|
startStoppableCommand(
|
|
command,
|
|
arguments_,
|
|
{ ...options, stopSignal },
|
|
complete,
|
|
);
|
|
} catch (error) {
|
|
// Node throws spawn errors other than EACCES, EAGAIN, EMFILE, ENFILE and
|
|
// ENOENT (for example ENOTDIR, EPERM or ENOMEM) synchronously. No
|
|
// process started, so a spawn errno completes with the same empty
|
|
// capture an asynchronous spawn failure carries; argument validation
|
|
// errors stay unadmitted.
|
|
if (error instanceof Error && admitSpawnFailure(error))
|
|
complete(error, Buffer.alloc(0), Buffer.alloc(0));
|
|
else reject(error);
|
|
}
|
|
});
|
|
|
|
/** 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, lifetimePipe, ...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 rootFailed = false;
|
|
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:
|
|
lifetimePipe === true
|
|
? ["ignore", "pipe", "pipe", "pipe"]
|
|
: ["ignore", "pipe", "pipe"],
|
|
killSignal: stopSignal,
|
|
});
|
|
child.on("error", (error: Error) => {
|
|
processError ??= error;
|
|
});
|
|
// After an EMFILE or ENFILE spawn failure Node creates no stdio and reports
|
|
// the failure only through the error and close events.
|
|
const stdio = child.stdio as typeof child.stdio | undefined;
|
|
if (lifetimePipe === true) {
|
|
const pipe = stdio?.[3];
|
|
if (pipe != null && "resume" in pipe) pipe.resume();
|
|
}
|
|
// The first stop request names the failure; a later one is not resent.
|
|
const stop = (reason: "abort" | "timeout" | "output-limit"): void => {
|
|
if (stopped !== undefined || rootFailed || processError !== null) 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 | undefined,
|
|
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(stdio?.[1], "stdout");
|
|
const stderr = capture(stdio?.[2], "stderr");
|
|
child.on("exit", (code, exitSignal) => {
|
|
// A failed root has completed its outcome even while descendants retain
|
|
// its pipes. Finalization cancellation cannot replace that failure.
|
|
rootFailed = code !== 0 || exitSignal !== null;
|
|
});
|
|
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)
|
|
: rootFailed && stopped === undefined
|
|
? 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,
|
|
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,
|
|
}));
|