fix(run): pin the JDWP port for Maven test forks (#2215) (#2268)

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:<port>, 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.
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-09 12:50:40 +08:00
1 джерело 92b527ab0e
коміт 0ccc1e687d
6 files changed
+49 -6

No files matched your search

+9
Переглянути файл
@@ -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:<port>`) — 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<u16> {
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
});
+7
Переглянути файл
@@ -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<u16> {
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]
+1
Переглянути файл
@@ -474,6 +474,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+2
Переглянути файл
@@ -1035,6 +1035,8 @@ export const commands = {
debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError<DataBreakpointInfo, IdeError>(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })),
/** Attach to Process: the user's processes a debugger can attach to. */
debugAttachableProcesses: () => typedError<AttachableProcess[], IdeError>(__TAURI_INVOKE("debug_attachable_processes")),
/** A loopback port that is free right now, for a debug agent the launch line starts. */
debugFreePort: () => typedError<number, IdeError>(__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.
+8
Переглянути файл
@@ -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);
+22 -6
Переглянути файл
@@ -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<boolean> {
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<number>();
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;
}