* 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>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js";
|
|
|
|
/** Result of terminating one debugger process owned by this application. */
|
|
export type OwnedDebuggerStopResult =
|
|
| { readonly status: "stopped" | "already_exited" }
|
|
| { readonly status: "incomplete"; readonly reason: string };
|
|
|
|
const DETACH_TIMEOUT_MS = 1_000;
|
|
const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024;
|
|
export type OwnedDebuggerDetachResult =
|
|
| { readonly status: "no-inferior" }
|
|
| { readonly status: "detached"; readonly outputSink: "durable" };
|
|
export interface OwnedInferiorIdentity {
|
|
readonly pid: string;
|
|
readonly startTime: string;
|
|
}
|
|
|
|
export type ProcessStartTimeReader = (pid: string) => Promise<string | null>;
|
|
|
|
const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => {
|
|
if (process.platform !== "linux")
|
|
throw new Error(
|
|
"Inferior process identity verification requires Linux /proc.",
|
|
);
|
|
let stat: string;
|
|
try {
|
|
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
} catch (cause: unknown) {
|
|
if (
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT"
|
|
)
|
|
return null;
|
|
throw cause;
|
|
}
|
|
const startTime = parseLinuxProcessStartTime(stat);
|
|
if (startTime === undefined)
|
|
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
|
|
return startTime;
|
|
};
|
|
|
|
/** Confirm only that every previously identified inferior process has ended. */
|
|
export const verifyOwnedInferiorsEnded = async (
|
|
identities: Iterable<OwnedInferiorIdentity>,
|
|
readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime,
|
|
): Promise<boolean> => {
|
|
let foundIdentity = false;
|
|
for (const identity of identities) {
|
|
foundIdentity = true;
|
|
const observed = await readStartTime(identity.pid);
|
|
if (observed === identity.startTime) return false;
|
|
}
|
|
return foundIdentity;
|
|
};
|
|
|
|
const detachAttempts = new WeakMap<
|
|
object,
|
|
Promise<OwnedDebuggerDetachResult>
|
|
>();
|
|
const detachOutcomes = new WeakMap<
|
|
object,
|
|
"confirmed" | "unconfirmed" | "inferior-ended"
|
|
>();
|
|
|
|
const isVerifiedDetachResult = (
|
|
result: unknown,
|
|
): result is OwnedDebuggerDetachResult =>
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
(("status" in result && result.status === "no-inferior") ||
|
|
("status" in result &&
|
|
result.status === "detached" &&
|
|
"outputSink" in result &&
|
|
result.outputSink === "durable"));
|
|
|
|
const waitForInferiorDetach = async (
|
|
session: object,
|
|
detach: () => Promise<OwnedDebuggerDetachResult>,
|
|
): Promise<OwnedDebuggerDetachResult | undefined> => {
|
|
let attempt = detachAttempts.get(session);
|
|
if (attempt === undefined) {
|
|
// Providers may use `this` to access their command transport.
|
|
const pending = Promise.resolve().then(() => detach.call(session));
|
|
const tracked = pending.then(
|
|
(result) => {
|
|
detachAttempts.delete(session);
|
|
detachOutcomes.set(
|
|
session,
|
|
isVerifiedDetachResult(result) ? "confirmed" : "unconfirmed",
|
|
);
|
|
return result;
|
|
},
|
|
(cause: unknown) => {
|
|
detachAttempts.delete(session);
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
throw cause;
|
|
},
|
|
);
|
|
attempt = tracked;
|
|
detachAttempts.set(session, tracked);
|
|
}
|
|
let timer!: NodeJS.Timeout;
|
|
try {
|
|
return await Promise.race([
|
|
attempt,
|
|
new Promise<undefined>((resolve) => {
|
|
timer = setTimeout(() => resolve(undefined), DETACH_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
interface OwnedDebuggerProcess {
|
|
readonly id: string;
|
|
readonly exited: boolean;
|
|
readonly inferiorStateUnknown?: () => boolean;
|
|
readonly verifyInferiorsEndedAfterExit?: () => Promise<boolean>;
|
|
readonly launched: {
|
|
readonly process: { kill(signal: NodeJS.Signals): boolean };
|
|
};
|
|
/** Must return only after the provider verifies detachment or no inferior. */
|
|
/** Confirms no inferior, or detaches only after verifying a durable output sink. */
|
|
readonly detachInferior?: () => Promise<OwnedDebuggerDetachResult>;
|
|
readonly supervisor: {
|
|
waitForExit(timeoutMs: number): Promise<boolean>;
|
|
/** WindowsOwnedProcess closes output only after the whole Job Object settles. */
|
|
waitForOutputClose(timeoutMs: number): Promise<boolean>;
|
|
unrefOutputStreams(): void;
|
|
/** Cap bytes retained after debugger shutdown while continuing to drain. */
|
|
limitAdditionalOutputBytes(maxBytes: number): void;
|
|
dispose(): void;
|
|
};
|
|
}
|
|
|
|
const finishExitedDebuggerSession = async (
|
|
session: OwnedDebuggerProcess,
|
|
debuggerName: string,
|
|
platform: NodeJS.Platform,
|
|
status: "stopped" | "already_exited",
|
|
): Promise<OwnedDebuggerStopResult> => {
|
|
try {
|
|
session.supervisor.limitAdditionalOutputBytes(
|
|
MAX_LATE_DEBUGGER_OUTPUT_BYTES,
|
|
);
|
|
if (!(await session.supervisor.waitForOutputClose(1_000))) {
|
|
return {
|
|
status: "incomplete",
|
|
reason:
|
|
platform === "win32"
|
|
? `Owned ${debuggerName} process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.`
|
|
: `Owned ${debuggerName} process leader exited, but inherited output streams are still open or have not reported settlement.`,
|
|
};
|
|
}
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason:
|
|
platform === "win32"
|
|
? `Owned ${debuggerName} Windows Job Object settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
: `Owned ${debuggerName} inherited output stream settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
if (platform !== "win32") session.supervisor.unrefOutputStreams();
|
|
session.supervisor.dispose();
|
|
return { status };
|
|
};
|
|
|
|
/** Stop only the directly owned debugger process, never its process group or target. */
|
|
export const stopOwnedDebuggerProcess = async (
|
|
session: OwnedDebuggerProcess,
|
|
debuggerName: string,
|
|
options: { readonly platform?: NodeJS.Platform } = {},
|
|
): Promise<OwnedDebuggerStopResult> => {
|
|
const platform = options.platform ?? process.platform;
|
|
if (session.exited) {
|
|
if (detachAttempts.has(session))
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach is still pending after the process exited.`,
|
|
};
|
|
const detachOutcome = detachOutcomes.get(session);
|
|
if (
|
|
detachOutcome === "unconfirmed" ||
|
|
(detachOutcome === undefined && session.inferiorStateUnknown?.() === true)
|
|
) {
|
|
try {
|
|
if ((await session.verifyInferiorsEndedAfterExit?.()) !== true)
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior disposition was not verified before the debugger exited, and no owner proof confirms the identified inferior processes have ended.`,
|
|
};
|
|
detachOutcomes.set(session, "inferior-ended");
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior process identity could not be verified after debugger exit: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
}
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"already_exited",
|
|
);
|
|
}
|
|
if (platform === "win32")
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.`,
|
|
};
|
|
try {
|
|
if (session.detachInferior === undefined) {
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach cannot be verified because the provider has no detach capability.`,
|
|
};
|
|
}
|
|
const detached = await waitForInferiorDetach(
|
|
session,
|
|
session.detachInferior,
|
|
);
|
|
if (detached === undefined)
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach was not verified within ${DETACH_TIMEOUT_MS} ms.`,
|
|
};
|
|
if (!isVerifiedDetachResult(detached)) {
|
|
detachOutcomes.set(session, "unconfirmed");
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} provider did not confirm no inferior or detachment with a durable output sink.`,
|
|
};
|
|
}
|
|
detachOutcomes.set(session, "confirmed");
|
|
} catch (cause: unknown) {
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
};
|
|
}
|
|
session.launched.process.kill("SIGTERM");
|
|
if (!(await session.supervisor.waitForExit(300))) {
|
|
session.launched.process.kill("SIGKILL");
|
|
const exitedAfterKill = await session.supervisor.waitForExit(1_000);
|
|
if (exitedAfterKill)
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"stopped",
|
|
);
|
|
return {
|
|
status: "incomplete",
|
|
reason: `Owned ${debuggerName} process did not exit after direct termination.`,
|
|
};
|
|
}
|
|
return finishExitedDebuggerSession(
|
|
session,
|
|
debuggerName,
|
|
platform,
|
|
"stopped",
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Stop every owned session and retain failed sessions for possible recovery.
|
|
* Callers must block replacement admission and drain in-flight starts until
|
|
* this settles; incomplete cleanup leaves the old owner in the map for retry.
|
|
*/
|
|
export const closeOwnedDebuggerSessions = async <
|
|
Session extends { id: string },
|
|
>(
|
|
sessions: Map<string, Session>,
|
|
stop: (session: Session) => Promise<OwnedDebuggerStopResult>,
|
|
debuggerName: string,
|
|
): Promise<void> => {
|
|
const results = await Promise.allSettled(
|
|
[...sessions.entries()].map(async ([key, session]) => ({
|
|
key,
|
|
session,
|
|
result: await stop(session),
|
|
})),
|
|
);
|
|
const failures: Error[] = [];
|
|
for (const settled of results) {
|
|
if (settled.status === "rejected") {
|
|
failures.push(
|
|
settled.reason instanceof Error
|
|
? settled.reason
|
|
: new Error(String(settled.reason)),
|
|
);
|
|
continue;
|
|
}
|
|
const { key, session, result } = settled.value;
|
|
if (result.status === "incomplete") failures.push(new Error(result.reason));
|
|
else if (sessions.get(key) === session) sessions.delete(key);
|
|
}
|
|
if (failures.length > 0)
|
|
throw new AggregateError(
|
|
failures,
|
|
`One or more owned ${debuggerName} processes did not stop cleanly`,
|
|
);
|
|
};
|