diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 55f29531e7..a5e587c8a1 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -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))) { diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index a03bea48fc..155f7457e1 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -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 => { 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 {