From ab2b53ff8017068423dcf8cee28726e4255dd25d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:45:32 +0800 Subject: [PATCH] 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. --- .../debugger/__tests__/relisten.test.ts | 81 +++++++++++++++++++ src/features/debugger/state.ts | 74 ++++++++++++++--- 2 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 src/features/debugger/__tests__/relisten.test.ts diff --git a/src/features/debugger/__tests__/relisten.test.ts b/src/features/debugger/__tests__/relisten.test.ts new file mode 100644 index 000000000..a301eacaa --- /dev/null +++ b/src/features/debugger/__tests__/relisten.test.ts @@ -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(); + 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(); +}); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index e910ff0c4..130e533d5 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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).