removedApps = params.getArguments().stream()
+ .map(o -> o instanceof JsonElement e ? (e.isJsonNull() ? null : e.getAsString()) : (String) o)
+ .filter(Objects::nonNull)
+ .map(localApps::remove)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
if (!removedApps.isEmpty()) {
bean.updateApps(localApps.values().toArray(new RemoteBootAppData[localApps.size()]));
}
diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/LocalJvmAttach.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/LocalJvmAttach.java
new file mode 100644
index 0000000000..ee2dc0625e
--- /dev/null
+++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/LocalJvmAttach.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Broadcom, Inc.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * Broadcom, Inc. - initial API and implementation
+ *******************************************************************************/
+package org.springframework.ide.vscode.boot.java.livehover.v2;
+
+import java.util.List;
+
+import com.sun.tools.attach.VirtualMachine;
+import com.sun.tools.attach.VirtualMachineDescriptor;
+
+/**
+ * Attaches to a local JVM process by its OS PID and starts (or reuses) its local JMX
+ * management agent on-demand, returning the JMX service URL the agent bound to.
+ *
+ * This avoids picking a JMX port before the target JVM exists: the port is only known once
+ * the target JVM has already bound it itself.
+ *
+ * @author Alex Boyko
+ */
+class LocalJvmAttach {
+
+ private static final String LOCAL_CONNECTOR_ADDRESS = "com.sun.management.jmxremote.localConnectorAddress";
+
+ private LocalJvmAttach() {
+ }
+
+ static String startLocalManagementAgent(String pid) throws Exception {
+ List vmds = VirtualMachine.list();
+ VirtualMachineDescriptor vmd = vmds.stream().filter(d -> d.id().equals(pid)).findFirst().orElse(null);
+ if (vmd == null) {
+ throw new IllegalStateException("No local JVM found for pid " + pid);
+ }
+ return startLocalManagementAgent(vmd);
+ }
+
+ static String startLocalManagementAgent(VirtualMachineDescriptor vmd) throws Exception {
+ VirtualMachine vm = VirtualMachine.attach(vmd);
+ try {
+ String jmxAddress = null;
+ try {
+ jmxAddress = vm.getAgentProperties().getProperty(LOCAL_CONNECTOR_ADDRESS);
+ } catch (Exception e) {
+ //ignore
+ }
+ if (jmxAddress == null) {
+ jmxAddress = vm.startLocalManagementAgent();
+ }
+ if (jmxAddress == null) {
+ throw new IllegalStateException("Could not start local management agent for pid " + vmd.id());
+ }
+ return jmxAddress;
+ } finally {
+ vm.detach();
+ }
+ }
+
+}
diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorLocal.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorLocal.java
index 0a4843859f..a221642a26 100644
--- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorLocal.java
+++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorLocal.java
@@ -40,9 +40,7 @@
public class SpringProcessConnectorLocal {
private static final Logger log = LoggerFactory.getLogger(SpringProcessConnectorLocal.class);
-
- private static final String LOCAL_CONNECTOR_ADDRESS = "com.sun.management.jmxremote.localConnectorAddress";
-
+
private final Map projects;
private final Set processes;
@@ -193,54 +191,23 @@ private void updateStatus(SpringProcessDescriptor[] processes) {
}
public CompletableFuture connectProcess(SpringProcessDescriptor descriptor) {
- VirtualMachine vm = null;
VirtualMachineDescriptor vmDescriptor = descriptor.getVm();
-
+
try {
- String jmxAddress = null;
- vm = VirtualMachine.attach(vmDescriptor);
-
- try {
- jmxAddress = vm.getAgentProperties().getProperty(LOCAL_CONNECTOR_ADDRESS);
- } catch (Exception e) {
- //ignore
- }
-
- if (jmxAddress == null) {
- try {
- jmxAddress = vm.startLocalManagementAgent();
- } catch (Exception e) {
- log.error("Error starting local management agent", e);
- return CompletableFuture.failedFuture(e);
- }
- }
-
- if (jmxAddress != null) {
- String processID = getProcessID(vmDescriptor);
- String processName = getProcessName(vmDescriptor);
- String urlScheme = "http";
-
- SpringProcessConnectorOverJMX connector = new SpringProcessConnectorOverJMX(ProcessType.LOCAL,
- descriptor.getProcessKey(), jmxAddress, urlScheme, processID, processName, descriptor.getProjectName(), null, null);
+ String jmxAddress = LocalJvmAttach.startLocalManagementAgent(vmDescriptor);
- return this.processConnectorService.connectProcess(descriptor.getProcessKey(), connector);
- }
- return CompletableFuture.failedFuture(new Exception("No JMX URL available!"));
+ String processID = getProcessID(vmDescriptor);
+ String processName = getProcessName(vmDescriptor);
+
+ SpringProcessConnectorOverJMX connector = new SpringProcessConnectorOverJMX(ProcessType.LOCAL,
+ descriptor.getProcessKey(), jmxAddress, "http", processID, processName, descriptor.getProjectName(), null, null);
+
+ return this.processConnectorService.connectProcess(descriptor.getProcessKey(), connector);
}
catch (Exception e) {
log.error("exception while connecting to jvm process", e);
return CompletableFuture.failedFuture(e);
}
- finally {
- if (vm != null) {
- try {
- vm.detach();
- }
- catch (Exception e) {
- log.error("error detaching from vm: " + vmDescriptor.id(), e);
- }
- }
- }
}
public boolean isKnownProcessKey(String processKey) {
diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorRemote.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorRemote.java
index 3559f2d105..ec6f240a38 100644
--- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorRemote.java
+++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/livehover/v2/SpringProcessConnectorRemote.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2019, 2024 Pivotal, Inc.
+ * Copyright (c) 2019, 2026 Pivotal, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
@@ -218,8 +218,13 @@ else if (StringUtils.hasText(appData.getHost())) {
}
}
+ /**
+ * Most callers pin a fixed jmxurl, which is a stable and unique identifier on its own. The
+ * auto/dynamic JMX port case has no jmxurl since no port is picked by the IDE; the process
+ * id is used as the key instead.
+ */
public static String getProcessKey(RemoteBootAppData appData) {
- return appData.getJmxurl();
+ return StringUtils.hasText(appData.getJmxurl()) ? appData.getJmxurl() : appData.getProcessID();
}
public CompletableFuture connectProcess(RemoteBootAppData remoteProcess) {
@@ -232,8 +237,13 @@ public CompletableFuture connectProcess(RemoteBootAppData remoteProcess) {
String urlScheme = remoteProcess.getUrlScheme();
String projectName = remoteProcess.getProjectName();
// boolean keepChecking = _appData.isKeepChecking();
-
- if (jmxURL.startsWith("http")) {
+
+ if (jmxURL == null) {
+ // Auto/dynamic JMX port case: no port was picked by the IDE. Attach to the real
+ // child process by its PID and let the JVM start its own local management agent
+ // on-demand.
+ return connectLocalProcessByPid(processKey, processID, processName, projectName, urlScheme);
+ } else if (jmxURL.startsWith("http")) {
SpringProcessConnectorOverHttp connector = new SpringProcessConnectorOverHttp(processType, processKey, jmxURL, urlScheme, processID, processName, projectName, host, port);
return processConnectorService.connectProcess(processKey, connector);
} else {
@@ -241,7 +251,21 @@ public CompletableFuture connectProcess(RemoteBootAppData remoteProcess) {
return processConnectorService.connectProcess(processKey, connector);
}
}
-
+
+ private CompletableFuture connectLocalProcessByPid(String processKey, String processID, String processName, String projectName, String urlScheme) {
+ if (processID == null) {
+ return CompletableFuture.failedFuture(new IllegalStateException("No jmxurl or process id available for " + processKey));
+ }
+ try {
+ String jmxAddress = LocalJvmAttach.startLocalManagementAgent(processID);
+ SpringProcessConnectorOverJMX connector = new SpringProcessConnectorOverJMX(processType, processKey, jmxAddress, urlScheme, processID, processName, projectName, null, null);
+ return processConnectorService.connectProcess(processKey, connector);
+ } catch (Exception e) {
+ logger.error("exception while attaching to local process by pid: " + processID, e);
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
public RemoteBootAppData[] getProcesses() {
Set remoteApps = this.remoteAppInstances.keySet();
return (RemoteBootAppData[]) remoteApps.toArray(new RemoteBootAppData[remoteApps.size()]);
diff --git a/vscode-extensions/vscode-spring-boot/lib/debug-config-provider.ts b/vscode-extensions/vscode-spring-boot/lib/debug-config-provider.ts
index a7e34ad38d..a9f6660e46 100644
--- a/vscode-extensions/vscode-spring-boot/lib/debug-config-provider.ts
+++ b/vscode-extensions/vscode-spring-boot/lib/debug-config-provider.ts
@@ -13,16 +13,12 @@ import { debug,
import * as path from "path";
import psList from 'ps-list';
import { ListenablePreferenceSetting } from "@pivotal-tools/commons-vscode/lib/launch-util";
-import { getPortPromise } from "portfinder";
import { RemoteBootApp } from "./live-hover-connect-ui";
const JMX_VM_ARG = '-Dspring.jmx.enabled=';
const ACTUATOR_JMX_EXPOSURE_ARG = '-Dmanagement.endpoints.jmx.exposure.include=';
const ADMIN_VM_ARG = '-Dspring.application.admin.enabled=';
const BOOT_PROJECT_ARG = '-Dspring.boot.project.name=';
-const RMI_HOSTNAME = '-Djava.rmi.server.hostname=localhost';
-
-const JMX_PORT_ARG = '-Dcom.sun.management.jmxremote.port=';
export const TEST_RUNNER_MAIN_CLASSES = [
'org.eclipse.jdt.internal.junit.runner.RemoteTestRunner',
@@ -39,7 +35,9 @@ interface ProcessEvent {
class SpringBootDebugConfigProvider implements DebugConfigurationProvider {
async resolveDebugConfigurationWithSubstitutedVariables(folder: WorkspaceFolder | undefined, debugConfiguration: DebugConfiguration, _token?: CancellationToken) {
- // Running app live hovers support
+ // Running app live hovers support. No JMX port is picked here: the language server
+ // attaches to the process id on-demand instead (see handleCustomDebugEvent), once the
+ // process actually exists and has bound its own local management agent port.
if (!TEST_RUNNER_MAIN_CLASSES.includes(debugConfiguration.mainClass) && isActuatorOnClasspath(debugConfiguration)) {
if (typeof debugConfiguration.vmArgs === 'string') {
if (debugConfiguration.vmArgs.indexOf(JMX_VM_ARG) < 0) {
@@ -54,18 +52,8 @@ class SpringBootDebugConfigProvider implements DebugConfigurationProvider {
if (debugConfiguration.vmArgs.indexOf(BOOT_PROJECT_ARG) < 0) {
debugConfiguration.vmArgs += ` ${BOOT_PROJECT_ARG}${debugConfiguration.projectName}`;
}
- if (debugConfiguration.vmArgs.indexOf(RMI_HOSTNAME) < 0) {
- debugConfiguration.vmArgs += ` ${RMI_HOSTNAME}`;
- }
- if (debugConfiguration.vmArgs.indexOf(JMX_PORT_ARG) < 0) {
- debugConfiguration.vmArgs += ` ${JMX_PORT_ARG}${await getPortPromise({
- startPort: 10000
- })} -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false`
- }
} else {
- debugConfiguration.vmArgs = `${JMX_VM_ARG}true ${ACTUATOR_JMX_EXPOSURE_ARG}* ${ADMIN_VM_ARG}true ${BOOT_PROJECT_ARG}${debugConfiguration.projectName} ${RMI_HOSTNAME} ${JMX_PORT_ARG}${await getPortPromise({
- startPort: 10000
- })} -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false`;
+ debugConfiguration.vmArgs = `${JMX_VM_ARG}true ${ACTUATOR_JMX_EXPOSURE_ARG}* ${ADMIN_VM_ARG}true ${BOOT_PROJECT_ARG}${debugConfiguration.projectName}`;
}
}
return debugConfiguration;
@@ -116,17 +104,13 @@ async function handleCustomDebugEvent(e: DebugSessionCustomEvent): Promise
if (canConnect(debugConfiguration)) {
setTimeout(async () => {
const pid = await getAppPid(e.body as ProcessEvent);
- const vmArgs = debugConfiguration.vmArgs as string;
- let idx = vmArgs.indexOf(JMX_PORT_ARG) + JMX_PORT_ARG.length;
- let jmxPort = "";
- for (; idx < vmArgs.length && vmArgs.charAt(idx) <= "9" && vmArgs.charAt(idx) >= "0"; idx++) {
- jmxPort += vmArgs.charAt(idx);
- }
+ // No jmxurl: the language server attaches to processId on-demand instead of
+ // connecting to a pre-picked port (see SpringProcessConnectorRemote on the LS side).
await commands.executeCommand("vscode-spring-boot.live.activate", {
host: "127.0.0.1",
port: null,
urlScheme: "http",
- jmxurl: `service:jmx:rmi:///jndi/rmi://127.0.0.1:${jmxPort}/jmxrmi`,
+ jmxurl: null,
manualConnect: true,
processId: pid.toString(),
processName: debugConfiguration.mainClass,
@@ -175,7 +159,6 @@ function canConnect(debugConfiguration: DebugConfiguration): boolean {
if (!TEST_RUNNER_MAIN_CLASSES.includes(debugConfiguration.mainClass) && isActuatorOnClasspath(debugConfiguration)) {
return typeof debugConfiguration.vmArgs === 'string'
&& debugConfiguration.vmArgs.indexOf(`${JMX_VM_ARG}true`) >= 0
- && debugConfiguration.vmArgs.indexOf(JMX_PORT_ARG) >= 0
&& debugConfiguration.vmArgs.indexOf(`${ADMIN_VM_ARG}true`) >= 0
}
return false;
diff --git a/vscode-extensions/vscode-spring-boot/lib/live-hover-connect-ui.ts b/vscode-extensions/vscode-spring-boot/lib/live-hover-connect-ui.ts
index 811cf3b827..aa9f03deba 100644
--- a/vscode-extensions/vscode-spring-boot/lib/live-hover-connect-ui.ts
+++ b/vscode-extensions/vscode-spring-boot/lib/live-hover-connect-ui.ts
@@ -15,7 +15,7 @@ interface ProcessCommandInfo {
}
export interface RemoteBootApp {
- jmxurl: string;
+ jmxurl: string | null;
host: string;
urlScheme: "https" | "http";
port: string;
@@ -26,6 +26,16 @@ export interface RemoteBootApp {
projectName?: string;
}
+/**
+ * Most callers pin a fixed jmxurl, which is a stable and unique identifier on its own. The
+ * auto/dynamic JMX port case has no jmxurl since no port is picked by the extension; the
+ * process id is used as the key instead (mirrors SpringProcessConnectorRemote.getProcessKey
+ * on the language server side).
+ */
+function getProcessKey(appData: RemoteBootApp | undefined): string | undefined {
+ return appData?.jmxurl || appData?.processId;
+}
+
export interface BootAppQuickPick extends QuickPickItem {
commandInfo: ProcessCommandInfo;
}
@@ -94,7 +104,7 @@ async function liveHoverConnectHandler() {
}
async function executeLiveProcessAction(commandInfo: ProcessCommandInfo) {
- if (activeBootApp?.jmxurl === commandInfo.processKey) {
+ if (getProcessKey(activeBootApp) === commandInfo.processKey) {
switch (commandInfo.action) {
case CONNECT_CMD:
await commands.executeCommand('vscode-spring-boot.live.show.active');
@@ -140,16 +150,23 @@ export function activate(
}),
commands.registerCommand("vscode-spring-boot.live.deactivate", async () => {
- await commands.executeCommand('sts/livedata/localRemove', activeBootApp.jmxurl);
- activeBootApp = undefined;
+ // Fires on every debug/run session termination, not just ones where live-hover was
+ // ever activated, so activeBootApp may be unset here.
+ if (activeBootApp) {
+ await commands.executeCommand('sts/livedata/localRemove', getProcessKey(activeBootApp));
+ activeBootApp = undefined;
+ }
updateBootAppState("none");
}),
commands.registerCommand("vscode-spring-boot.live.show.active", async () => {
+ if (!activeBootApp) {
+ return;
+ }
try {
updateBootAppState("connecting");
await commands.executeCommand(CONNECT_CMD, {
- processKey: activeBootApp.jmxurl
+ processKey: getProcessKey(activeBootApp)
});
updateBootAppState("connected");
} catch (error) {
@@ -159,16 +176,22 @@ export function activate(
}),
commands.registerCommand("vscode-spring-boot.live.refresh.active", async () => {
+ if (!activeBootApp) {
+ return;
+ }
await commands.executeCommand(REFRESH_CMD, {
- processKey: activeBootApp.jmxurl
+ processKey: getProcessKey(activeBootApp)
});
}),
commands.registerCommand("vscode-spring-boot.live.hide.active", async () => {
+ if (!activeBootApp) {
+ return;
+ }
try {
updateBootAppState("disconnecting");
await commands.executeCommand(DISCONNECT_CMD, {
- processKey: activeBootApp.jmxurl
+ processKey: getProcessKey(activeBootApp)
});
updateBootAppState("disconnected");
} catch (error) {
diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json
index 54df904484..33ab1bb0dc 100644
--- a/vscode-extensions/vscode-spring-boot/package.json
+++ b/vscode-extensions/vscode-spring-boot/package.json
@@ -1614,7 +1614,6 @@
},
"dependencies": {
"@pivotal-tools/commons-vscode": "file:../commons-vscode/pivotal-tools-commons-vscode-0.2.4.tgz",
- "portfinder": "~1.0.38",
"ps-list": "^7.2.0",
"vscode-languageclient": "^9.0.1"
},