mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
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();
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user