From 75e7d582d4f9b49f7d9c1d86dee9c927a9d42a7b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:46:12 +0800 Subject: [PATCH] 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 --- crates/ide-core/src/process.rs | 23 ++++ crates/ide-dap/src/adapter.rs | 121 ++++++++++++++++-- crates/ide-run/src/which.rs | 23 +--- .../debugger/__tests__/launch.test.ts | 14 ++ src/features/debugger/actions.ts | 7 +- src/features/debugger/launch.ts | 4 + src/features/run/java.test.ts | 42 ++++++ src/features/run/javaDebug.ts | 18 ++- src/platform/locales/text/zh.json | 1 + 9 files changed, 216 insertions(+), 37 deletions(-) diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 2d5d35dd1..dd9212582 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -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 { + 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::*; diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 5aa690ec6..117e3c90b 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -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 { 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 { + 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 { 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 { } 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 (`//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); + } } diff --git a/crates/ide-run/src/which.rs b/crates/ide-run/src/which.rs index a2402bde7..8e790cc1f 100644 --- a/crates/ide-run/src/which.rs +++ b/crates/ide-run/src/which.rs @@ -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 { - 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 { diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index f9e098dd4..247916360 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -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"); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index ee0ca88c1..88338717c 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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); diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index ccd9229b6..36477017b 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -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 ( diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 974a5bcf2..079d82529 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -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", () => { diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index def29583c..0fbed5b2f 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -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, key: string, value: string): Record { + 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; } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 57d441b04..7e9d6faf3 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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",