Files
rea/src/process/ExecFileOutput.ts
T
4aa51bfe19 fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* feat(process): add owned debugger shutdown lifecycle

* feat(reverse): add local inspection commands

* feat(gdb): add persistent debugger integration

* feat(cutter): add Cutter bridge integration

* test(reverse): enforce complete integration coverage

* fix(process): preserve debugger shutdown ownership

* fix(reverse): retain evidence across interrupted commands

* fix(gdb): honor captured provider environment

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* fix(server): snapshot provider environment at creation

* fix(reverse): close review gaps with complete coverage

* fix(reverse): address inspection review findings

* fix(gdb): bound sessions and retain cleanup ownership

* fix(ci): require reverse-tools quality lane

* fix(process): unref inherited debugger output pipes

* fix(reverse): address remaining PR 1960 findings

* fix(gdb): address remaining PR 1961 findings

* fix(reverse): account for unrestricted provider execution

* fix(process): retain debugger ownership until safe shutdown

* fix(process): preserve replacement debugger sessions

* fix(reverse): preserve admission and output provenance

* fix(process): retain exited debugger until output settles

* fix(mcp): bound provider admission and debugger cleanup

* fix(reverse): preserve failure provenance and classify E2BIG

* fix(gdb): preserve startup cleanup and detach safety

* fix(process): retain debugger ownership after detach failure

* fix(reverse): preserve mutation and failure provenance

* fix(gdb): address debugger review findings

* fix(reverse): rebind sessions after external mutation

* fix(process): cap debugger output after exit

* fix(gdb): protect one-shot detach verification

* feat(debugger): add reverse tool coverage gate

* test(debugger): cover integrated failure paths

* fix(gdb): bound and preserve session observations

* fix(gdb): address review regressions

* style: format merged integration files

* fix(reverse): preserve session and output evidence

* refactor(gdb): share repeated test and partial result setup

* fix(gdb): invalidate TTY proof for Python aliases

* fix(reverse): retain failure output byte counts

* test(process): synchronize late debugger output

* fix(gdb): invalidate TTY proof for wrapped commands

* fix(gdb): recognize wit command abbreviation

* test(reverse): wait for cancelled descendant readiness

* test(reverse): bound descendant cancellation cleanup

* style(reverse): format integration tests and adapters

* style: format reverse engineering integration files

* test(reverse): remove duplicate environment harness

* test(gdb): share session startup assertions

* fix(gdb): invalidate output proof for command abbreviations

* test(reverse): share composition edge case scenarios

* fix(ci): recognize verification and mutation tool entries

* test(rizin): cover successful fake inferior detach

* test(cli): align full output failure envelope

* test(cli): isolate reverse command lifecycle service

* test(process): share debugger output fixture setup

* fix(binary): preserve high-bit Windows signature bytes

* style(binary): format Windows signature parser

* test(mcp): preserve call coverage in unchanged comparison

* fix(ci): include reverse tools Vitest configs in lint cache

* fix(mcp): preserve target content on delivery errors

* ci: align duplication ceiling with expanded suite

* test(reverse): allow signal admission mutants

* fix(reverse): bound retained cleanup failures to process owners

* fix(test): replace drifting mutation ranges with owned selectors

* fix(reverse): admit persistent commands and retain cleanup ownership

* fix(cutter): recover timed-out commands from exact receipts

* fix(cutter): reject unsupported bridge request kinds

* fix(ci): repair client format and shared verification fixtures

* fix(native): preserve debugger cleanup ownership and bridge authentication

* test(native): retain diagnostics without perfect coverage requirements

* chore(licenses): refresh locked dependency inventory and notices

* fix(test): exclude transient command locks from mutation sandboxes

* fix(gdb): admit only platforms with safe detach verification

* fix(cutter): retain bounded completion receipts until acknowledgement

* fix(cutter): acknowledge commands rejected before UI dispatch

* test(reverse): fail cancellation checks before fixture teardown

* test(gdb): clean up the retained broken-stdin fixture owner

* docs(test): clarify focused provider verification scope

* fix(rizin): retain close cleanup failures

* fix(gdb): wait for inferior stop and bound MI buffering

* fix(cutter): retain failed receipt acknowledgements

* fix(rizin): return retained session identity on rebind failure

* fix(gdb): preserve startup cancellation and lossy status

* fix(rizin): retain launch provenance and assemble frames once

* fix(cutter): recover receipts across callers and bound discovery

* docs: describe debugger evidence and bridge recovery

* fix(process): keep cleanup incomplete while writers remain

Stopping a debugger no longer reports success when inherited output
streams stay open, so BinarySession cannot reopen a binary a child is
still changing. Concurrent Cutter cleanup retries share one receipt
acknowledgement instead of treating a consumed receipt as failure.

* fix(debuggers): recover cleanup with captured process identities

* fix(analysis): retire sessions after unrestricted commands

* fix(cli): preserve completed evidence after cleanup recovery

* fix(cutter): bound frame collection and coalesce cleanup

* fix(windows): resolve selected provider paths absolutely

* fix(gdb): report decoding loss in failed startup evidence

* fix(rizin): scope decoding loss to collected command frames

* test(mcp): cover snapshot retirement in reverse tools CI

---------

Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com>
Co-authored-by: REA Maintainers <noreply@github.com>
Co-authored-by: OpenAI Codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-14 04:47:34 +08:00

215 lines
7.3 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;
const abortError = (signal: AbortSignal): Error => {
const error = new Error("The operation was aborted", {
cause: signal.reason,
});
error.name = "AbortError";
Reflect.set(error, "code", "ABORT_ERR");
return error;
};
/**
* 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(abortError(signal));
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
? abortError(signal)
: stopped === "timeout" && timeout !== undefined
? timeoutError(command, timeout, error)
: 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,
}));