дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
a2405c083a
коміт
7610fc3f80
13 files changed
+374
-154
No files matched your search
@@ -34,58 +34,72 @@ async fn resolve_launch(
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
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<String> = 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<String> = 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).
|
||||
|
||||
@@ -306,9 +306,12 @@ impl LspManager {
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
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::<Value, Value>(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`.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => []);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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) };
|
||||
}
|
||||
@@ -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": "等待你的回答",
|
||||
|
||||
Reference in new issue
Заблокувати користувача