diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 0b61b0daa..a0bd5889b 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -351,6 +351,14 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul .map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))? } +/// A loopback port that is free right now, for a debug agent the launch line starts +/// (`address=127.0.0.1:`) — used when its "Listening for transport" line never reaches +/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents). +pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult { + host.require_trust(window)?; + ide_dap::adapter::free_port().map_err(ide_core::IdeError::from) +} + /// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote /// JVM to connect to `host:port`; returns the loopback port java-debug attaches to. pub async fn debug_listen_for_jvm( @@ -492,6 +500,7 @@ pub(crate) fn register(d: &mut Dispatcher) { .await }); d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await }); + d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await }); d.add("debug_listen_for_jvm", |c| async move { debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await }); diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 4e7080c98..c99fb0656 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -289,6 +289,13 @@ pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult ide_host::ops::debug::debug_attachable_processes(&state.host, "").await } +/// A loopback port that is free right now, for a debug agent the launch line starts. +#[tauri::command] +#[specta::specta] +pub async fn debug_free_port(window: Window, state: State<'_, AppState>) -> IdeResult { + ide_host::ops::debug::debug_free_port(&state.host, window.label()).await +} + /// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote /// JVM to connect to `host:port`; returns the loopback port java-debug attaches to. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 468567274..4693f9b5a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -474,6 +474,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_set_data_breakpoints, debug::debug_data_breakpoint_info, debug::debug_attachable_processes, + debug::debug_free_port, debug::debug_listen_for_jvm, debug::debug_redefine_classes, coverage::coverage_prepare, diff --git a/src/bindings.ts b/src/bindings.ts index f30f8dac6..a0cab15bc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1035,6 +1035,8 @@ export const commands = { debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })), /** Attach to Process: the user's processes a debugger can attach to. */ debugAttachableProcesses: () => typedError(__TAURI_INVOKE("debug_attachable_processes")), + /** A loopback port that is free right now, for a debug agent the launch line starts. */ + debugFreePort: () => typedError(__TAURI_INVOKE("debug_free_port")), /** * Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote * JVM to connect to `host:port`; returns the loopback port java-debug attaches to. diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index bf6864059..3a71f9443 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -343,6 +343,14 @@ describe("debugging build tool configurations", () => { expect(merged.args[1]).toMatch(/^-Dspring-boot\.run\.jvmArguments=-Xmx1g -agentlib:jdwp=/); const test = jdwpLaunch(cfg({ id: "t", kind: "maven", args: ["test", "-Dtest=A#b"], isTest: true }))!; expect(test.args.some((a) => a.startsWith("-Dmaven.surefire.debug=-agentlib:jdwp="))).toBe(true); + // A fork's "Listening for transport" line stays buffered until tests run — which suspend=y + // prevents — so a Maven test's port is pinned and attached without the line. + const pinned = jdwpLaunch(cfg({ id: "t2", kind: "maven", args: ["test"], isTest: true }), 50123)!; + expect(pinned.args.some((a) => a.includes("address=127.0.0.1:50123"))).toBe(true); + const pinnedFailsafe = jdwpLaunch(cfg({ id: "t3", kind: "maven", args: ["verify"], isTest: true }), 50124)!; + expect( + pinnedFailsafe.args.some((a) => a.startsWith("-Dmaven.failsafe.debug=") && a.includes("address=127.0.0.1:50124")), + ).toBe(true); expect(jdwpLaunch(cfg({ id: "g", kind: "gradle", args: ["bootRun"] }))!.args).toContain(DELEGATED_DEBUG); const gradleTest = jdwpLaunch(cfg({ id: "gt", kind: "gradle", args: ["test"], isTest: true }))!; expect(gradleTest.args).toContain(DELEGATED_DEBUG); diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index fa8a1ed3f..bc84b0b41 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -9,7 +9,7 @@ * debugger (a script such as `catalina.sh run`) through `JAVA_TOOL_OPTIONS`, which every JVM reads. */ import { executeAction } from "@/platform/actions"; -import type { RunConfig } from "@/platform/ipc"; +import { call, commands, type RunConfig } from "@/platform/ipc"; import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch"; import { applyBuildPolicyToCommand } from "./buildCommand"; import { startRun, useRun } from "./store"; @@ -37,8 +37,11 @@ function withProperty(args: readonly string[], key: string, value: string): stri return out; } -/** The configuration with a waiting JDWP agent, or null when it starts no debuggable JVM. */ -export function jdwpLaunch(config: RunConfig): RunConfig | null { +/** The configuration with a waiting JDWP agent, or null when it starts no debuggable JVM. + * `jdwpPort`: pin a Surefire / Failsafe fork to this port — its "Listening for transport" + * line stays buffered until the tests run, which `suspend=y` prevents, so the port must be + * known without it. */ +export function jdwpLaunch(config: RunConfig, jdwpPort?: number): RunConfig | null { config = applyBuildPolicyToCommand(config); if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } }; const args = config.args; @@ -53,8 +56,9 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { return { ...config, args: withProperty(args, "spring-boot.run.jvmArguments", JDWP_AGENT) }; if (config.isTest || args.some(isMavenTestGoal)) { const failsafe = args.some((a) => /integration-test|verify|failsafe/.test(a)); - let out = withProperty(args, "maven.surefire.debug", JDWP_AGENT); - if (failsafe) out = withProperty(out, "maven.failsafe.debug", JDWP_AGENT); + const agent = jdwpPort ? JDWP_AGENT.replace("address=127.0.0.1:0", `address=127.0.0.1:${jdwpPort}`) : JDWP_AGENT; + let out = withProperty(args, "maven.surefire.debug", agent); + if (failsafe) out = withProperty(out, "maven.failsafe.debug", agent); return { ...config, args: out }; } if (JVM_ADAPTERS.includes(config.debugAdapter ?? "") || args.some(isMavenInProcessGoal)) @@ -80,13 +84,23 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { return null; } +/** A Maven test launch: its Surefire / Failsafe fork holds the "Listening" line, so pin its JDWP port. */ +function needsPinnedPort(config: RunConfig): boolean { + if (config.kind !== "maven" && !/(^|\/)mvnw?(\.cmd)?$/.test(config.command)) return false; + return config.isTest || config.args.some(isMavenTestGoal); +} + /** Runs `config` with a JDWP agent and attaches the Java debugger to every JVM it starts. */ export async function debugWithAgent(config: RunConfig, show?: boolean): Promise { - const launch = jdwpLaunch(config); + const pinned = needsPinnedPort(config) ? await call(commands.debugFreePort()).catch(() => null) : null; + const launch = jdwpLaunch(config, pinned ?? undefined); if (!launch) return false; let sessionId: string | null = null; const waiting: number[] = []; + const attached = new Set(); const attach = (port: number) => { + if (attached.has(port)) return; + attached.add(port); const runId = useRun.getState().sessions.find((s) => s.id === sessionId)?.runId ?? null; void executeAction("debug.attach", { name: config.name, @@ -111,6 +125,8 @@ export async function debugWithAgent(config: RunConfig, show?: boolean): Promise }); if (!session) return true; sessionId = session.id; + // The pinned port needs no output line: java-debug retries the attach while the fork starts. + if (pinned !== null) attach(pinned); waiting.splice(0).forEach(attach); return true; }