From 4139cb64c29b5675b317ab5900946180273dc802 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 9 Nov 2026 01:15:23 +0800 Subject: [PATCH] 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 <28730815+misswell@users.noreply.github.com> --- e2e/boot.spec.ts | 2 +- src/features/run/gradleBoot.gradle | 17 ++++++++++ src/features/run/gradleRun.test.ts | 49 +++++++++++++++++++++++++++++ src/features/run/gradleRun.ts | 12 +++++++ src/features/run/gradleTests.gradle | 17 ++++++++++ src/features/run/java.test.ts | 8 ++++- src/features/run/javaDebug.ts | 6 ++++ src/features/run/jvmTypes.test.ts | 4 +-- src/features/run/store.ts | 4 +-- 9 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 src/features/run/gradleBoot.gradle diff --git a/e2e/boot.spec.ts b/e2e/boot.spec.ts index b550e328f..4805a3308 100644 --- a/e2e/boot.spec.ts +++ b/e2e/boot.spec.ts @@ -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(); diff --git a/src/features/run/gradleBoot.gradle b/src/features/run/gradleBoot.gradle new file mode 100644 index 000000000..b324b5f62 --- /dev/null +++ b/src/features/run/gradleBoot.gradle @@ -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') + } + } + } + } +} diff --git a/src/features/run/gradleRun.test.ts b/src/features/run/gradleRun.test.ts index e96112c0c..c085ffcdb 100644 --- a/src/features/run/gradleRun.test.ts +++ b/src/features/run/gradleRun.test.ts @@ -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 }); diff --git a/src/features/run/gradleRun.ts b/src/features/run/gradleRun.ts index e643aefb7..ce906c06c 100644 --- a/src/features/run/gradleRun.ts +++ b/src/features/run/gradleRun.ts @@ -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(config: T): Promise< } } +/** Gives a direct Gradle BootRun a dynamic JDWP port for the forked application JVM. */ +export async function gradleBootLaunch(config: T): Promise { + 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 diff --git a/src/features/run/gradleTests.gradle b/src/features/run/gradleTests.gradle index 76edcff6f..0ab21c18a 100644 --- a/src/features/run/gradleTests.gradle +++ b/src/features/run/gradleTests.gradle @@ -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') + } + } + } + } +} diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 5c74648ba..83ca8226f 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -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(); }); diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index bb09353cf..2eb636ad3 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -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"] }; } diff --git a/src/features/run/jvmTypes.test.ts b/src/features/run/jvmTypes.test.ts index a4aa91760..f8a0cc143 100644 --- a/src/features/run/jvmTypes.test.ts +++ b/src/features/run/jvmTypes.test.ts @@ -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", ]); }); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 9df27bead..47f366586 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -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)));