mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(debugger): allow cancelling Java launch preparation (#484)
Java debugging resolved the launch and built the workspace before a debug session existed, so during a jdtls build of up to 20 minutes there was no way to stop. A starting notification with Stop now appears before preparation begins, using the same pattern as waiting for a remote JVM. Stop cancels the resolve request, and a late response neither starts a session nor reports an error. Real resolve failures report as before.
This commit is contained in:
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number> {
|
||||
async function debugServer(module: string, signal?: AbortSignal): Promise<number> {
|
||||
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<LaunchConfig> {
|
||||
export async function javaLaunchConfig(
|
||||
rc: RunConfig,
|
||||
root: string,
|
||||
preparation?: { request: RequestId; signal: AbortSignal },
|
||||
): Promise<LaunchConfig> {
|
||||
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<Lau
|
||||
});
|
||||
const resolved = resolveJavaCommand(rc, info);
|
||||
const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(cwd));
|
||||
preparation?.signal.throwIfAborted();
|
||||
const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths;
|
||||
if (kotlin) {
|
||||
return {
|
||||
@@ -108,7 +126,8 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise<Lau
|
||||
}),
|
||||
};
|
||||
}
|
||||
const port = await debugServer(module);
|
||||
const port = await debugServer(module, preparation?.signal);
|
||||
preparation?.signal.throwIfAborted();
|
||||
return {
|
||||
name: rc.name,
|
||||
adapter: JAVA_ADAPTER,
|
||||
|
||||
@@ -3345,6 +3345,7 @@
|
||||
"No changes yet": "还没有改动",
|
||||
"What should the agent do?": "让 agent 做点什么?",
|
||||
"New worktree": "新 worktree",
|
||||
"Starting debugger for {name}…": "正在启动 {name} 的调试器…",
|
||||
"Starting…": "正在启动…",
|
||||
"Waiting for your answer": "等待你的回答",
|
||||
"Waiting for your answer…": "等待你的回答…",
|
||||
|
||||
Reference in New Issue
Block a user