fix(debugger): Debug JVM scripts and Maven server goals through a JDWP agent (#185)

java-debug runs inside jdtls and is reached through its debugServer port, but configurations that aren't Application / Spring Boot and select java-debug (a Shell Script running `catalina.bat run`, say) fell through to the generic launch template, and the adapter launcher tried to start com.microsoft.java.debug.plugin.jar as a process: on Windows that failed with os error 193, elsewhere with a misleading "debug adapter not found".

- Shell script / program configurations with java-debug start with a suspended JDWP agent injected through JAVA_TOOL_OPTIONS and attach once the JVM prints its port, so `catalina.bat run` / `catalina.sh run` can be debugged.
- Maven goals that run the app in Maven's JVM (tomcat7:run, jetty:run(-war), exec:java, or any Maven config with java-debug) are debugged through MAVEN_OPTS.
- Gradle tasks that start no JVM say why they can't be debugged; `debug.startConfig` with a single debuggable config goes through the run debug flow.
- .jar adapters are never spawned as processes, and Windows adapter lookup only accepts PATHEXT files (so npm-installed adapters pick the .cmd shim instead of the POSIX one); the PATHEXT expansion moves to ide_core::process::executable_names, shared with ide-run.

Not run on Windows; the Windows resolution is covered by tests with an injected platform.

Fixes #179

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 09:46:12 +08:00
1 джерело 1f9be73174
коміт 75e7d582d4
9 files changed
+216 -37

No files matched your search

+23
Переглянути файл
@@ -190,6 +190,29 @@ pub fn env_overlay() -> Changes {
OVERLAY.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
}
const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
/// File names an executable `name` may have on disk. Unix: the name itself. Windows: the
/// name when it already ends in a `PATHEXT` extension, else the name with each
/// extension (an extensionless file is not executable there).
pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec<String> {
if !windows {
return vec![name.to_owned()];
}
let exts: Vec<&str> = pathext
.filter(|p| !p.trim().is_empty())
.unwrap_or(DEFAULT_PATHEXT)
.split(';')
.map(str::trim)
.filter(|e| e.starts_with('.') && e.len() > 1)
.collect();
let lower = name.to_ascii_lowercase();
if exts.iter().any(|e| lower.ends_with(&e.to_ascii_lowercase())) {
return vec![name.to_owned()];
}
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
+108 -13
Переглянути файл
@@ -6,6 +6,7 @@
//! Adapters that run inside another process (java-debug inside jdtls) are
//! reached through a `debugServer` port in the launch arguments instead.
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry};
use std::future::Future;
@@ -195,30 +196,43 @@ fn search_dirs() -> Vec<PathBuf> {
dirs
}
fn is_executable(path: &Path) -> bool {
fn is_executable(path: &Path, windows: bool) -> bool {
#[cfg(unix)]
{
if !windows {
use std::os::unix::fs::PermissionsExt;
path.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
return path.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false);
}
#[cfg(not(unix))]
{
path.is_file()
}
let _ = windows;
path.is_file()
}
/// Resolves `command` to an executable: paths are taken as-is (relative to `cwd`), bare names are looked up on PATH.
/// On Windows only `PATHEXT` files count (`foo` → `foo.exe` / `foo.cmd`), so a `.jar` or an
/// extensionless script next to a shim is never started as a program.
pub fn find_binary(command: &str, cwd: &Path) -> Option<PathBuf> {
let pathext = std::env::var("PATHEXT").ok();
find_binary_in(command, cwd, &search_dirs(), cfg!(windows), pathext.as_deref())
}
fn find_binary_in(
command: &str,
cwd: &Path,
dirs: &[PathBuf],
windows: bool,
pathext: Option<&str>,
) -> Option<PathBuf> {
let p = Path::new(command);
if p.components().count() > 1 || p.is_absolute() {
let full = if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) };
return is_executable(&full).then_some(full);
let (dir, name) = (full.parent()?, full.file_name()?.to_str()?);
return executable_names(name, windows, pathext)
.into_iter()
.map(|n| dir.join(n))
.find(|c| is_executable(c, windows));
}
let exts: &[&str] = if cfg!(windows) { &["", ".exe", ".cmd", ".bat"] } else { &[""] };
search_dirs()
.into_iter()
.flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}"))))
.find(|c| is_executable(c))
let names = executable_names(command, windows, pathext);
dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|c| is_executable(c, windows))
}
/// [`find_binary`], plus Xcode's toolchain on macOS (`xcrun -f lldb-dap`): Xcode ships
@@ -234,7 +248,7 @@ fn xcrun_tool(command: &str) -> Option<PathBuf> {
}
let out = std::process::Command::new("/usr/bin/xcrun").args(["-f", command]).stderr(Stdio::null()).output().ok()?;
let path = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
(out.status.success() && is_executable(&path)).then_some(path)
(out.status.success() && is_executable(&path, false)).then_some(path)
}
#[cfg(not(target_os = "macos"))]
@@ -288,6 +302,22 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
Path::new(command).extension().is_some_and(|e| e.eq_ignore_ascii_case("jar"))
}
/// A launch of a plugin adapter without the `debugServer` of its host program.
pub fn not_standalone(spec: &AdapterSpec) -> IdeError {
IdeError::unavailable(format!(
"debug adapter `{}` runs inside the Java language server and can't be started on its own. \
Debug an Application, Spring Boot, JUnit, Maven or Gradle configuration, or start the JVM \
with a JDWP agent and attach with a Remote JVM Debug configuration",
spec.adapter
))
}
pub fn missing(spec: &AdapterSpec) -> IdeError {
let mut msg = format!(
"debug adapter `{}` not found (`{}` is neither installed by OpenIDEA nor on PATH)",
@@ -376,6 +406,9 @@ impl AdapterLauncher for ProcessLauncher {
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
return Err(not_standalone(&spec));
}
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
@@ -584,4 +617,66 @@ mod tests {
let err = ProcessLauncher.launch(spec, None).await.err().unwrap();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
}
/// The managed tools layout (`<tools>/<tool>/bin`) as Windows sees it: only `PATHEXT`
/// files run, so the java-debug bundle jar and POSIX shims are never adapter programs.
#[test]
fn windows_resolution_only_takes_pathext_files() {
let dir = std::env::temp_dir().join(format!("openidea-dap-win-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let java_debug = dir.join("java-debug").join("bin");
let js_debug = dir.join("js-debug").join("bin");
let codelldb = dir.join("codelldb").join("bin");
for d in [&java_debug, &js_debug, &codelldb] {
std::fs::create_dir_all(d).unwrap();
}
let jar = "com.microsoft.java.debug.plugin.jar";
std::fs::write(java_debug.join(jar), "PK").unwrap();
std::fs::write(js_debug.join("js-debug-adapter"), "#!/bin/sh\n").unwrap();
std::fs::write(js_debug.join("js-debug-adapter.cmd"), "@echo off\r\n").unwrap();
std::fs::write(codelldb.join("codelldb.exe"), "MZ").unwrap();
let dirs = [java_debug.clone(), js_debug.clone(), codelldb.clone()];
let cwd = dir.clone();
let win = |cmd: &str| find_binary_in(cmd, &cwd, &dirs, true, Some(".COM;.EXE;.BAT;.CMD"));
assert_eq!(win(jar), None);
assert_eq!(win(&java_debug.join(jar).to_string_lossy()), None);
assert_eq!(win("js-debug-adapter"), Some(js_debug.join("js-debug-adapter.cmd")));
assert_eq!(win("codelldb"), Some(codelldb.join("codelldb.exe")));
assert_eq!(win(&codelldb.join("codelldb").to_string_lossy()), Some(codelldb.join("codelldb.exe")));
assert_eq!(win(&codelldb.join("codelldb.exe").to_string_lossy()), Some(codelldb.join("codelldb.exe")));
assert_eq!(win("netcoredbg"), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// Unix needs the executable bit: a plain file (the bundle jar) is not a program.
#[cfg(unix)]
#[test]
fn unix_resolution_needs_the_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("openidea-dap-unix-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plugin.jar"), "PK").unwrap();
std::fs::write(dir.join("adapter"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(dir.join("adapter"), std::fs::Permissions::from_mode(0o755)).unwrap();
let dirs = [dir.clone()];
assert_eq!(find_binary_in("plugin.jar", &dir, &dirs, false, None), None);
assert_eq!(find_binary_in("adapter", &dir, &dirs, false, None), Some(dir.join("adapter")));
let _ = std::fs::remove_dir_all(&dir);
}
/// java-debug (from `languages/java.toml` or the built-in) is only reachable through
/// jdtls: a launch without `debugServer` is refused instead of starting the jar.
#[tokio::test]
async fn plugin_adapters_are_never_started_as_processes() {
assert!(is_plugin_bundle("com.microsoft.java.debug.plugin.jar"));
assert!(is_plugin_bundle("C:\\tools\\java-debug\\bin\\Plugin.JAR"));
for cmd in ["codelldb", "python3", "js-debug-adapter", "dlv", "netcoredbg.exe", "lldb-dap"] {
assert!(!is_plugin_bundle(cmd), "{cmd}");
}
let spec = resolve(&builtin("java-debug").unwrap(), std::env::temp_dir());
let err = ProcessLauncher.launch(spec, None).await.err().unwrap();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
}
+1 -22
Переглянути файл
@@ -5,28 +5,7 @@
use std::ffi::OsStr;
use std::path::PathBuf;
const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
/// File names `name` may have on disk. Unix: the name itself. Windows: the
/// name when it already ends in a `PATHEXT` extension, else the name with each
/// extension (an extensionless file is not executable there).
pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec<String> {
if !windows {
return vec![name.to_owned()];
}
let exts: Vec<&str> = pathext
.filter(|p| !p.trim().is_empty())
.unwrap_or(DEFAULT_PATHEXT)
.split(';')
.map(str::trim)
.filter(|e| e.starts_with('.') && e.len() > 1)
.collect();
let lower = name.to_ascii_lowercase();
if exts.iter().any(|e| lower.ends_with(&e.to_ascii_lowercase())) {
return vec![name.to_owned()];
}
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
}
pub use ide_core::process::executable_names;
/// First `search` directory holding `name` (see [`executable_names`]).
pub fn find_executable_in(name: &str, search: &OsStr, windows: bool, pathext: Option<&str>) -> Option<PathBuf> {
+14
Переглянути файл
@@ -162,6 +162,20 @@ describe("run config → launch config", () => {
expect(launchConfigFor(script, "/p")).not.toBeNull();
});
it("never hands a Java command to java-debug as a program to start", () => {
const tomcat = rc({
name: "tomcat9",
command: "C:\\apache-tomcat-9.0.98\\bin\\catalina.bat",
args: ["run"],
debugAdapter: "java-debug",
});
const gradle = rc({ kind: "gradle", command: "./gradlew", args: ["build"], debugAdapter: "java-debug" });
for (const c of [tomcat, gradle]) {
expect(launchConfigFor(c, "/p")).toBeNull();
expect(debugUnsupportedReason(c)).toContain("Remote JVM Debug");
}
});
it("delve: debug mode for run, test mode for test", () => {
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
expect(run.program).toBe("/p");
+6 -1
Переглянути файл
@@ -1,5 +1,5 @@
/** Debugger actions (ids match `keymaps/intellij-macos.ts`). */
import { registerActions, type Action } from "@/platform/actions";
import { executeAction, getAction, registerActions, type Action } from "@/platform/actions";
import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
@@ -242,6 +242,11 @@ async function startConfig(arg: unknown) {
return;
}
config = configs[0]!;
// Through Run's debug flow: Maven / Gradle / scripts start the JVM with a JDWP agent there.
if (getAction("run.debugConfig")) {
await executeAction("run.debugConfig", config);
return;
}
}
if (isRemoteConfig(config)) {
await startRemote(config, root);
+4
Переглянути файл
@@ -124,8 +124,12 @@ function commandName(cmd: string) {
/**
* Why `rc` can't be debugged even though it names a debugger, or null when it can.
* Bun's inspector speaks the WebKit Inspector protocol, which js-debug can't attach to.
* Java configurations reach this only when the Run feature found no JVM to attach to.
*/
export function debugUnsupportedReason(rc: RunConfig): string | null {
// java-debug runs inside jdtls: it launches Application configurations or attaches to a JDWP agent.
if (rc.debugAdapter === "java-debug")
return "The Java debugger can't start this command itself. Debug an Application, Spring Boot, Maven or Gradle configuration, or start the JVM with a JDWP agent and attach with a Remote JVM Debug configuration.";
const name = commandName(rc.command);
if (rc.kind === "bun" || (name === "bun" && rc.kind !== "npm"))
return (
+42
Переглянути файл
@@ -261,6 +261,48 @@ describe("debugging build tool configurations", () => {
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"] }))).toBeNull();
expect(jdwpLaunch(cfg({ id: "n", kind: "npm", args: ["test"] }))).toBeNull();
});
const AGENT = /-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127\.0\.0\.1:0$/;
it.each([
["tomcat7:run"],
["org.apache.tomcat.maven:tomcat7-maven-plugin:2.2:run"],
["jetty:run"],
["jetty:run-war"],
["exec:java"],
])("debugs Maven's own JVM for the in-process goal %s", (goal) => {
const out = jdwpLaunch(cfg({ id: "m", kind: "maven", args: ["clean", goal], env: { MAVEN_OPTS: "-Xmx1g" } }))!;
expect(out.args).toEqual(["clean", goal]);
expect(out.env.MAVEN_OPTS).toMatch(/^-Xmx1g -agentlib:jdwp=/);
expect(out.env.MAVEN_OPTS).toMatch(AGENT);
});
it("keeps plain Maven builds out of the debugger unless set to the Java debugger", () => {
expect(jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["exec:exec"] }))).toBeNull();
const chosen = jdwpLaunch(cfg({ id: "p", kind: "maven", args: ["package"], debugAdapter: "java-debug" }))!;
expect(chosen.env.MAVEN_OPTS).toMatch(AGENT);
});
it.each([
["shell", "C:\\apache-tomcat-9.0.98\\bin\\catalina.bat", ["run"]],
["shell", "/opt/tomcat9/bin/catalina.sh", ["run"]],
["program", "java", ["-jar", "app.war"]],
])("starts a %s configuration set to the Java debugger with an agent in JAVA_TOOL_OPTIONS", (kind, command, args) => {
const rc = cfg({ id: "tomcat9", kind, command, args, debugAdapter: "java-debug" });
const out = jdwpLaunch(rc)!;
expect(out.command).toBe(command);
expect(out.args).toEqual(args);
expect(out.env.JAVA_TOOL_OPTIONS).toMatch(AGENT);
const merged = jdwpLaunch({ ...rc, env: { JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8" } })!;
expect(merged.env.JAVA_TOOL_OPTIONS).toMatch(/^-Dfile\.encoding=UTF-8 -agentlib:jdwp=/);
});
it("leaves Application configurations and other debuggers alone", () => {
for (const kind of ["application", "spring-boot"])
expect(jdwpLaunch(cfg({ id: "a", kind, command: "java", debugAdapter: "java-debug" }))).toBeNull();
expect(jdwpLaunch(cfg({ id: "s", kind: "shell", command: "./run.sh", debugAdapter: "debugpy" }))).toBeNull();
expect(jdwpLaunch(cfg({ id: "g", kind: "gradle", args: ["build"], debugAdapter: "java-debug" }))).toBeNull();
});
});
describe("JUnit XML reports", () => {
+17 -1
Переглянути файл
@@ -4,15 +4,26 @@
* (`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 type { RunConfig } from "@/platform/ipc";
import { JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { startRun, useRun } from "./store";
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 JAVA_ADAPTER = "java-debug";
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}=`;
@@ -38,12 +49,17 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null {
if (failsafe) out = withProperty(out, "maven.failsafe.debug", JDWP_AGENT);
return { ...config, args: out };
}
if (config.debugAdapter === JAVA_ADAPTER || args.some(isMavenInProcessGoal))
return { ...config, env: withOption(config.env, "MAVEN_OPTS", JDWP_AGENT) };
return null;
}
if (config.kind === "gradle" || /(^|\/)gradlew?(\.bat)?$/.test(config.command)) {
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 (config.debugAdapter === JAVA_ADAPTER && !isJvmApplication(config))
return { ...config, env: withOption(config.env, "JAVA_TOOL_OPTIONS", JDWP_AGENT) };
return null;
}
+1
Переглянути файл
@@ -3462,6 +3462,7 @@
"'{name}': listening for the remote JVM on port {port}": "'{name}':正在端口 {port} 上监听远程 JVM",
"“{name}” can't be debugged": "无法调试“{name}”",
"It has no debug adapter.": "它没有调试适配器。",
"The Java debugger can't start this command itself. Debug an Application, Spring Boot, Maven or Gradle configuration, or start the JVM with a JDWP agent and attach with a Remote JVM Debug configuration.": "Java 调试器无法自行启动此命令。请调试“应用程序”、Spring Boot、Maven 或 Gradle 配置,或者用 JDWP 代理启动 JVM,再用“远程 JVM 调试”配置附加。",
"Attach the Java debugger to a JVM listening for JDWP (arg: { name, module, hostName, port, process })": "将 Java 调试器附加到监听 JDWP 的 JVM(参数:{ name, module, hostName, port, process })",
"'{name}' has compile errors; debugging the last compiled classes": "'{name}' 存在编译错误;将调试上次编译的类",
"Attach to Godot": "附加到 Godot",