Файли
openidea/src/features/run/javaDebug.ts
T
arookieofc 0ccc1e687d 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.
2026-11-09 12:50:40 +08:00

133 рядки
7.0 KiB
TypeScript

/**
* Debugging Maven / Gradle run configurations (IDEA debugs those too): the
* build tool starts the JVM with a JDWP agent that waits for a debugger
* (`spring-boot:run` → `-Dspring-boot.run.jvmArguments`, Surefire →
* `-Dmaven.surefire.debug`, Gradle → `--debug-jvm`); when the agent reports
* its port, the Java debugger attaches. Output and the test tree stay in the Run tab.
* Maven goals that run the application inside Maven's JVM (`tomcat7:run`, `jetty:run`,
* `exec:java`) get the agent through `MAVEN_OPTS`; any other configuration set to the Java
* debugger (a script such as `catalina.sh run`) through `JAVA_TOOL_OPTIONS`, which every JVM reads.
*/
import { executeAction } from "@/platform/actions";
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";
import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun";
const isMavenTestGoal = (a: string) =>
/^(test|verify|integration-test|surefire:test|failsafe:integration-test)$/.test(a);
const isGradleJvmTask = (a: string) => /(^|:)(test|bootRun|run|integrationTest|check)$/.test(a);
/** A plugin goal that runs the application in Maven's own JVM (`tomcat7:run`, `jetty:run-war`, `exec:java`). */
const isMavenInProcessGoal = (a: string) => /^[\w.-]+(:[\w.-]+)*:(run|run-war|run-exploded|java)$/.test(a);
const JVM_ADAPTERS = ["java-debug", "kotlin-debug-adapter"];
function withOption(env: Record<string, string>, key: string, value: string): Record<string, string> {
const current = env[key]?.trim();
return { ...env, [key]: current ? `${current} ${value}` : value };
}
function withProperty(args: readonly string[], key: string, value: string): string[] {
const prefix = `-D${key}=`;
const i = args.findIndex((a) => a.startsWith(prefix));
if (i < 0) return [...args, prefix + value];
const current = args[i]!.slice(prefix.length).trim();
const out = [...args];
out[i] = prefix + (current ? `${current} ${value}` : value);
return out;
}
/** 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;
// A single source file (`java Hi.java`): the agent goes before the file. Compiled first
// (`javac … && java -cp …`): through `JDK_JAVA_OPTIONS`, which only the `java` launcher reads.
if (config.kind === "java-file")
return args.length
? { ...config, args: [JDWP_AGENT, ...args] }
: { ...config, env: withOption(config.env, "JDK_JAVA_OPTIONS", JDWP_AGENT) };
if (config.kind === "maven" || /(^|\/)mvnw?(\.cmd)?$/.test(config.command)) {
if (args.some((a) => a === "spring-boot:run" || /:spring-boot-maven-plugin:[^:]*:run$/.test(a)))
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));
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))
return { ...config, env: withOption(config.env, "MAVEN_OPTS", JDWP_AGENT) };
return null;
}
if (config.kind === "gradle" || /(^|\/)gradlew?(\.bat)?$/.test(config.command)) {
// A delegated Application: its init script adds the agent (port 0) to the JavaExec JVM.
if (args.some((a) => a.startsWith(DELEGATED_MAIN)))
return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] };
// Gradle Test forks test workers: the init script gives them a dynamic JDWP port.
if (config.isTest || args.some((a) => /(^|:)(test|integrationTest)$/.test(a)))
return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] };
// BootRun's init script gives the forked application JVM a dynamic port.
if (args.some((a) => /(^|:)bootRun$/.test(a)))
return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] };
if (!args.some(isGradleJvmTask)) return null;
return args.includes("--debug-jvm") ? config : { ...config, args: [...args, "--debug-jvm"] };
}
// Application / Spring Boot configurations are launched by java-debug itself.
if (JVM_ADAPTERS.includes(config.debugAdapter ?? "") && !isJvmApplication(config))
return { ...config, env: withOption(config.env, "JAVA_TOOL_OPTIONS", JDWP_AGENT) };
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 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,
// The application's module (a delegated run's working directory is the Gradle build).
module: config.module ?? config.cwd ?? "",
hostName: "127.0.0.1",
port,
process: runId,
runConfig: config,
show,
});
};
const session = await startRun(config, undefined, undefined, {
launch,
show,
onLine: (line) => {
const port = jdwpListeningPort(line);
if (port === null) return;
if (sessionId) attach(port);
else waiting.push(port);
},
});
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;
}