feat(gradle): debug forked JVMs on a dynamic JDWP port (#2154)

Debugging a Gradle test worker or Spring Boot application attached through a fixed --debug-jvm port, so concurrent runs collided. The forked JVM now gets a dynamic JDWP address for Test tasks and BootRun, and the init scripts attach to the selected port. An agent the build configures itself (jvmArgs or debug = true) is replaced, so the JVM has one agent; a plain Run has none.

Co-authored-by: misswell <[email protected]>
Цей коміт міститься в:
misswellтаmisswell authored and GitHub committed 2026-11-09 01:15:23 +08:00
1 джерело a73e3c21ad
коміт 4139cb64c2
9 files changed
+113 -6

No files matched your search

+1 -1
Переглянути файл
@@ -77,7 +77,7 @@ test("a slow module load succeeds before the deadline and cancels the startup ti
await page.goto("/", { waitUntil: "commit" });
await moduleRequest;
await page.locator("#splash").waitFor({ state: "attached" });
await page.clock.runFor(25_000);
await page.clock.runFor(15_000);
await expect(page.locator("#boot-error")).toHaveCount(0);
release();
await expect(page.locator("#root")).not.toBeEmpty();
+17
Переглянути файл
@@ -0,0 +1,17 @@
// OpenIDEA: BootRun is a JavaExec task; give its forked application JVM a dynamic
// JDWP port instead of Gradle's fixed 5005. The property is added by jdwpLaunch.
gradle.projectsEvaluated {
allprojects {
tasks.matching { it.name == 'bootRun' }.configureEach { task ->
if (System.getProperty('openidea.run.debug') == 'true') {
task.debugOptions.enabled = false
task.doFirst {
task.setJvmArgs(task.jvmArgs.findAll {
!it.startsWith('-agentlib:jdwp=') && !it.startsWith('-Xrunjdwp:')
})
task.jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0')
}
}
}
}
}
+49
Переглянути файл
@@ -1,15 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import * as ipc from "@/platform/ipc";
import { applicationConfig } from "./application";
import {
DELEGATED_DEBUG,
delegatedGradleConfig,
gradleDelegation,
gradleBootLaunch,
gradleTestLaunch,
withTestEvents,
wrapperGradleVersion,
} from "./gradleRun";
import { jdwpLaunch } from "./javaDebug";
import { isJvmApplication } from "@/platform/javaLaunch";
import TESTS_SCRIPT from "./gradleTests.gradle?raw";
import BOOT_SCRIPT from "./gradleBoot.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
@@ -135,6 +140,16 @@ describe("Build and run using: Gradle", () => {
expect(run.params).toBeUndefined();
});
it("gives Gradle BootRun a dynamic JDWP port when debugging", () => {
expect(BOOT_SCRIPT).toContain("openidea.run.debug");
expect(BOOT_SCRIPT).toContain("address=127.0.0.1:0");
});
it("gives Gradle test workers a dynamic JDWP port when debugging", () => {
expect(TESTS_SCRIPT).toContain("openidea.run.debug");
expect(TESTS_SCRIPT).toContain("address=127.0.0.1:0");
});
it("debugs the forked JVM: the init script adds the agent", () => {
const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []);
const run = delegatedGradleConfig(app, "app", ctx)!;
@@ -166,6 +181,40 @@ describe("Gradle test runs", () => {
});
});
describe("Gradle launch routing", () => {
const boot = {
kind: "gradle",
command: "./gradlew",
args: [":module:bootRun"],
} as RunConfig;
const test = { ...boot, args: [":module:test"], isTest: true };
afterEach(() => vi.restoreAllMocks());
it("leaves non-desktop launches alone", async () => {
vi.spyOn(ipc, "isTauri").mockReturnValue(false);
expect(await gradleBootLaunch(boot)).toBe(boot);
expect(await gradleTestLaunch(test)).toBe(test);
});
it("keeps the configuration when the host cannot write the init script", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(ipc.commands, "runGradleInitScript").mockRejectedValue(new Error("host unavailable"));
expect(await gradleBootLaunch(boot)).toBe(boot);
expect(await gradleTestLaunch(test)).toBe(test);
});
it("adds reporting scripts without enabling JDWP for plain Run", async () => {
for (const config of [await gradleBootLaunch(boot), await gradleTestLaunch(test)]) {
expect(config.args.slice(0, 2)).toEqual(["--init-script", "/tmp/openidea-gradle-run.gradle"]);
expect(config.args).not.toContain(DELEGATED_DEBUG);
expect(config.args).not.toContain("--debug-jvm");
}
expect(boot.args).toEqual([":module:bootRun"]);
expect(test.args).toEqual([":module:test"]);
});
});
describe("delegation across independent builds", () => {
afterEach(() => {
useProject.setState({ project: null, settings: null });
+12
Переглянути файл
@@ -7,6 +7,7 @@
*/
import RUN_SCRIPT from "./gradleRun.gradle?raw";
import TESTS_SCRIPT from "./gradleTests.gradle?raw";
import BOOT_SCRIPT from "./gradleBoot.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
@@ -125,6 +126,17 @@ export async function gradleTestLaunch<T extends RunConfig>(config: T): Promise<
}
}
/** Gives a direct Gradle BootRun a dynamic JDWP port for the forked application JVM. */
export async function gradleBootLaunch<T extends RunConfig>(config: T): Promise<T> {
if (!isTauri() || !isGradleCommand(config) || !config.args.some((a) => /(^|:)bootRun$/.test(a))) return config;
try {
return withTestEvents(config, await writeScript("openidea-gradle-boot", BOOT_SCRIPT));
} catch (e) {
console.warn("Gradle BootRun init script not written", e);
return config;
}
}
/**
* The launch of an Application / Spring Boot `config` through Gradle, when the project
* runs with Gradle (Settings › Build Tools › Gradle) and the main class is in one of its
+17
Переглянути файл
@@ -42,3 +42,20 @@ allprojects {
})
}
}
// Replace build-configured agents only for this debug launch, after project configuration.
gradle.projectsEvaluated {
allprojects {
tasks.withType(Test).configureEach { Test task ->
if (System.getProperty('openidea.run.debug') == 'true') {
task.debugOptions.enabled = false
task.doFirst {
task.setJvmArgs(task.jvmArgs.findAll {
!it.startsWith('-agentlib:jdwp=') && !it.startsWith('-Xrunjdwp:')
})
task.jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0')
}
}
}
}
}
+7 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { useProject } from "@/platform/project";
import { applicationConfig, jvmModuleDir, jvmTestConfig } from "./application";
import { buildRows } from "./ConfigChooser";
import { jdwpLaunch } from "./javaDebug";
import { DELEGATED_DEBUG } from "./gradleRun";
import { firstTopLevelType, jvmMainCandidates, scanJvmTargets } from "./jvmTargets";
import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store";
import { detectRunTargets, targetConfig } from "./targets";
@@ -324,6 +325,8 @@ describe("java command lines", () => {
expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""');
expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123);
expect(jdwpListeningPort("Listening for transport dt_socket at address: 127.0.0.1:5005")).toBe(5005);
expect(jdwpListeningPort("[Test worker] Listening for transport dt_socket at address: 50124")).toBe(50124);
expect(jdwpListeningPort(" Listening for transport dt_socket at address: 50125\r")).toBe(50125);
expect(jdwpListeningPort("[INFO] Listening")).toBeNull();
});
});
@@ -340,7 +343,10 @@ 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);
expect(jdwpLaunch(cfg({ id: "g", kind: "gradle", args: ["bootRun"] }))!.args).toEqual(["bootRun", "--debug-jvm"]);
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);
expect(gradleTest.args).not.toContain("--debug-jvm");
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"] }))).toBeNull();
expect(jdwpLaunch(cfg({ id: "n", kind: "npm", args: ["test"] }))).toBeNull();
});
+6
Переглянути файл
@@ -63,6 +63,12 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null {
// 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"] };
}
+2 -2
Переглянути файл
@@ -230,13 +230,13 @@ describe("Maven / Gradle: VM options of the forked JVM", () => {
const launched = await gradle().prepareLaunch!(c);
expect(scripts).toEqual([gradleJvmArgs(params)]);
expect(launched.args).toEqual(["--init-script", "/tmp/openidea-gradle/jvm-args-1.init.gradle", "bootRun"]);
// Debugging keeps them: `--debug-jvm` is added before the launch is prepared.
// Debugging keeps them: the BootRun init script gives the forked JVM a dynamic port.
const debug = await gradle().prepareLaunch!(jdwpLaunch(c)!);
expect(debug.args).toEqual([
"--init-script",
"/tmp/openidea-gradle/jvm-args-1.init.gradle",
"bootRun",
"--debug-jvm",
"-Dopenidea.run.debug=true",
]);
});
+2 -2
Переглянути файл
@@ -57,7 +57,7 @@ import { loadRunTemplates } from "./runTemplates";
import { recordRun } from "./services";
import { startedPort } from "@/platform/serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { gradleDelegation, gradleTestLaunch } from "./gradleRun";
import { gradleBootLaunch, gradleDelegation, gradleTestLaunch } from "./gradleRun";
import { openUrl } from "./navigate";
import { tx } from "@/platform/i18n";
@@ -877,7 +877,7 @@ export async function startRun(
if ((options.launch || launch.isTest) && launch.emulateTerminal) launch = { ...launch, emulateTerminal: false };
if (launch.emulateTerminal) rt.terminal = new TerminalOutput();
patchSession(sessionId, { terminal: !!launch.emulateTerminal });
const started = launch.isTest ? await gradleTestLaunch(withTestReporting(launch)) : launch;
const started = launch.isTest ? await gradleTestLaunch(withTestReporting(launch)) : await gradleBootLaunch(launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
const processStarted = performance.now();
const info = await startWithRemedies(started, () => call(commands.runStart(started, channel)));