* 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>
257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
import type {
|
|
OwnedProviderProcessSpawnOptions,
|
|
ProviderProcessSnapshot,
|
|
SpawnedOwnedProviderProcess,
|
|
} from "./ProviderProcess.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
} from "./ProviderProcess.js";
|
|
import {
|
|
cleanupOwnedProcessGroup,
|
|
type OwnedProcessGroup,
|
|
type ProcessCleanupFailure,
|
|
} from "./ProcessOwnership.js";
|
|
import {
|
|
ProviderStartupDeadline,
|
|
waitForAbortableDelay,
|
|
} from "./ProviderDeadline.js";
|
|
|
|
/** A short-lived command failed its lifecycle or complete-output contract. */
|
|
export class OwnedCommandFailure extends Error {
|
|
readonly cleanupResult: ProcessCleanupFailure | undefined;
|
|
readonly collectionFailure: boolean;
|
|
|
|
constructor(
|
|
readonly reason: "cancelled" | "timeout" | "output-limit" | "process",
|
|
message: string,
|
|
readonly snapshot: ProviderProcessSnapshot | null = null,
|
|
readonly cleanupFailure: string | null = null,
|
|
options?: ErrorOptions & {
|
|
readonly cleanupResult?: ProcessCleanupFailure;
|
|
readonly collectionFailure?: boolean;
|
|
},
|
|
readonly resources: readonly string[] = [],
|
|
readonly cleanupOwner?: ProviderProcessSupervisor,
|
|
) {
|
|
super(message, options);
|
|
this.cleanupResult = options?.cleanupResult;
|
|
this.collectionFailure = options?.collectionFailure ?? false;
|
|
}
|
|
}
|
|
|
|
/** Report recorded launch identities; these labels do not authorize signaling. */
|
|
export const ownedCommandResources = (
|
|
ownership: OwnedProcessGroup,
|
|
directory?: string,
|
|
): readonly string[] => [
|
|
ownership.runId,
|
|
...(directory === undefined ? [] : [directory]),
|
|
`pid:${ownership.leaderPid}`,
|
|
`process-group:${ownership.processGroupId}`,
|
|
];
|
|
|
|
/** Execute a single owned process with independent cleanup and bounded diagnostic retention. */
|
|
export const runOwnedCommand = async (
|
|
spawn: OwnedProviderProcessSpawnOptions,
|
|
limits: {
|
|
readonly timeoutMs: number;
|
|
readonly diagnosticBytes: number;
|
|
/** Accept an ordinary nonzero exit after output closes without other process errors. */
|
|
readonly acceptNonZeroExit?: boolean;
|
|
},
|
|
options: {
|
|
readonly signal?: AbortSignal | undefined;
|
|
/** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */
|
|
readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void;
|
|
readonly launcher?: (
|
|
input: OwnedProviderProcessSpawnOptions,
|
|
) => Promise<SpawnedOwnedProviderProcess>;
|
|
} = {},
|
|
): Promise<ProviderProcessSnapshot> => {
|
|
const callerSignal = options.signal ?? spawn.signal;
|
|
if (callerSignal?.aborted)
|
|
throw new OwnedCommandFailure(
|
|
"cancelled",
|
|
"Command cancelled before launch.",
|
|
);
|
|
const deadline = new ProviderStartupDeadline(limits.timeoutMs, callerSignal);
|
|
try {
|
|
return await collectOwnedCommand(
|
|
spawn,
|
|
limits,
|
|
options.launcher ?? spawnOwnedProviderProcess,
|
|
deadline,
|
|
callerSignal,
|
|
options.retainCleanup,
|
|
);
|
|
} finally {
|
|
deadline.dispose();
|
|
}
|
|
};
|
|
|
|
const collectOwnedCommand = async (
|
|
spawn: OwnedProviderProcessSpawnOptions,
|
|
limits: {
|
|
readonly diagnosticBytes: number;
|
|
/** Accept an ordinary nonzero exit after output closes without other process errors. */
|
|
readonly acceptNonZeroExit?: boolean;
|
|
},
|
|
launcher: (
|
|
input: OwnedProviderProcessSpawnOptions,
|
|
) => Promise<SpawnedOwnedProviderProcess>,
|
|
deadline: ProviderStartupDeadline,
|
|
callerSignal?: AbortSignal,
|
|
retainCleanup?: (owner: ProviderProcessSupervisor) => void,
|
|
): Promise<ProviderProcessSnapshot> => {
|
|
const signal = deadline.signal;
|
|
let launched: SpawnedOwnedProviderProcess;
|
|
try {
|
|
launched = await launcher({
|
|
...spawn,
|
|
signal,
|
|
});
|
|
} catch (cause: unknown) {
|
|
const interruption = deadline.interruption;
|
|
if (interruption !== undefined)
|
|
throw new OwnedCommandFailure(
|
|
interruption,
|
|
interruption === "cancelled"
|
|
? "Command cancelled during ownership preparation."
|
|
: "Command deadline elapsed during ownership preparation.",
|
|
null,
|
|
null,
|
|
{ cause },
|
|
);
|
|
throw cause;
|
|
}
|
|
let exceeded = false;
|
|
let processFailure: string | undefined;
|
|
const supervisor = new ProviderProcessSupervisor(
|
|
{
|
|
...launched,
|
|
ownsProcessLifetime: true,
|
|
cleanup:
|
|
launched.cleanup ??
|
|
(() => cleanupOwnedProcessGroup(launched.ownership)),
|
|
},
|
|
{
|
|
maxDiagnosticBytes: limits.diagnosticBytes,
|
|
onDiagnostic: (event) => {
|
|
if (event.type === "output" && event.truncated === true)
|
|
exceeded = true;
|
|
if (event.type === "error") processFailure = event.message;
|
|
},
|
|
},
|
|
);
|
|
let failure: OwnedCommandFailure | undefined;
|
|
try {
|
|
while (!(await supervisor.waitForOutputClose(10))) {
|
|
const interruption = deadline.interruption;
|
|
if (interruption !== undefined)
|
|
throw new OwnedCommandFailure(
|
|
interruption,
|
|
interruption === "cancelled"
|
|
? "Command cancelled."
|
|
: "Command deadline elapsed.",
|
|
);
|
|
if (exceeded)
|
|
throw new OwnedCommandFailure(
|
|
"output-limit",
|
|
"Command diagnostic output exceeded its complete-output budget.",
|
|
);
|
|
if (processFailure !== undefined)
|
|
throw new OwnedCommandFailure("process", processFailure, null, null, {
|
|
collectionFailure: true,
|
|
});
|
|
await waitForAbortableDelay(25, signal);
|
|
}
|
|
const snapshot = supervisor.snapshot();
|
|
const interruption = deadline.interruption;
|
|
if (interruption !== undefined)
|
|
throw new OwnedCommandFailure(
|
|
interruption,
|
|
interruption === "cancelled"
|
|
? "Command cancelled."
|
|
: "Command deadline elapsed.",
|
|
snapshot,
|
|
);
|
|
if (exceeded)
|
|
throw new OwnedCommandFailure(
|
|
"output-limit",
|
|
"Command diagnostic output exceeded its complete-output budget.",
|
|
snapshot,
|
|
);
|
|
const acceptedNonzeroExit =
|
|
limits.acceptNonZeroExit === true &&
|
|
snapshot.exitCode !== null &&
|
|
snapshot.exitCode !== undefined &&
|
|
snapshot.exitCode !== 0 &&
|
|
snapshot.signal === null;
|
|
if (
|
|
processFailure !== undefined ||
|
|
((snapshot.exitCode !== 0 || snapshot.signal !== null) &&
|
|
!acceptedNonzeroExit)
|
|
)
|
|
throw new OwnedCommandFailure(
|
|
"process",
|
|
processFailure ??
|
|
"Command failed; inspect its exit status and diagnostics.",
|
|
snapshot,
|
|
null,
|
|
{ collectionFailure: processFailure !== undefined },
|
|
);
|
|
} catch (cause: unknown) {
|
|
failure =
|
|
cause instanceof OwnedCommandFailure
|
|
? cause
|
|
: new OwnedCommandFailure(
|
|
"process",
|
|
"Command collection failed.",
|
|
null,
|
|
null,
|
|
{ cause },
|
|
);
|
|
}
|
|
const stopped = await supervisor.stop();
|
|
const snapshot = supervisor.snapshot();
|
|
if (stopped.status === "incomplete" && retainCleanup !== undefined)
|
|
retainCleanup(supervisor);
|
|
else if (stopped.status === "incomplete")
|
|
throw new OwnedCommandFailure(
|
|
failure?.reason ?? "process",
|
|
failure?.message ?? "Command cleanup failed.",
|
|
snapshot,
|
|
stopped.reason,
|
|
{
|
|
...(failure === undefined ? {} : { cause: failure }),
|
|
...(stopped.cleanupResult === undefined
|
|
? {}
|
|
: { cleanupResult: stopped.cleanupResult }),
|
|
...(failure?.collectionFailure === true
|
|
? { collectionFailure: true }
|
|
: {}),
|
|
},
|
|
ownedCommandResources(launched.ownership, spawn.cwd),
|
|
supervisor,
|
|
);
|
|
if (failure !== undefined)
|
|
throw new OwnedCommandFailure(
|
|
failure.reason,
|
|
failure.message,
|
|
snapshot,
|
|
failure.cleanupFailure,
|
|
{
|
|
cause: failure,
|
|
...(failure.cleanupResult === undefined
|
|
? {}
|
|
: { cleanupResult: failure.cleanupResult }),
|
|
...(failure.collectionFailure ? { collectionFailure: true } : {}),
|
|
},
|
|
failure.resources,
|
|
);
|
|
if (callerSignal?.aborted)
|
|
throw new OwnedCommandFailure("cancelled", "Command cancelled.", snapshot);
|
|
return snapshot;
|
|
};
|