From 0ccc1e687d753a97e27795d8af3e3c8a7e3618df Mon Sep 17 00:00:00 2001 From: arookieofc Date: Mon, 9 Nov 2026 12:50:40 +0800 Subject: [PATCH] fix(run): pin the JDWP port for Maven test forks (#2215) (#2268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Surefire/Failsafe fork started with suspend=y never reaches the console with its "Listening for transport" line: surefire buffers the fork's output until the tests run, and the tests wait for the debugger. The IDE found no port, never attached, and Stop left an orphan debug session. Pick a free port up front (new debug_free_port IPC), pass it to the -Dmaven.surefire.debug/-Dmaven.failsafe.debug agent as address=127.0.0.1:, and attach right away — java-debug retries the connect while the fork starts, so no output line is needed. A duplicated line that does arrive later is deduplicated by port. --- crates/ide-host/src/ops/debug.rs | 9 +++++++++ src-tauri/src/commands/debug.rs | 7 +++++++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 2 ++ src/features/run/java.test.ts | 8 ++++++++ src/features/run/javaDebug.ts | 28 ++++++++++++++++++++++------ 6 files changed, 49 insertions(+), 6 deletions(-) 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; }