diff --git a/src/features/debugger/__tests__/javaCancel.test.ts b/src/features/debugger/__tests__/javaCancel.test.ts new file mode 100644 index 00000000..edbb3dfc --- /dev/null +++ b/src/features/debugger/__tests__/javaCancel.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { provideNotificationService } from "@/workbench/contributions"; +import type { RunConfig } from "@/platform/ipc"; +import { registerDebugActions } from "../actions"; +import { useDebug } from "../state"; + +const ipc = vi.hoisted(() => ({ + javaResolveLaunch: vi.fn(), + cancelRequest: vi.fn(async () => undefined), + javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })), + debugStart: vi.fn(async () => ({ status: "ok", data: 42 })), + fsReadDir: vi.fn(async () => ({ status: "ok", data: [] })), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); +const notify = vi.fn(); +const expire = vi.fn(); +const config: RunConfig = { + id: "application:Main", + name: "Main", + kind: "application", + command: "java", + args: ["-cp", "${classpath}", "Main"], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "java-debug", +}; +let dispose: () => void; +let respond: (result: unknown) => void; +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null, console: {} }); + useProject.setState({ project: { root: "/p" } as never }); + provideNotificationService({ notify, expire }); + ipc.javaResolveLaunch.mockImplementation( + () => + new Promise((resolve) => { + respond = resolve; + }), + ); + dispose = registerDebugActions(); +}); +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(); + expect(ipc.cancelRequest).toHaveBeenCalledWith(ipc.javaResolveLaunch.mock.calls[0]![3]); + expect(expire).toHaveBeenCalledWith(notification.key); + return { starting }; +} + +function expectNoLaunch() { + expect(ipc.javaDebugServer).not.toHaveBeenCalled(); + expect(ipc.debugStart).not.toHaveBeenCalled(); + expect(useDebug.getState().sessions).toEqual({}); + expect(notify).toHaveBeenCalledTimes(1); +} + +it("Stop cancels the resolve request and ends silently without starting a session", async () => { + const { starting } = await startAndStop(); + respond({ status: "error", error: { kind: "cancelled", message: "Cancelled" } }); + await starting; + expectNoLaunch(); +}); + +it("ignores a successful resolve arriving after Stop, including compile warnings", async () => { + const { starting } = await startAndStop(); + respond({ status: "ok", data: { errorCount: 1 } }); + await starting; + expectNoLaunch(); +}); + +it("ignores a failed resolve arriving after Stop", async () => { + const { starting } = await startAndStop(); + respond({ status: "error", error: { kind: "internal", message: "Build failed" } }); + await starting; + expectNoLaunch(); +}); + +it("still reports a real resolve failure and removes the starting notification", async () => { + const starting = executeAction("debug.startConfig", config); + await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); + respond({ status: "error", error: { kind: "internal", message: "Build failed" } }); + await starting; + expect(notify).toHaveBeenLastCalledWith( + expect.objectContaining({ + level: "error", + title: "Cannot start debugging", + body: "Build failed", + }), + ); + expect(expire).toHaveBeenCalledWith(notify.mock.calls[0]![0].key); + expect(ipc.debugStart).not.toHaveBeenCalled(); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 939101f0..3d016a74 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -218,6 +218,48 @@ 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. */ +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); + }, + }, + ], + }); + 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) { const root = useProject.getState().project?.root; if (!root) return; @@ -255,10 +297,7 @@ async function startConfig(arg: unknown) { return; } if (isJavaDebuggable(config)) { - await maybeEditorService() - ?.saveAll() - .catch(() => undefined); - await startSession(await javaLaunchConfig(config, root), { runConfig: origin ?? config }); + await startJavaConfig(config, root, origin ?? config); return; } const launch = launchConfigFor(config, root); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 2614e0fa..3ad22fbe 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -5,7 +5,15 @@ * JVMs started by Maven / Gradle with a JDWP agent are attached to. */ import { executeAction } from "@/platform/actions"; -import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc"; +import { + call, + commands, + IpcError, + nextRequestId, + type LaunchConfig, + type RequestId, + type RunConfig, +} from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, @@ -62,21 +70,30 @@ function offerInstall(e: unknown): never { throw e; } -async function debugServer(module: string): Promise { +async function debugServer(module: string, signal?: AbortSignal): Promise { try { return await call(commands.javaDebugServer(module)); } catch (e) { + signal?.throwIfAborted(); offerInstall(e); } } /** Launch request for an Application configuration (`java … -cp ${classpath} Main args`). */ -export async function javaLaunchConfig(rc: RunConfig, root: string): Promise { +export async function javaLaunchConfig( + rc: RunConfig, + root: string, + preparation?: { request: RequestId; signal: AbortSignal }, +): Promise { const module = rc.cwd ?? ""; 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}'`); - const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, nextRequestId())); + preparation?.signal.throwIfAborted(); + const info = await call( + commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation?.request ?? nextRequestId()), + ); + preparation?.signal.throwIfAborted(); if (info.errorCount > 0) notifications().notify({ group: "run", @@ -87,6 +104,7 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise