Files
reverse-engineering-anything/src/process/ownedDebuggerSession.ts
T
morluto 49e67aa1d9 fix(process): retain native ownership evidence through cleanup
Keep a run-scoped evidence owner from acquisition through settlement and
cleanup retry. Bind token observations to native generations and retain
related generations and unresolved identity lookups across table omissions,
reparenting and process-group changes without treating PID hints as authority.

Use native effective UID facts for withholding decisions, corroborate Linux
procfs absence, and share the Linux stat parser with sampling and debugger
identity checks. Signal only after current token and group revalidation.
Complete swept cleanup across all groups with one bounded final family check;
retain unresolved owners and successful-peer completion across retries.

Validation: npm run check, docs:check and test discovery passed. Focused
ownership/consumer coverage totals 513 passed and 1 skipped across 35 files,
including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real
owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran.
Real Electron applications, native Linux races/PID reuse and Windows cleanup
were not qualified. The original Linux reports remain open for that work.

Fixes #5238
Refs #4322, #5206
2026-10-19 20:04:00 +08:00

312 lines
10 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { parseLinuxProcessStat } from "./LinuxProcessStat.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 parsed = parseLinuxProcessStat(stat);
if (parsed === undefined || parsed.pid !== Number(pid))
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
return parsed.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`,
);
};