fix(debugger): ignore a failed attach to a JVM that re-listened as its debugger detached (#1380)

A JDWP agent in server mode listens again, on a new port, when its debugger detaches. When the debugger detaches as the JVM exits (kotlin-debug-adapter on VM death, or Stop on a Maven/Gradle debug run), the announcement reached the Run feature, which attached again: the new session replaced the one that had just finished, then failed with "Cannot start debugger". An attach to a run's process within 3 s of a session on it ending now keeps the finished session until it succeeds, and a failure is logged instead of reported, its leftover session removed. Sessions record when they ended.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-05 07:45:32 +08:00
1 parent 2ba731f0ec
commit ab2b53ff80
2 files changed
+143 -12

No files matched your search

@@ -0,0 +1,81 @@
import { beforeEach, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import type { LaunchConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideToolWindowService,
type ToolWindowService,
} from "@/workbench/contributions";
import { adoptSession, handleDebugEvent, startSession, useDebug } from "../state";
const ipc = vi.hoisted(() => ({
debugStart: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const attach: LaunchConfig = {
name: "AppKt",
adapter: "kotlin-debug-adapter",
request: "attach",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ hostName: "127.0.0.1", port: 35549 }),
};
const notify = vi.fn();
beforeEach(() => {
useDebug.setState({ sessions: {}, active: null, console: {} });
useProject.setState({ project: { root: "/p" } as never });
provideNotificationService({ notify });
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
vi.clearAllMocks();
// The adapter's session announces itself, then the attach fails: the JVM is gone.
ipc.debugStart.mockImplementation(async () => {
handleDebugEvent(43, { kind: "started", name: "AppKt", adapter: "kotlin-debug-adapter", parent: null });
return { status: "error", error: { kind: "internal", message: "attach failed: Internal error.", data: null } };
});
});
/** A finished session that debugged a JVM of run 7. */
function finished(endedAgoMs: number) {
adoptSession(1, { name: "AppKt", process: 7 });
handleDebugEvent(1, { kind: "terminated" });
useDebug.setState((s) => ({
sessions: { ...s.sessions, 1: { ...s.sessions[1]!, endedAt: Date.now() - endedAgoMs } },
}));
}
it("ignores a failed attach to the port a JVM announced as its debugger detached", async () => {
finished(100);
expect(await startSession(attach, { process: 7, rerunnable: false })).toBeNull();
expect(notify).not.toHaveBeenCalled();
const { sessions, active } = useDebug.getState();
expect(Object.keys(sessions)).toEqual(["1"]);
expect(active).toBe(1);
});
it("reports a failed attach to a JVM of another run, or long after the last session ended", async () => {
finished(100);
await startSession(attach, { process: 8, rerunnable: false });
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
notify.mockClear();
useDebug.setState({ sessions: {}, active: null, console: {} });
finished(60_000);
await startSession(attach, { process: 7, rerunnable: false });
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
expect(useDebug.getState().sessions[1]).toBeUndefined();
});
it("replaces the finished session once an attach soon after it succeeds", async () => {
finished(100);
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 44 });
expect(await startSession(attach, { process: 7, rerunnable: false })).toBe(44);
expect(Object.keys(useDebug.getState().sessions)).toEqual(["44"]);
expect(notify).not.toHaveBeenCalled();
});
+62 -12
View File
@@ -38,6 +38,8 @@ export interface DebugSession {
/** Originating configuration, retained for toolbar targeting and prepared debug reruns. */
runConfig?: RunConfig;
startedAt?: number;
/** When it terminated. */
endedAt?: number;
adapter: string;
parent: DebugSessionId | null;
/** Launch configuration (root sessions), used by Rerun. */
@@ -223,16 +225,58 @@ function ownSession(id: DebugSessionId, event: DebugEvent): DebugSession | null
return event.kind === "started" ? adoptSession(id, { name: event.name, adapter: event.adapter }) : adoptSession(id);
}
/** A start finished (`id` on success): sessions no start claimed are failed starts' leftovers. */
function settleStart(id: DebugSessionId | null) {
/**
* A start finished (`id` on success): sessions no start claimed are failed starts' leftovers,
* kept as terminated or, with `discard`, removed.
*/
function settleStart(id: DebugSessionId | null, discard = false) {
pendingStarts = Math.max(0, pendingStarts - 1);
if (id != null) provisional.delete(id);
if (pendingStarts > 0) return;
const leftovers = [...provisional];
provisional.clear();
if (discard) {
useDebug.setState((st) => {
const sessions = { ...st.sessions };
const console = { ...st.console };
for (const s of leftovers) {
delete sessions[s];
delete console[s];
ansiStreams.delete(s);
}
return { sessions, console, active: st.active != null && sessions[st.active] ? st.active : null };
});
publishContext();
return;
}
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
}
/** Drops finished sessions; running ones stay (multiple concurrent sessions are fine). */
function dropFinished() {
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
publishContext();
}
/**
* A JDWP agent in server mode listens again, on a new port, when its debugger detaches; a
* debugger that detaches as the JVM exits leaves an announcement of a JVM about to be gone.
* An attach to `process` this soon after a session on it ended may be to such a JVM.
*/
const RELISTEN_MS = 3000;
function justDetached(process: RunId | null | undefined): boolean {
if (process == null) return false;
const now = Date.now();
return Object.values(useDebug.getState().sessions).some(
(s) => s.process === process && s.status === "terminated" && s.endedAt != null && now - s.endedAt < RELISTEN_MS,
);
}
export function publishContext() {
const s = activeSession();
const retained = Object.values(useDebug.getState().sessions);
@@ -460,14 +504,10 @@ export async function startSession(
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
return null;
}
// Drop finished sessions; keep running ones (multiple concurrent sessions are fine).
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
publishContext();
// Attaching to a JVM that may be gone: the finished session stays until the attach succeeds.
const afterDetach = justDetached(options.process);
const previousActive = useDebug.getState().active;
if (!afterDetach) dropFinished();
try {
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
} catch {
@@ -482,6 +522,7 @@ export async function startSession(
const id = await call(commands.debugStart(launch));
console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted });
owned.add(id);
if (afterDetach) dropFinished();
if (options.preparation?.signal.aborted) {
await call(commands.debugStop(id)).catch(() => undefined);
settleStart(id);
@@ -526,7 +567,16 @@ export async function startSession(
void syncSpecial();
return id;
} catch (e) {
settleStart(null);
settleStart(null, afterDetach);
if (afterDetach) {
// The JVM that announced the port ended as its previous debugger detached.
console.info("Debugger not attached: the JVM is gone", { name: launch.name, error: e });
const { sessions, active } = useDebug.getState();
if (active == null && previousActive != null && sessions[previousActive])
useDebug.setState({ active: previousActive });
publishContext();
return null;
}
if (options.preparation?.signal.aborted || isCancelled(e)) return null;
if (options.preparation)
appendConsole(options.preparation.id, "stderr", (e instanceof Error ? e.message : String(e)) + "\n");
@@ -612,7 +662,7 @@ export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession |
}
function markTerminated(id: DebugSessionId) {
patch(id, { status: "terminated", frames: [], selectedFrame: null, stoppedThread: null });
patch(id, { status: "terminated", endedAt: Date.now(), frames: [], selectedFrame: null, stoppedThread: null });
const st = useDebug.getState();
if (st.active === id) {
// Prefer another live session (e.g. the parent of a finished child).