fix(java): show debugger startup progress and support cancellation (#546)

Java debug preparation ran before a session existed, so the toolbar and console stayed idle until jdtls had finished, and background console output waited for an animation frame.

- Create the Debug session as soon as the action starts, with progress in its console and a Stop that cancels preparation.
- Share launch preparation between Run and Debug, and move JDK and Lombok detection off the async runtime.
- Flush background console output on a timer as well as on animation frames, so a hidden window still receives it.
- Log how long each launch phase takes.

Refs #530
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 15:02:39 +08:00
1 джерело a2405c083a
коміт 7610fc3f80
13 files changed
+374 -154

No files matched your search

+11
Переглянути файл
@@ -96,3 +96,14 @@ describe("multiline output", () => {
expect(output.every((line) => line.text.length === 4001)).toBe(true);
});
});
it("flushes output while an unfocused webview suspends animation frames", () => {
vi.useFakeTimers();
vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
adoptSession(13);
appendConsoleQueued(13, "stdout", "Spring Boot starting…\n");
expect(lines(13)).toEqual([]);
vi.advanceTimersByTime(100);
expect(lines(13).map((l) => l.text)).toEqual(["Spring Boot starting…\n"]);
expect(useDebug.getState().sessions[13]?.status).toBe("running");
});
+86 -11
Переглянути файл
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { executeAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { provideNotificationService } from "@/workbench/contributions";
import { provideNotificationService, useDebugRunSessions } from "@/workbench/contributions";
import type { RunConfig } from "@/platform/ipc";
import { registerDebugActions } from "../actions";
import { useDebug } from "../state";
@@ -11,6 +11,7 @@ const ipc = vi.hoisted(() => ({
cancelRequest: vi.fn(async () => undefined),
javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })),
debugStart: vi.fn(async () => ({ status: "ok", data: 42 })),
debugStop: vi.fn(async () => ({ status: "ok", data: null })),
fsReadDir: vi.fn(async () => ({ status: "ok", data: [] })),
}));
vi.mock("@/platform/ipc", async (original) => {
@@ -52,23 +53,30 @@ afterEach(() => dispose());
async function startAndStop() {
const starting = executeAction("debug.startConfig", config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
const notification = notify.mock.calls[0]![0];
expect(notification).toMatchObject({ sticky: true, title: "Starting debugger for Main…" });
expect(notification.actions[0].label).toBe("Stop");
notification.actions[0].run();
const session = Object.values(useDebug.getState().sessions)[0]!;
expect(session).toMatchObject({ name: "Main", status: "starting", runConfig: config });
expect(
useDebug
.getState()
.console[session.id]?.map((l) => l.text)
.join("\n"),
).toContain("Building and resolving the classpath of Main…");
expect(useDebugRunSessions.getState().sessions[0]?.configId).toBe(config.id);
await executeAction("debug.stop");
expect(ipc.cancelRequest).toHaveBeenCalledWith(ipc.javaResolveLaunch.mock.calls[0]![3]);
expect(expire).toHaveBeenCalledWith(notification.key);
expect(useDebug.getState().sessions[session.id]?.status).toBe("terminated");
expect(useDebugRunSessions.getState().sessions).toEqual([]);
return { starting };
}
function expectNoLaunch() {
expect(ipc.javaDebugServer).not.toHaveBeenCalled();
expect(ipc.debugStart).not.toHaveBeenCalled();
expect(useDebug.getState().sessions).toEqual({});
expect(notify).toHaveBeenCalledTimes(1);
expect(Object.values(useDebug.getState().sessions).every((s) => s.status === "terminated")).toBe(true);
expect(notify).not.toHaveBeenCalled();
}
it("Stop cancels the resolve request and ends silently without starting a session", async () => {
it("Stop cancels the resolve request and ends the preparation session without starting DAP", async () => {
const { starting } = await startAndStop();
respond({ status: "error", error: { kind: "cancelled", message: "Cancelled" } });
await starting;
@@ -89,7 +97,7 @@ it("ignores a failed resolve arriving after Stop", async () => {
expectNoLaunch();
});
it("still reports a real resolve failure and removes the starting notification", async () => {
it("still reports a real resolve failure in the preparation console", async () => {
const starting = executeAction("debug.startConfig", config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
respond({ status: "error", error: { kind: "internal", message: "Build failed" } });
@@ -101,6 +109,73 @@ it("still reports a real resolve failure and removes the starting notification",
body: "Build failed",
}),
);
expect(expire).toHaveBeenCalledWith(notify.mock.calls[0]![0].key);
expect(
Object.values(useDebug.getState().console)
.flat()
.map((line) => line.text)
.join("\n"),
).toContain("Build failed");
expect(ipc.debugStart).not.toHaveBeenCalled();
});
it("keeps progress and the originating configuration when DAP takes over", async () => {
const starting = executeAction("debug.startConfig", config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
respond({
status: "ok",
data: {
build: "succeeded",
errorCount: 0,
errors: [],
hints: [],
classPaths: ["/p/target/classes"],
modulePaths: [],
pathListSeparator: ":",
javaExec: "java",
},
});
await starting;
expect(Object.keys(useDebug.getState().sessions)).toEqual(["42"]);
expect(useDebug.getState().sessions[42]).toMatchObject({ status: "running", runConfig: config });
expect(
useDebug
.getState()
.console[42]?.map((line) => line.text)
.join("\n"),
).toContain("Building and resolving the classpath of Main…");
});
it.each(["success", "failure"])("handles a late DAP %s after preparation was cancelled", async (outcome) => {
let fail!: (error: Error) => void;
let finish!: (value: { status: "ok"; data: number }) => void;
ipc.debugStart.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
finish = resolve;
fail = reject;
}),
);
const starting = executeAction("debug.startConfig", config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
respond({
status: "ok",
data: {
build: "succeeded",
errorCount: 0,
errors: [],
hints: [],
classPaths: ["/p/target/classes"],
modulePaths: [],
pathListSeparator: ":",
javaExec: "java",
},
});
await vi.waitFor(() => expect(ipc.debugStart).toHaveBeenCalledOnce());
await executeAction("debug.stop");
if (outcome === "success") finish({ status: "ok", data: 42 });
else fail(new Error("Adapter start failed"));
await starting;
if (outcome === "success") expect(ipc.debugStop).toHaveBeenCalledWith(42);
expect(notify).not.toHaveBeenCalled();
expect(useDebugRunSessions.getState().sessions).toEqual([]);
});
+16 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
@@ -42,6 +42,21 @@ const session: DebugSession = {
};
describe("debug tool window", () => {
it("shows preparation output, preserves a chosen tab and opens variables on pause", () => {
useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 });
render(<DebugToolWindow />);
const console = screen.getByRole("tab", { name: "Console" });
const variables = screen.getByRole("tab", { name: "Threads & Variables" });
expect(console.getAttribute("aria-selected")).toBe("true");
fireEvent.click(variables);
expect(variables.getAttribute("aria-selected")).toBe("true");
fireEvent.click(console);
act(() => useDebug.setState({ sessions: { 1: { ...session, status: "running" } } }));
expect(console.getAttribute("aria-selected")).toBe("true");
act(() => useDebug.setState({ sessions: { 1: session } }));
expect(variables.getAttribute("aria-selected")).toBe("true");
});
it("renders session tabs, views, frames and typed variables", async () => {
useDebug.setState({ sessions: { 1: session }, active: 1 });
render(
+19 -40
Переглянути файл
@@ -30,6 +30,7 @@ import {
sessionSupports,
showExecutionPoint,
startSession,
startPreparedSession,
stopSession,
useDebug,
type DebugSession,
@@ -218,46 +219,24 @@ async function startRemote(config: RunConfig, root: string) {
if (launch) await startSession(launch, { runConfig: config });
}
/** Java preparation has no backend session id yet; expose Stop like remote JVM listening. */
/** Preparation belongs to a session from the moment the action begins. */
async function startJavaConfig(config: RunConfig, root: string, origin: RunConfig) {
const request = nextRequestId();
const controller = new AbortController();
const key = `debug.prepare.${request}`;
let preparing = true;
notifications().notify({
group: "run",
level: "info",
key,
sticky: true,
title: tx`Starting debugger for ${config.name}…`,
actions: [
{
label: "Stop",
run: () => {
if (!preparing || controller.signal.aborted) return;
controller.abort();
notifications().expire?.(key);
void commands.cancelRequest(request).catch(() => undefined);
},
},
],
await startPreparedSession(origin, async (signal, report) => {
const request = nextRequestId();
const cancel = () => {
void commands.cancelRequest(request).catch(() => undefined);
};
signal.addEventListener("abort", cancel, { once: true });
try {
await maybeEditorService()
?.saveAll()
.catch(() => undefined);
signal.throwIfAborted();
return await javaLaunchConfig(config, root, { request, signal, report });
} finally {
signal.removeEventListener("abort", cancel);
}
});
let launch;
try {
await maybeEditorService()
?.saveAll()
.catch(() => undefined);
controller.signal.throwIfAborted();
launch = await javaLaunchConfig(config, root, { request, signal: controller.signal });
controller.signal.throwIfAborted();
} catch (e) {
if (controller.signal.aborted || isCancelled(e)) return;
throw e;
} finally {
preparing = false;
notifications().expire?.(key);
}
await startSession(launch, { runConfig: origin });
}
async function startConfig(arg: unknown) {
@@ -417,10 +396,10 @@ export function registerDebugActions(): () => void {
title: "Pause Program",
category: "Debug",
icon: "pause",
when: "debugSessionActive && !debugSessionPaused",
when: "debugSessionRunning",
run: async () => {
const s = activeSession();
if (!s || !isLive(s)) return;
if (!s || s.status !== "running") return;
let thread = s.selectedThread ?? s.threads[0]?.id;
if (thread == null) {
const threads = await call(commands.debugThreads(s.id)).catch(() => []);
+10 -11
Переглянути файл
@@ -11,7 +11,6 @@ import {
IpcError,
nextRequestId,
type LaunchConfig,
type RequestId,
type RunConfig,
} from "@/platform/ipc";
import {
@@ -19,8 +18,8 @@ import {
isJvmApplication,
joinJavaArgs,
parseJavaArgs,
resolveJavaCommand,
resolveJavaModule,
prepareJavaLaunch,
type JavaPreparation,
} from "@/platform/javaLaunch";
import { notifications } from "@/workbench/contributions";
@@ -84,19 +83,20 @@ async function debugServer(module: string, signal?: AbortSignal): Promise<number
export async function javaLaunchConfig(
rc: RunConfig,
root: string,
preparation?: { request: RequestId; signal: AbortSignal },
preparation?: JavaPreparation,
): Promise<LaunchConfig> {
const cwd = rc.cwd ? join(root, rc.cwd) : root;
const parsed = parseJavaArgs(rc.args);
if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`);
preparation?.signal.throwIfAborted();
const module = await resolveJavaModule(rc);
preparation?.signal.throwIfAborted();
const info = await call(
commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation?.request ?? nextRequestId()),
const { module, info, resolved } = await prepareJavaLaunch(
rc,
preparation ?? {
request: nextRequestId(),
signal: new AbortController().signal,
},
);
preparation?.signal.throwIfAborted();
if (info.errorCount > 0)
if (info.errorCount > 0 && !preparation?.report)
notifications().notify({
group: "run",
level: "warning",
@@ -104,7 +104,6 @@ export async function javaLaunchConfig(
title: `'${rc.name}' has compile errors; debugging the last compiled classes`,
body: formatCompileErrors(info).trimEnd(),
});
const resolved = resolveJavaCommand(rc, info);
const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(join(root, module)));
preparation?.signal.throwIfAborted();
const classPaths = parsed.explicitClasspath
+95 -12
Переглянути файл
@@ -2,6 +2,7 @@
* Debug sessions as the UI sees them, fed by `IdeEvent::Debug` and by pulls
* (threads, frames, locals) after each stop.
*/
import { tx } from "@/platform/i18n";
import { workflowSettings } from "@/platform/workflowSettings";
import { executeAction } from "@/platform/actions";
import { create } from "zustand";
@@ -11,6 +12,7 @@ import {
call,
commands,
isTauri,
isCancelled,
type DebugCapabilities,
type DebugEvent,
type DebugSessionId,
@@ -108,7 +110,14 @@ interface PendingOutput {
raw: string;
}
const pendingOutput: PendingOutput[] = [];
let outputFlushHandle: number | null = null;
let outputFlushHandle: { raf: number; timeout: number } | null = null;
function cancelOutputFlush() {
if (!outputFlushHandle) return;
cancelAnimationFrame(outputFlushHandle.raf);
clearTimeout(outputFlushHandle.timeout);
outputFlushHandle = null;
}
function ansiStream(session: DebugSessionId, category: string): AnsiStream {
let streams = ansiStreams.get(session);
@@ -250,6 +259,7 @@ export function publishContext() {
});
const anyLive = Object.values(useDebug.getState().sessions).some(isLive);
setContext("debugSessionActive", anyLive);
setContext("debugSessionRunning", s?.status === "running");
setContext("debugSessionPaused", s?.status === "paused");
setContext("debugCanRerun", !!s && !!(rootOf(s).runConfig || rootOf(s).launch));
setContext(
@@ -277,7 +287,7 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st
/**
* Queues streamed debuggee output: ANSI parsing and the console store update happen
* once per animation frame with all pending chunks merged per
* once per animation frame (or timer while unfocused), with pending chunks merged per
* (session, category), so a flood of `output` events can't saturate React.
*/
export function appendConsoleQueued(session: DebugSessionId, category: string, raw: string) {
@@ -289,14 +299,15 @@ export function appendConsoleQueued(session: DebugSessionId, category: string, r
pendingOutput.push({ session, category, raw });
}
if (outputFlushHandle === null) {
outputFlushHandle = requestAnimationFrame(() => {
outputFlushHandle = null;
flushConsoleOutput();
});
outputFlushHandle = {
raf: requestAnimationFrame(flushConsoleOutput),
timeout: window.setTimeout(flushConsoleOutput, 100),
};
}
}
function flushConsoleOutput() {
cancelOutputFlush();
if (pendingOutput.length === 0) return;
const chunks = pendingOutput.splice(0);
useDebug.setState((s) => {
@@ -362,16 +373,61 @@ function trimConsole(lines: ConsoleLine[]) {
}
export function clearConsole(session: DebugSessionId) {
flushConsoleOutput();
ansiStreams.delete(session);
useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } }));
}
// --- lifecycle ---------------------------------------------------------------
let preparationSeq = 0;
const preparations = new Map<DebugSessionId, AbortController>();
/** A visible session owns preparation, including its console and Stop, before DAP has an id. */
export async function startPreparedSession(
config: RunConfig,
prepare: (signal: AbortSignal, report: (text: string, kind?: "system" | "stderr") => void) => Promise<LaunchConfig>,
): Promise<DebugSessionId | null> {
const id = --preparationSeq as DebugSessionId;
const controller = new AbortController();
preparations.set(id, controller);
useDebug.setState((s) => ({
sessions: {
...s.sessions,
[id]: blank(id, { name: config.name, adapter: config.debugAdapter ?? "", runConfig: config }),
},
console: { ...s.console, [id]: [] },
active: id,
}));
publishContext();
try {
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
} catch {
/* no workbench (tests) */
}
const report = (text: string, kind = "system") => {
if (!controller.signal.aborted && useDebug.getState().sessions[id]) appendConsole(id, kind, text);
};
try {
report(tx`Starting debugger for ${config.name}…` + "\n");
const launch = await prepare(controller.signal, report);
controller.signal.throwIfAborted();
return await startSession(launch, { runConfig: config, preparation: { id, signal: controller.signal } });
} catch (e) {
if (controller.signal.aborted || isCancelled(e)) return null;
report((e instanceof Error ? e.message : String(e)) + "\n", "stderr");
throw e;
} finally {
preparations.delete(id);
if (useDebug.getState().sessions[id]) markTerminated(id);
}
}
export async function startSession(
launch: LaunchConfig,
options: {
runConfig?: RunConfig;
preparation?: { id: DebugSessionId; signal: AbortSignal };
/** A process started for this session (e.g. Maven with a JDWP agent): Stop ends it too. */
process?: RunId | null;
/** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */
@@ -398,18 +454,38 @@ export async function startSession(
pendingStarts++;
try {
await flushBreakpoints();
options.preparation?.signal.throwIfAborted();
const adapterStarted = performance.now();
const id = await call(commands.debugStart(launch));
console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted });
owned.add(id);
if (options.preparation?.signal.aborted) {
await call(commands.debugStop(id)).catch(() => undefined);
settleStart(id);
if (useDebug.getState().sessions[id]) markTerminated(id);
return null;
}
flushConsoleOutput();
const preparation = options.preparation && useDebug.getState().sessions[options.preparation.id];
useDebug.setState((s) => {
const cur = s.sessions[id] ?? blank(id, { status: "running" });
const sessions = { ...s.sessions };
const console = { ...s.console };
if (preparation) {
console[id] = [...(console[preparation.id] ?? []), ...(console[id] ?? [])];
delete console[preparation.id];
delete sessions[preparation.id];
ansiStreams.delete(preparation.id);
}
return {
console,
sessions: {
...s.sessions,
...sessions,
[id]: {
...cur,
name: launch.name,
runConfig: options.runConfig,
startedAt,
startedAt: preparation?.startedAt ?? startedAt,
adapter: launch.adapter,
launch: options.rerunnable === false ? null : launch,
process: options.process ?? null,
@@ -428,6 +504,9 @@ export async function startSession(
return id;
} catch (e) {
settleStart(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");
const missing = missingAdapter(launch.adapter, e);
if (missing) {
notifications().notify({
@@ -461,6 +540,13 @@ export async function stopSession(id: DebugSessionId) {
const s = useDebug.getState().sessions[id];
if (!s) return;
const root = rootOf(s);
const preparation = preparations.get(root.id);
if (preparation) {
preparation.abort();
appendConsole(root.id, "system", tx("Launch cancelled.") + "\n");
markTerminated(root.id);
return;
}
try {
await call(commands.debugStop(root.id));
} catch (e) {
@@ -799,10 +885,7 @@ export function resetSessions() {
const { sessions } = useDebug.getState();
for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id);
pendingOutput.length = 0;
if (outputFlushHandle !== null) {
cancelAnimationFrame(outputFlushHandle);
outputFlushHandle = null;
}
cancelOutputFlush();
ansiStreams.clear();
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
+13 -2
Переглянути файл
@@ -144,7 +144,18 @@ export function DebugTabs() {
export function DebugToolWindow() {
const active = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
const muted = useBreakpoints((s) => s.muted);
const [tab, setTab] = useState<Tab>("variables");
const [view, setView] = useState<{
session: number | undefined;
status: DebugSession["status"] | undefined;
tab: Tab;
}>({ session: undefined, status: undefined, tab: "variables" });
if (active && (view.session !== active.startedAt || view.status !== active.status))
setView({
session: active.startedAt,
status: active.status,
tab: active.status === "starting" ? "console" : active.status === "paused" ? "variables" : view.tab,
});
const tab = view.tab;
if (!active) {
return (
@@ -175,7 +186,7 @@ export function DebugToolWindow() {
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setTab(id)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{label}
</button>