fix(debugger): retain verified detach across cleanup retries

This commit is contained in:
morluto
2026-10-27 14:04:49 +08:00
parent 1fe92bff8e
commit d14f199736
2 changed files with 51 additions and 38 deletions
+37 -27
View File
@@ -174,7 +174,11 @@ const finishExitedDebuggerSession = async (
return { status };
};
/** Stop only the directly owned debugger process, never its process group or target. */
/**
* 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,
@@ -218,36 +222,42 @@ export const stopOwnedDebuggerProcess = async (
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");
// 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 cannot be verified because the provider has no detach capability.`,
reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
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))) {
@@ -224,6 +224,17 @@ it("retains process supervision after an incomplete stop and disposes after a re
let disposals = 0;
let outputUnrefs = 0;
const signals: NodeJS.Signals[] = [];
const detachInferior = vi
.fn<
() => Promise<{
readonly status: "detached";
readonly outputSink: "durable";
}>
>()
.mockResolvedValueOnce({ status: "detached", outputSink: "durable" })
.mockRejectedValue(
new Error("debugger transport closed during termination"),
);
const session = {
id: "debugger-session",
exited: false,
@@ -235,11 +246,7 @@ it("retains process supervision after an incomplete stop and disposes after a re
},
},
},
detachInferior: async () =>
({
status: "detached",
outputSink: "durable",
}) as const,
detachInferior,
supervisor: {
waitForExit: async (): Promise<boolean> => {
waits += 1;
@@ -273,6 +280,7 @@ it("retains process supervision after an incomplete stop and disposes after a re
expect(retry).toEqual({ status: "stopped" });
expect(disposals).toBe(1);
expect(waits).toBe(3);
expect(detachInferior).toHaveBeenCalledOnce();
});
it("refuses Windows job-backed debugger termination before signaling", async () => {
@@ -694,11 +702,6 @@ it("retains an owned debugger when inferior detach does not settle", async () =>
},
detachInferior: () => {
detachCalls += 1;
if (detachCalls > 1)
return Promise.resolve({
status: "detached",
outputSink: "durable",
} as const);
return new Promise<{
readonly status: "detached";
readonly outputSink: "durable";
@@ -744,7 +747,7 @@ it("retains an owned debugger when inferior detach does not settle", async () =>
expect(
await stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).toEqual({ status: "stopped" });
expect(detachCalls).toBe(2);
expect(detachCalls).toBe(1);
expect(signals).toEqual(["SIGTERM"]);
expect(disposals).toBe(1);
} finally {