From 7610fc3f8031c1de44089f7e6c8b519d4819514e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:02:39 +0800 Subject: [PATCH] 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 --- crates/ide-host/src/ops/java.rs | 106 +++++++++-------- crates/ide-lsp/src/jdtls.rs | 15 ++- .../debugger/__tests__/console.test.ts | 11 ++ .../debugger/__tests__/javaCancel.test.ts | 97 ++++++++++++++-- .../debugger/__tests__/toolWindow.test.tsx | 17 ++- src/features/debugger/actions.ts | 59 ++++------ src/features/debugger/java.ts | 21 ++-- src/features/debugger/state.ts | 107 ++++++++++++++++-- src/features/debugger/ui/DebugToolWindow.tsx | 15 ++- src/features/run/resolveCancellation.test.ts | 1 + src/features/run/store.ts | 42 +++---- src/platform/javaLaunch.ts | 32 +++++- src/platform/locales/text/zh.json | 5 + 13 files changed, 374 insertions(+), 154 deletions(-) diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 69b02708a..7092a1b51 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -34,58 +34,72 @@ async fn resolve_launch( build: bool, cancel: &CancellationToken, ) -> IdeResult { + let started = std::time::Instant::now(); let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. let mut info = project.lsp.java_launch(&module, test, build, cancel).await?; - let built = if build && compile::has_non_java_sources(&module) { - let (root, cache, module, settings) = - (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); - let cancel = cancel.clone(); - tokio::task::spawn_blocking(move || { - compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel) - }) - .await - .map_err(|e| IdeError::internal(e.to_string()))?? - } else { - Vec::new() - }; - let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); - for p in std::mem::take(&mut info.class_paths) { - if !class_paths.contains(&p) { - class_paths.push(p); + let lsp_ms = started.elapsed().as_millis(); + let data_dir = host.data_dir.clone(); + let cancel = cancel.clone(); + tokio::task::spawn_blocking(move || { + let built = if build && compile::has_non_java_sources(&module) { + compile::build_for_launch( + &project.root, + &load(&project.root, &project.build_cache_dir(), &settings), + &module, + test, + &cancel, + )? + } else { + Vec::new() + }; + let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + for p in std::mem::take(&mut info.class_paths) { + if !class_paths.contains(&p) { + class_paths.push(p); + } } - } - info.class_paths = class_paths; - // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. - let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); - let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); - let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { - let release = ide_sdk::java::project_java_version(&module); - let jdks = ide_sdk::java::installed_jdks(); - info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); - ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) - }); - info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); - let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); - let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); - info.shorten_command_line = Some(shorten.as_str().to_owned()); - if info.error_count > 0 { - let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); - let managed = ide_tools::managed_bundle(&host.data_dir, "lombok"); - let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); - let root = project.root.clone(); - // Detection may read the project's sources. - let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo)) - .await - .map_err(|e| IdeError::internal(e.to_string()))?; - info.hints.extend(lombok.problem()); - } - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - Ok(info) + info.class_paths = class_paths; + let compile_ms = started.elapsed().as_millis() - lsp_ms; + // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. + let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); + let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); + let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { + let release = ide_sdk::java::project_java_version(&module); + let jdks = ide_sdk::java::installed_jdks(); + info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); + ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) + }); + info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); + let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); + let shorten = + ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); + info.shorten_command_line = Some(shorten.as_str().to_owned()); + if info.error_count > 0 { + let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); + let managed = ide_tools::managed_bundle(&data_dir, "lombok"); + let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); + let root = project.root.clone(); + // Detection may read the project's sources. + let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo); + info.hints.extend(lombok.problem()); + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + tracing::info!( + lsp_ms, + compile_ms, + jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms, + total_ms = started.elapsed().as_millis(), + "Java launch resolved" + ); + Ok(info) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 641037482..3c8da6806 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -306,9 +306,12 @@ impl LspManager { timeout: Duration, cancel: &CancellationToken, ) -> IdeResult { + let started = Instant::now(); let (server, run) = self.jdtls(path, cancel).await?; + let ready_ms = started.elapsed().as_millis(); + let phase = params.get("command").and_then(Value::as_str).unwrap_or(method).to_owned(); let fut = run.peer.request::(method, params, Some(cancel.clone())); - match tokio::time::timeout(timeout, fut).await { + let result = match tokio::time::timeout(timeout, fut).await { Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)), Err(_) => Err(IdeError::new( ErrorKind::Protocol, @@ -317,7 +320,15 @@ impl LspManager { timeout.as_secs() ), )), - } + }; + tracing::info!( + phase, + ready_ms, + request_ms = started.elapsed().as_millis() - ready_ms, + success = result.is_ok(), + "Java launch request" + ); + result } /// `workspace/executeCommand` on the jdtls serving `path`. diff --git a/src/features/debugger/__tests__/console.test.ts b/src/features/debugger/__tests__/console.test.ts index 26ba33023..8a4758900 100644 --- a/src/features/debugger/__tests__/console.test.ts +++ b/src/features/debugger/__tests__/console.test.ts @@ -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"); +}); diff --git a/src/features/debugger/__tests__/javaCancel.test.ts b/src/features/debugger/__tests__/javaCancel.test.ts index 4b8addb55..b4b1282af 100644 --- a/src/features/debugger/__tests__/javaCancel.test.ts +++ b/src/features/debugger/__tests__/javaCancel.test.ts @@ -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([]); +}); diff --git a/src/features/debugger/__tests__/toolWindow.test.tsx b/src/features/debugger/__tests__/toolWindow.test.tsx index efaa52ff3..415ccdea2 100644 --- a/src/features/debugger/__tests__/toolWindow.test.tsx +++ b/src/features/debugger/__tests__/toolWindow.test.tsx @@ -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(); @@ -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(); + 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( diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 3d016a740..78a08fe4e 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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(() => []); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 3ee175f8c..51cc0196c 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -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 { 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 diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 6d5f0afc2..18d3f5202 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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(); + +/** 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, +): Promise { + 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: {} }); diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 3746a54b4..d8bf9ea82 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -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("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} diff --git a/src/features/run/resolveCancellation.test.ts b/src/features/run/resolveCancellation.test.ts index 51702fabf..d023a1b23 100644 --- a/src/features/run/resolveCancellation.test.ts +++ b/src/features/run/resolveCancellation.test.ts @@ -72,6 +72,7 @@ describe("Stop during Java launch resolution", () => { expect(request).toEqual(expect.any(Number)); await stopSession(session.id); expect(ipc.cancelRequest).toHaveBeenCalledExactlyOnceWith(request); + expect(useRun.getState().sessions[0]?.status).toBe("finished"); resolve( response === "cancelled" ? { status: "error", error: { kind: "cancelled", message: "request cancelled" } } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 597ab30ca..092e1e0f4 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -17,14 +17,7 @@ import { type RunId, type TestCaseReport, } from "@/platform/ipc"; -import { - findJavaModule, - formatCompileErrors, - isJvmApplication, - parseJavaArgs, - resolveJavaCommand, - resolveJavaModule, -} from "@/platform/javaLaunch"; +import { findJavaModule, isJvmApplication, parseJavaArgs, prepareJavaLaunch } from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; @@ -79,7 +72,7 @@ export interface RunSession { } interface SessionRuntime { - resolveRequest?: { id: RequestId; cancelled: boolean }; + resolveRequest?: { id: RequestId; controller: AbortController }; console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; @@ -665,7 +658,9 @@ export async function startRun( if (launch.emulateTerminal) rt.terminal = new TerminalOutput(); patchSession(sessionId, { terminal: !!launch.emulateTerminal }); const started = launch.isTest ? withTestReporting(launch) : launch; + const processStarted = performance.now(); const info = await startWithRemedies(started, () => call(commands.runStart(started, channel))); + console.info("Run process started", { name: config.name, ms: performance.now() - processStarted }); if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; if (info.pty && rt.terminalSize) void commands.runResize(info.id, rt.terminalSize.cols, rt.terminalSize.rows); // The process may already have exited (fast commands) before we got its id. @@ -716,20 +711,15 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom return null; } if (!parsed.classpath) return config; - say(`Building and resolving the classpath of ${parsed.mainClass}…\n`); const rt = runtime(sessionId)!; - const request = { id: nextRequestId(), cancelled: false }; + const request = { id: nextRequestId(), controller: new AbortController() }; rt.resolveRequest = request; try { - const module = await resolveJavaModule(config); - if (request.cancelled) return null; - const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, request.id)); - if (request.cancelled) return null; - if (info.build === "withErrors") - say("Build completed with errors; launching with the last compiled classes.\n", "stderr"); - if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr"); - const compileReport = formatCompileErrors(info); - if (compileReport) say(compileReport, "stderr"); + const { info, resolved } = await prepareJavaLaunch(config, { + request: request.id, + signal: request.controller.signal, + report: say, + }); // No installed JDK is new enough for the module: offer to download one. const jdkHint = info.hints.find((h) => downloadJdkActions(h).length); if (jdkHint) @@ -741,14 +731,10 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom body: jdkHint, actions: downloadJdkActions(jdkHint), }); - if (!info.classPaths.length) { - say("The classpath is empty: is the module imported by the Java language server?\n", "stderr"); - return null; - } - return resolveJavaCommand(config, info); + return resolved; } catch (e) { const message = e instanceof Error ? e.message : String(e); - if (request.cancelled || isCancelled(e)) { + if (request.controller.signal.aborted || isCancelled(e)) { if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true }); return null; } @@ -881,8 +867,8 @@ export async function stopSession(sessionId: string): Promise { if (!session) return; const request = runtimes.get(sessionId)?.resolveRequest; if (session.status === "starting" && request) { - request.cancelled = true; - patchSession(sessionId, { stopped: true }); + request.controller.abort(); + patchSession(sessionId, { stopped: true, status: "finished", endedAt: Date.now() }); await commands.cancelRequest(request.id); return; } diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 6dde76525..ee2bf54da 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -4,7 +4,7 @@ * `${classpath}` (or `${testClasspath}`) and the `java` launcher are resolved * through jdtls when the configuration starts. */ -import { call, commands, type JavaLaunchInfo, type RunConfig } from "@/platform/ipc"; +import { call, commands, type JavaLaunchInfo, type RunConfig, type RequestId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; export const CLASSPATH = "${classpath}"; @@ -149,3 +149,33 @@ export async function resolveJavaModule(config: RunConfig): Promise { throw new Error(tx`Cannot determine the module for main class '${parseJavaArgs(config.args).mainClass}'`); return module; } + +/** Shared, cancellable build and classpath preparation for Run and Debug. */ +export interface JavaPreparation { + request: RequestId; + signal: AbortSignal; + report?: (text: string, kind?: "system" | "stderr") => void; +} + +export async function prepareJavaLaunch(config: RunConfig, preparation: JavaPreparation) { + const { signal, report } = preparation; + signal.throwIfAborted(); + const parsed = parseJavaArgs(config.args); + if (!parsed.mainClass) throw new Error(`No main class in '${config.name}'`); + report?.(tx`Building and resolving the classpath of ${parsed.mainClass}…` + "\n"); + const started = performance.now(); + const module = await resolveJavaModule(config); + signal.throwIfAborted(); + const moduleMs = performance.now() - started; + const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation.request)); + signal.throwIfAborted(); + console.info("Java launch preparation", { module, moduleMs, resolveMs: performance.now() - started - moduleMs }); + if (info.build === "withErrors") + report?.(tx("Build completed with errors; launching with the last compiled classes.") + "\n", "stderr"); + if (info.build === "failed") report?.(tx("Build failed; launching with the last compiled classes.") + "\n", "stderr"); + const errors = formatCompileErrors(info); + if (errors) report?.(errors, "stderr"); + if (parsed.classpath && !info.classPaths.length) + throw new Error(tx("The classpath is empty: is the module imported by the Java language server?")); + return { module, info, resolved: resolveJavaCommand(config, info) }; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 55b239873..28269b99b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3317,6 +3317,11 @@ "No changes yet": "还没有改动", "What should the agent do?": "让 agent 做点什么?", "New worktree": "新 worktree", + "Building and resolving the classpath of {name}…": "正在构建并解析 {name} 的类路径…", + "Build completed with errors; launching with the last compiled classes.": "构建完成但存在错误;使用上次编译的类启动。", + "Build failed; launching with the last compiled classes.": "构建失败;使用上次编译的类启动。", + "The classpath is empty: is the module imported by the Java language server?": "类路径为空:Java 语言服务器是否已导入此模块?", + "Launch cancelled.": "启动已取消。", "Starting debugger for {name}…": "正在启动 {name} 的调试器…", "Starting…": "正在启动…", "Waiting for your answer": "等待你的回答",