fix(lsp): allow long jdtls workspace builds and make Java launch resolution cancellable (#449)

java/buildWorkspace builds the whole imported jdtls workspace, and its 5-minute cap failed launches in large multi-module projects. The budget is now 20 minutes, and the timeout error names it so a real stall can be told apart.

Waiting that long needs a way out, so java_resolve_launch now takes a RequestId and is cancellable locally and over remote. The token reaches the jdtls ready and import waits, the requests (sending $/cancelRequest), and the external build. Stopping a run session during this phase cancels silently and does not launch. Starting a debug session passes a request id but has no stop path yet.

Closes #373

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 23:02:20 +08:00
1 джерело 14c7821cf4
коміт d5fe771eed
8 files changed
+245 -39

No files matched your search

+26 -5
Переглянути файл
@@ -4,10 +4,11 @@ use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::JavaLaunchInfo;
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
/// Builds the workspace (when `build`), then resolves the runtime (or test)
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
@@ -17,17 +18,33 @@ pub async fn java_resolve_launch(
module: PathBuf,
test: bool,
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
let cancel = host.begin_request(request);
let result = resolve_launch(host, window, module, test, build, &cancel).await;
host.end_request(request);
result
}
async fn resolve_launch(
host: &Host,
window: &str,
module: PathBuf,
test: bool,
build: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
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).await?;
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, &CancellationToken::new())
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
@@ -65,6 +82,9 @@ pub async fn java_resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(info)
}
@@ -83,13 +103,14 @@ pub async fn java_build_workspace(
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module).await
project.lsp.java_build(&module, &CancellationToken::new()).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
.await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
+97 -24
Переглянути файл
@@ -8,8 +8,7 @@
use crate::LspManager;
use crate::server::{Running, Server};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeResult, LanguageId};
use ide_jsonrpc::RpcError;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
@@ -20,7 +19,9 @@ use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
/// First start imports the Maven / Gradle model, which can take minutes.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
const BUILD_TIMEOUT: Duration = Duration::from_secs(300);
/// A workspace build of a large multi-module project can take far longer than
/// an editor request; the user can always cancel the launch waiting on it.
const BUILD_TIMEOUT: Duration = Duration::from_secs(20 * 60);
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
/// How long to retry while the project is still being imported.
const IMPORT_WAIT: Duration = Duration::from_secs(90);
@@ -163,10 +164,19 @@ fn missing_bundle(e: &IdeError) -> bool {
m.contains("no delegatecommandhandler") || m.contains("unsupported command") || m.contains("not supported")
}
/// Cancel local waits without interrupting a shared server's startup or import.
async fn cancellable_wait<T>(cancel: &CancellationToken, wait: impl std::future::Future<Output = T>) -> IdeResult<T> {
tokio::select! {
biased;
_ = cancel.cancelled() => Err(IdeError::cancelled()),
result = wait => Ok(result),
}
}
impl LspManager {
/// The jdtls instance serving `path` (a module directory or source file),
/// started if needed and waited for.
async fn jdtls(&self, path: &Path) -> IdeResult<(Arc<Server>, Running)> {
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
let java = LanguageId::new("java");
let server = self
.servers_for(&java, path)
@@ -174,7 +184,7 @@ impl LspManager {
.find(|s| s.name == SERVER)
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
self.ensure_started(&server);
match server.wait_ready(READY_TIMEOUT).await {
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
Some(run) => Ok((server, run)),
None => {
let status = server.status();
@@ -191,34 +201,64 @@ impl LspManager {
}
}
async fn jdtls_request(&self, path: &Path, method: &str, params: Value, timeout: Duration) -> IdeResult<Value> {
let (server, run) = self.jdtls(path).await?;
let fut = run.peer.request::<Value, Value>(method, params, None);
async fn jdtls_request(
&self,
path: &Path,
method: &str,
params: Value,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<Value> {
let (server, run) = self.jdtls(path, cancel).await?;
let fut = run.peer.request::<Value, Value>(method, params, Some(cancel.clone()));
match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
Err(_) => Err(crate::rpc_err(&server.name, method, RpcError::Timeout)),
Err(_) => Err(IdeError::new(
ErrorKind::Protocol,
format!(
"{SERVER}: {method} timed out after {} s; retry, or check that jdtls is not stuck",
timeout.as_secs()
),
)),
}
}
/// `workspace/executeCommand` on the jdtls serving `path`.
pub async fn jdtls_command(&self, path: &Path, command: &str, arguments: Vec<Value>) -> IdeResult<Value> {
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
}
async fn jdtls_command_cancellable(
&self,
path: &Path,
command: &str,
arguments: Vec<Value>,
cancel: &CancellationToken,
) -> IdeResult<Value> {
self.jdtls_request(
path,
"workspace/executeCommand",
json!({ "command": command, "arguments": arguments }),
COMMAND_TIMEOUT,
cancel,
)
.await
}
/// Like [`jdtls_command`](Self::jdtls_command), retried while the project is still importing.
async fn jdtls_project_command(&self, path: &Path, command: &str, arguments: Vec<Value>) -> IdeResult<Value> {
async fn jdtls_project_command(
&self,
path: &Path,
command: &str,
arguments: Vec<Value>,
cancel: &CancellationToken,
) -> IdeResult<Value> {
let deadline = Instant::now() + IMPORT_WAIT;
loop {
match self.jdtls_command(path, command, arguments.clone()).await {
match self.jdtls_command_cancellable(path, command, arguments.clone(), cancel).await {
Err(e) if not_imported(&e) && Instant::now() < deadline => {
tracing::debug!("{command}: {e}; waiting for the project import");
tokio::time::sleep(Duration::from_millis(1500)).await;
cancellable_wait(cancel, tokio::time::sleep(Duration::from_millis(1500))).await?;
}
r => return r,
}
@@ -245,43 +285,62 @@ impl LspManager {
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
/// class files are up to date before a launch.
pub async fn java_build(&self, path: &Path) -> IdeResult<JavaBuildStatus> {
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT).await?;
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
}
/// Runtime (`test == false`) or test classpath of the project containing `path`.
pub async fn java_classpath(&self, path: &Path, test: bool) -> IdeResult<JavaClasspath> {
pub async fn java_classpath(
&self,
path: &Path,
test: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaClasspath> {
let uri = crate::uri::path_to_uri_string(path);
let options = json!({ "scope": if test { "test" } else { "runtime" } }).to_string();
let v =
self.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)]).await?;
let v = self
.jdtls_project_command(path, "java.project.getClasspaths", vec![json!(uri), json!(options)], cancel)
.await?;
Ok(parse_classpaths(&v))
}
/// The JDK home jdtls uses for the project containing `path`.
pub async fn java_vm_location(&self, path: &Path) -> IdeResult<Option<PathBuf>> {
pub async fn java_vm_location(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<Option<PathBuf>> {
let uri = crate::uri::path_to_uri_string(path);
let v = self
.jdtls_project_command(
path,
"java.project.getSettings",
vec![json!(uri), json!(["org.eclipse.jdt.ls.core.vm.location"])],
cancel,
)
.await?;
Ok(parse_vm_location(&v))
}
/// Builds (when asked), then resolves the classpath and JDK of the module at `module`.
pub async fn java_launch(&self, module: &Path, test: bool, build: bool) -> IdeResult<JavaLaunchInfo> {
let build = if build { Some(self.java_build(module).await?) } else { None };
pub async fn java_launch(
&self,
module: &Path,
test: bool,
build: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let build = if build { Some(self.java_build(module, cancel).await?) } else { None };
if build == Some(JavaBuildStatus::Cancelled) {
return Err(IdeError::cancelled());
}
let (errors, error_count) = match build {
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => self.java_compile_errors(module).await,
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
cancellable_wait(cancel, self.java_compile_errors(module)).await?
}
_ => (Vec::new(), 0),
};
let cp = self.java_classpath(module, test).await?;
let java_home = match self.java_vm_location(module).await {
let cp = self.java_classpath(module, test, cancel).await?;
let java_home = match self.java_vm_location(module, cancel).await {
Ok(home) => home,
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => {
tracing::debug!("java.project.getSettings: {e}");
None
@@ -303,7 +362,7 @@ impl LspManager {
/// Compile errors jdtls reports for its workspace (all modules it imported).
async fn java_compile_errors(&self, module: &Path) -> (Vec<JavaCompileError>, u32) {
let Ok((server, _)) = self.jdtls(module).await else { return (Vec::new(), 0) };
let Ok((server, _)) = self.jdtls(module, &CancellationToken::new()).await else { return (Vec::new(), 0) };
let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS);
let found = collect();
if found.1 > 0 {
@@ -341,6 +400,20 @@ impl LspManager {
mod tests {
use super::*;
#[tokio::test]
async fn cancellation_ends_a_pending_wait() {
let cancel = CancellationToken::new();
let waiting = cancellable_wait(&cancel, std::future::pending::<()>());
let cancelling = async {
tokio::task::yield_now().await;
cancel.cancel();
};
let (result, ()) = tokio::time::timeout(Duration::from_secs(1), async { tokio::join!(waiting, cancelling) })
.await
.expect("cancelled wait must finish");
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
}
#[test]
fn launch_errors_are_sorted_and_capped() {
let diag = |line: u32, severity, message: &str| lsp_types::Diagnostic {
+4 -2
Переглянути файл
@@ -1,13 +1,14 @@
//! IPC commands of `ide_host::ops::java` (the shared implementation).
use crate::state::AppState;
use ide_core::IdeResult;
use ide_core::{IdeResult, RequestId};
#[allow(unused_imports)]
pub use ide_host::ops::java::*;
use ide_lsp::jdtls::JavaLaunchInfo;
use std::path::PathBuf;
use tauri::{State, Window};
/// Cancellable with `cancel_request(request)`.
/// Builds the workspace (when `build`), then resolves the runtime (or test)
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
@@ -19,8 +20,9 @@ pub async fn java_resolve_launch(
module: PathBuf,
test: bool,
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build).await
ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build, request).await
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
+2 -1
Переглянути файл
@@ -606,11 +606,12 @@ export const commands = {
*/
buildRun: (kind: BuildKind, file: string | null, request: RequestId, clear: string[], onEvent: Channel<BuildEvent>) => typedError<BuildResult, IdeError>(__TAURI_INVOKE("build_run", { kind, file, request, clear, onEvent })),
/**
* Cancellable with `cancel_request(request)`.
* Builds the workspace (when `build`), then resolves the runtime (or test)
* classpath and the JDK of the module at `module` (absolute, or relative to the project root).
* A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
*/
javaResolveLaunch: (module: string, test: boolean, build: boolean) => typedError<JavaLaunchInfo, IdeError>(__TAURI_INVOKE("java_resolve_launch", { module, test, build })),
javaResolveLaunch: (module: string, test: boolean, build: boolean, request: RequestId) => typedError<JavaLaunchInfo, IdeError>(__TAURI_INVOKE("java_resolve_launch", { module, test, build, request })),
/** Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). */
javaDebugServer: (module: string) => typedError<number, IdeError>(__TAURI_INVOKE("java_debug_server", { module })),
/** Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap). */
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ describe("Java debugging (java-debug through jdtls)", () => {
expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false);
const l = await javaLaunchConfig(app, "/p");
expect(invoked).toEqual([
["javaResolveLaunch", ["api", false, true]],
["javaResolveLaunch", ["api", false, true, expect.any(Number)]],
["javaDebugServer", ["api"]],
]);
expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } });
+2 -2
Переглянути файл
@@ -5,7 +5,7 @@
* JVMs started by Maven / Gradle with a JDWP agent are attached to.
*/
import { executeAction } from "@/platform/actions";
import { call, commands, IpcError, type LaunchConfig, type RunConfig } from "@/platform/ipc";
import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc";
import {
formatCompileErrors,
isJvmApplication,
@@ -76,7 +76,7 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise<Lau
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));
const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, nextRequestId()));
if (info.errorCount > 0)
notifications().notify({
group: "run",
+82
Переглянути файл
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc";
const ipc = vi.hoisted(() => ({
javaResolveLaunch: vi.fn(),
cancelRequest: vi.fn(async () => undefined),
runStart: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: ((m: unknown) => void) | null = null;
},
}));
import { provideNotificationService } from "@/workbench/contributions";
import { resetRuns, runtime, startRun, stopSession, useRun } from "./store";
const config: RunConfig = {
id: "application:Main",
name: "Main",
kind: "application",
command: "java",
args: ["-cp", "${classpath}", "Main"],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
};
const info: JavaLaunchInfo = {
build: "succeeded",
projectRoot: "/p",
classPaths: ["/p/target/classes"],
modulePaths: [],
javaHome: null,
javaExec: "java",
shortenCommandLine: null,
errors: [],
errorCount: 0,
hints: [],
};
const notify = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
provideNotificationService({ notify });
});
afterEach(() => resetRuns());
describe("Stop during Java launch resolution", () => {
it.each(["cancelled", "success"])("cancels the request and ignores a late %s response", async (response) => {
type Result = Awaited<ReturnType<typeof import("@/platform/ipc").commands.javaResolveLaunch>>;
let resolve!: (result: Result) => void;
const pending = new Promise<Result>((done) => {
resolve = done;
});
ipc.javaResolveLaunch.mockReturnValue(pending);
const launch = startRun(config);
await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce());
const session = useRun.getState().sessions[0]!;
expect(session.status).toBe("starting");
const request = ipc.javaResolveLaunch.mock.calls[0]![3];
expect(request).toEqual(expect.any(Number));
await stopSession(session.id);
expect(ipc.cancelRequest).toHaveBeenCalledExactlyOnceWith(request);
resolve(
response === "cancelled"
? { status: "error", error: { kind: "cancelled", message: "request cancelled" } }
: { status: "ok", data: info },
);
await launch;
expect(ipc.runStart).not.toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
expect(runtime(session.id)!.console.lines.some((line) => line.kind === "stderr")).toBe(false);
expect(useRun.getState().sessions[0]).toMatchObject({ stopped: true, status: "finished", runId: null });
expect(runtime(session.id)!.resolveRequest).toBeUndefined();
});
});
+31 -4
Переглянути файл
@@ -9,6 +9,9 @@ import {
call,
commands,
isTauri,
isCancelled,
nextRequestId,
type RequestId,
type OutputChunk,
type RunConfig,
type RunId,
@@ -68,6 +71,7 @@ export interface RunSession {
}
interface SessionRuntime {
resolveRequest?: { id: RequestId; cancelled: boolean };
console: ConsoleModel;
tests: TestResults | null;
parser: TestOutputParser | null;
@@ -620,7 +624,10 @@ export async function startRun(
const resolved = await resolveJvmApplication(sessionId, launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (!resolved) {
patchSession(sessionId, { status: "failed", endedAt: Date.now() });
patchSession(sessionId, {
status: getSession(sessionId)?.stopped ? "finished" : "failed",
endedAt: Date.now(),
});
return getSession(sessionId) ?? null;
}
launch = resolved;
@@ -690,8 +697,14 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
}
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 };
rt.resolveRequest = request;
try {
const info = await call(commands.javaResolveLaunch(config.cwd ?? "", parsed.classpath === "test", true));
const info = await call(
commands.javaResolveLaunch(config.cwd ?? "", 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");
@@ -715,6 +728,10 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
return resolveJavaCommand(config, info, pathSeparator());
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (request.cancelled || isCancelled(e)) {
if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true });
return null;
}
say(`Cannot resolve the classpath: ${message}\n`, "stderr");
// jdtls can't start without a newer JDK: "Download JDK 25".
const actions = downloadJdkActions(message);
@@ -726,6 +743,8 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
actions,
});
return null;
} finally {
if (rt.resolveRequest === request) delete rt.resolveRequest;
}
}
@@ -835,7 +854,15 @@ export function onRunExited(runId: RunId, code: number | null) {
export async function stopSession(sessionId: string): Promise<void> {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
if (!session) return;
const request = runtimes.get(sessionId)?.resolveRequest;
if (session.status === "starting" && request) {
request.cancelled = true;
patchSession(sessionId, { stopped: true });
await commands.cancelRequest(request.id);
return;
}
if (session.runId === null || session.status !== "running") return;
patchSession(sessionId, { stopped: true });
try {
await call(commands.runStop(session.runId));
@@ -995,7 +1022,7 @@ export async function closeSessionTab(sessionId: string): Promise<boolean> {
export function closeSession(sessionId: string) {
const session = getSession(sessionId);
if (!session) return;
if (session.status === "running") void stopSession(sessionId);
if (session.status === "running" || session.status === "starting") void stopSession(sessionId);
const rt = runtimes.get(sessionId);
if (rt) cancelFlush(rt);
runtimes.delete(sessionId);