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; /** Read one Linux identity; absence is distinct from unreadable or malformed data. */ export 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, readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime, ): Promise => { 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 >(); 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, ): Promise => { 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((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; 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; readonly supervisor: { waitForExit(timeoutMs: number): Promise; /** WindowsOwnedProcess closes output only after the whole Job Object settles. */ waitForOutputClose(timeoutMs: number): Promise; 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 => { 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. * The caller must permanently close command admission before the first attempt; * retries retain completed detach facts while process termination is pending. */ export const stopOwnedDebuggerProcess = async ( session: OwnedDebuggerProcess, debuggerName: string, options: { readonly platform?: NodeJS.Platform } = {}, ): Promise => { 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.`, }; // Admission remains closed once cleanup starts. A verified detach is an // owned completion fact, even when terminating the debugger later fails. // Retrying the command transport can no longer improve that fact and may // block termination if shutdown already made the transport unavailable. if (detachOutcomes.get(session) !== "confirmed") { 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 ${String(DETACH_TIMEOUT_MS)} ms. This release has no runtime override.`, }; 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, stop: (session: Session) => Promise, debuggerName: string, ): Promise => { 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`, ); };