diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 1b3ce2d2d..aff17aeaa 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -36,6 +36,8 @@ pub struct AdapterSpec { pub cwd: PathBuf, /// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`). pub env: Vec<(String, String)>, + /// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface. + pub server_host: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -116,6 +118,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { install_hint: config.install_hint.clone(), cwd, env: Vec::new(), + server_host: None, } } @@ -309,7 +312,12 @@ impl AdapterLauncher for ProcessLauncher { Box::pin(async move { let stderr = Arc::new(parking_lot::Mutex::new(Vec::new())); if let Some(port) = reuse_port { - let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?; + // A remote adapter (`dlv --headless` on another machine) may answer slowly. + let (host, timeout) = match spec.server_host.as_deref() { + Some(host) => (host, CONNECT_TIMEOUT), + None => (LOOPBACK, Duration::from_secs(5)), + }; + let stream = connect_with_retries(host, port, None, timeout).await?; let (r, w) = stream.into_split(); return Ok(AdapterIo { reader: Box::new(r), @@ -366,7 +374,7 @@ impl AdapterLauncher for ProcessLauncher { if let Some(out) = child.stdout.take() { pump_lines(out, spec.adapter.clone(), stderr.clone()); } - let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await { + let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await { Ok(s) => s, Err(e) => { let _ = child.start_kill(); @@ -410,14 +418,17 @@ fn pump_lines( }); } +const LOOPBACK: &str = "127.0.0.1"; + async fn connect_with_retries( + host: &str, port: u16, mut child: Option<&mut Child>, timeout: Duration, ) -> IdeResult { let deadline = Instant::now() + timeout; loop { - match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + match tokio::net::TcpStream::connect((host, port)).await { Ok(s) => { let _ = s.set_nodelay(true); return Ok(s); @@ -432,7 +443,7 @@ async fn connect_with_retries( } if Instant::now() >= deadline { return Err(IdeError::unavailable(format!( - "could not connect to debug adapter on port {port}: {e}" + "could not connect to debug adapter at {host}:{port}: {e}" ))); } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/crates/ide-dap/src/jdwp.rs b/crates/ide-dap/src/jdwp.rs new file mode 100644 index 000000000..41deab2c2 --- /dev/null +++ b/crates/ide-dap/src/jdwp.rs @@ -0,0 +1,97 @@ +//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens, +//! the remote JVM (`-agentlib:jdwp=…,server=n,address=:`) connects to +//! it. java-debug can only attach, so the connection is relayed: once the JVM has +//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP +//! stream (handshake included) both ways. + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::ErrorKind; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +/// How long the relay waits for java-debug to attach once the JVM is connected. +const ATTACH_TIMEOUT: Duration = Duration::from_secs(60); + +/// The address to listen on for `host`: the interface it names, or every interface when +/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to). +async fn bind(host: &str, port: u16) -> IdeResult { + let host = host.trim(); + let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') }; + match TcpListener::bind((host, port)).await { + Ok(l) => Ok(l), + Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => { + TcpListener::bind(("0.0.0.0", port)) + .await + .map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}"))) + } + Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))), + } +} + +/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays +/// to it (accepts one connection). Cancelled through `cancel` while waiting. +pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult { + let listener = bind(host, port).await?; + let jvm = tokio::select! { + _ = cancel.cancelled() => return Err(IdeError::cancelled()), + accepted = listener.accept() => accepted.map_err(IdeError::from)?.0, + }; + drop(listener); + let _ = jvm.set_nodelay(true); + let relay = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = relay.local_addr()?.port(); + tokio::spawn(relay_one(relay, jvm)); + Ok(local) +} + +async fn relay_one(relay: TcpListener, mut jvm: TcpStream) { + let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await { + Ok(Ok((s, _))) => s, + _ => return, + }; + drop(relay); + let _ = debugger.set_nodelay(true); + let mut debugger = debugger; + if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await { + tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn relays_the_jdwp_handshake_both_ways() { + let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new())); + // The remote JVM (`server=n`) connects to the IDE. + let mut jvm = loop { + if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await { + break s; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let local = waiting.await.unwrap().unwrap(); + // java-debug attaches to the relay and starts the handshake. + let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap(); + debugger.write_all(b"JDWP-Handshake").await.unwrap(); + let mut buf = [0u8; 14]; + jvm.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + jvm.write_all(b"JDWP-Handshake").await.unwrap(); + debugger.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + } + + #[tokio::test] + async fn waiting_is_cancellable() { + let cancel = CancellationToken::new(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone())); + tokio::time::sleep(Duration::from_millis(50)).await; + cancel.cancel(); + let err = waiting.await.unwrap().unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + } +} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 1d76b48f9..20f8c0cbc 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -12,6 +12,7 @@ pub mod adapter; pub mod attach; mod cargo; pub mod connection; +pub mod jdwp; mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; @@ -45,7 +46,8 @@ pub struct LaunchConfig { /// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo /// and launches the produced executable; `"debugServer": ` connects to an /// adapter already listening on that local port (e.g. java-debug inside jdtls) - /// instead of starting one. + /// instead of starting one, `"debugServer": "host:port"` to one on another machine + /// (`dlv --headless`, `debugpy --listen`). pub extra_json: Option, } @@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() -> }) } +/// Where a running adapter listens: `debugServer` of the launch arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DebugServer { + /// `None` for the loopback interface (an adapter inside a local process such as jdtls). + pub host: Option, + pub port: u16, +} + /// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the -/// launch arguments and returns its port. -pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { +/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` / +/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`). +pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) }; - let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())); - match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) { - Some(p) => Ok(Some(p)), - None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))), + let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}")); + let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0); + if let Some(p) = v.as_u64() { + return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid); } + let s = v.as_str().map(str::trim).ok_or_else(invalid)?; + let (host, port) = match s.rsplit_once(':') { + Some((host, port)) => { + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + (Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port) + } + None => (None, s), + }; + let port = port.trim().parse::().ok().and_then(port_of).ok_or_else(invalid)?; + Ok(Some(DebugServer { host, port })) } /// DAP stack frame → [`StackFrame`] (1-based → 0-based). @@ -558,6 +579,9 @@ impl DebugManager { } let id = DebugSessionId::next(); let debug_server = take_debug_server(&mut arguments)?; + if let Some(server) = &debug_server { + spec.server_host = server.host.clone(); + } if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) { let cargo_args: Vec = cargo .get("args") @@ -599,8 +623,8 @@ impl DebugManager { if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() { arguments["sourceLanguages"] = json!(["rust"]); } - self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server) - .await + let port = debug_server.map(|s| s.port); + self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await } /// `startDebugging` reverse request: a child session on the same adapter. diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 39939630e..fac94e1b9 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() { #[test] fn debug_server_is_taken_from_launch_arguments() { + let local = |port| Some(DebugServer { host: None, port }); + let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port }); let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"}); - assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711)); + assert_eq!(take_debug_server(&mut args).unwrap(), local(4711)); assert!(args.get("debugServer").is_none()); assert_eq!(args["mainClass"], "a.B"); - assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005)); assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None); + // A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`. + assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345)); assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err()); assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err()); } #[test] @@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() { assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. }))); } +/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`, +/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one. +#[tokio::test] +async fn connects_to_remote_debug_server_by_host() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new()))); + let config = LaunchConfig { + name: "Remote".into(), + adapter: "delve".into(), + request: "attach".into(), + program: None, + args: vec![], + cwd: Some(std::env::temp_dir().into()), + env: BTreeMap::new(), + extra_json: Some( + json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]}) + .to_string(), + ), + }; + let id = manager.start(config, owner).await.unwrap(); + let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap(); + assert_eq!(attach["arguments"]["mode"], "remote"); + assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app"); + assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client"); + manager.stop(id).await.unwrap(); +} + fn requests(h: &Harness, command: &str) -> Vec { h.log .lock() diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 6c93cd8e5..e435b115f 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -264,6 +264,22 @@ pub async fn debug_attachable_processes() -> IdeResult, + request: ide_core::RequestId, + host: String, + port: u16, +) -> IdeResult { + let token = state.begin_request(request); + let result = jdwp::listen_for_jvm(&host, port, token).await; + state.end_request(request); + result +} + /// HotSwap (Reload Changed Classes) in a java-debug session. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2a312c052..7247d0b85 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -286,6 +286,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_set_data_breakpoints, debug::debug_data_breakpoint_info, debug::debug_attachable_processes, + debug::debug_listen_for_jvm, debug::debug_redefine_classes, coverage::coverage_prepare, coverage::coverage_collect, diff --git a/src/bindings.ts b/src/bindings.ts index 3f40dce2c..6e5e95057 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -582,6 +582,11 @@ export const commands = { debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })), /** Attach to Process: the user's processes a debugger can attach to. */ debugAttachableProcesses: () => typedError(__TAURI_INVOKE("debug_attachable_processes")), + /** + * Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote + * JVM to connect to `host:port`; returns the loopback port java-debug attaches to. + */ + debugListenForJvm: (request: RequestId, host: string, port: number) => typedError(__TAURI_INVOKE("debug_listen_for_jvm", { request, host, port })), /** HotSwap (Reload Changed Classes) in a java-debug session. */ debugRedefineClasses: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_redefine_classes", { session })), /** @@ -2949,7 +2954,8 @@ export type LaunchConfig = { * A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo * and launches the produced executable; `"debugServer": ` connects to an * adapter already listening on that local port (e.g. java-debug inside jdtls) - * instead of starting one. + * instead of starting one, `"debugServer": "host:port"` to one on another machine + * (`dlv --headless`, `debugpy --listen`). */ extraJson: string | null, }; diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts new file mode 100644 index 000000000..d0c17fff8 --- /dev/null +++ b/src/features/debugger/__tests__/remote.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { registerRunConfigType } from "@/workbench/contributions"; + +const invoked: Array<[string, unknown[]]> = []; +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + return { + ...real, + commands: { + ...real.commands, + javaDebugServer: (...a: unknown[]) => { + invoked.push(["javaDebugServer", a]); + return ok(41234); + }, + }, + }; +}); + +const { javaAttachConfig } = await import("../java"); +const remote = await import("../remote"); +const { missingParams, newConfigOfType, isDebugOnly, terminalApplies, withParams } = + await import("@/features/run/types"); +const { validateConfig } = await import("@/features/run/configs"); + +const { + REMOTE_TYPES, + remoteJvmType, + goRemoteType, + pythonRemoteType, + nodeRemoteType, + jdwpAgentArgs, + remoteAttachConfig, + remoteJvmTarget, +} = remote; + +function configOf(type: (typeof REMOTE_TYPES)[number], params: Record = {}): RunConfig { + const c = newConfigOfType(type, []); + return withParams({ ...c, name: "Remote" }, { ...c.params, ...params }, type); +} + +let disposers: Array<() => void> = []; +beforeEach(() => { + invoked.length = 0; + disposers = REMOTE_TYPES.map(registerRunConfigType); +}); +afterEach(() => disposers.forEach((d) => d())); + +describe("remote debug configuration types", () => { + it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))( + "%s is debug-only, launches nothing and is valid by default", + (_, type) => { + const c = configOf(type); + expect(type.debugOnly).toBe(true); + expect(isDebugOnly(c)).toBe(true); + expect(c.debugAdapter).toBe(type.debugAdapter); + expect(c.command).toBe(""); + expect(terminalApplies(c, type)).toBe(false); + expect(validateConfig(c, [c])).toEqual({}); + expect(missingParams(c, type)).toEqual({}); + }, + ); + + it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))("%s rejects a bad port and an empty host", (_, type) => { + expect(missingParams(configOf(type, { port: "70000" }), type)).toEqual({ + port: "Port must be a number from 1 to 65535", + }); + expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined(); + expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" }); + }); + + it("Remote JVM Debug defaults to attaching to localhost:5005", () => { + expect(configOf(remoteJvmType).params).toEqual({ mode: "attach", host: "localhost", port: "5005", jdk: "9" }); + }); +}); + +describe("Remote JVM Debug: command line arguments for remote JVM", () => { + it.each([ + [{ jdk: "9" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"], + [{ jdk: "5" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"], + [{ jdk: "1.4" }, "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"], + [{ jdk: "9", port: "8000" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"], + [ + { mode: "listen", host: "dev-box", port: "5005" }, + "-agentlib:jdwp=transport=dt_socket,server=n,address=dev-box:5005,suspend=y", + ], + ])("%j → %s", (params, expected) => { + expect(jdwpAgentArgs({ ...remoteJvmType.defaults, ...params })).toBe(expected); + }); + + it("is shown as a read-only field that follows the form", () => { + const field = remoteJvmType.fields.find((f) => f.key === "args")!; + expect(field.control).toBe("output"); + expect(field.value!({ port: "6006", jdk: "5" })).toContain("address=6006"); + }); +}); + +describe("remote attach requests", () => { + it("Remote JVM Debug attaches java-debug to host:port with the module's sources", async () => { + const c = configOf(remoteJvmType, { host: "10.0.0.5", port: "5006", module: "api" }); + const t = remoteJvmTarget(c); + expect(t).toEqual({ mode: "attach", host: "10.0.0.5", port: 5006, module: "api" }); + const l = await javaAttachConfig({ name: c.name, module: t.module, hostName: t.host, port: t.port }, "/p"); + expect(invoked).toEqual([["javaDebugServer", ["api"]]]); + expect(l.adapter).toBe("java-debug"); + expect(l.request).toBe("attach"); + expect(l.cwd).toBe("/p/api"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "10.0.0.5", port: 5006 }); + }); + + it("Remote JVM Debug in listen mode", () => { + expect(remoteJvmTarget(configOf(remoteJvmType, { mode: "listen" })).mode).toBe("listen"); + }); + + it("Go Remote connects to dlv --headless and maps the source root", () => { + const l = remoteAttachConfig(configOf(goRemoteType, { host: "build", remoteRoot: "/go/src/app" }), "/p")!; + expect(l).toMatchObject({ adapter: "delve", request: "attach", cwd: "/p" }); + expect(JSON.parse(l.extraJson!)).toEqual({ + debugServer: "build:2345", + mode: "remote", + substitutePath: [{ from: "/p", to: "/go/src/app" }], + }); + const plain = JSON.parse(remoteAttachConfig(configOf(goRemoteType), "/p")!.extraJson!); + expect(plain).toEqual({ debugServer: "localhost:2345", mode: "remote" }); + }); + + it("Python remote connects to debugpy --listen with pathMappings", () => { + const l = remoteAttachConfig( + configOf(pythonRemoteType, { host: "::1", localRoot: "svc", remoteRoot: "/app" }), + "/p", + )!; + expect(l.adapter).toBe("debugpy"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + debugServer: "[::1]:5678", + pathMappings: [{ localRoot: "/p/svc", remoteRoot: "/app" }], + }); + }); + + it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => { + const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!; + expect(l.adapter).toBe("js-debug"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + type: "pwa-node", + address: "web", + port: 9229, + localRoot: "/p", + remoteRoot: "/usr/src/app", + }); + }); + + it("start-the-remote-side hints follow the port", () => { + expect(remote.delveServerCommand({ port: "4000" })).toBe( + "dlv debug --headless --listen=:4000 --api-version=2 --accept-multiclient", + ); + expect(remote.debugpyServerCommand({ port: "5679" })).toContain("--listen 0.0.0.0:5679"); + expect(remote.nodeInspectCommand({})).toBe("node --inspect=0.0.0.0:9229 app.js"); + }); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index f70c73c73..761f2da99 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -1,6 +1,6 @@ /** Debugger actions (ids match `keymaps/intellij-macos.ts`). */ import { registerActions, type Action } from "@/platform/actions"; -import { call, commands, isTauri, type RunConfig, type RunId } from "@/platform/ipc"; +import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; @@ -18,6 +18,7 @@ import { toggleBreakpointSmart } from "./breakpoints/toggle"; import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java"; import { reloadChangedClasses } from "./hotswap"; import { debugUnsupportedReason, launchConfigFor } from "./launch"; +import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; import { activeSession, isLive, @@ -142,6 +143,52 @@ function evaluateSeed(): string { return expressionAt(ed.view, sel.head)?.text ?? ""; } +/** + * Remote JVM Debug in listen mode: the IDE listens and the remote JVM connects; java-debug + * then attaches through a loopback relay. Null when cancelled. + */ +async function listenForJvm(name: string, host: string, port: number): Promise { + const request = nextRequestId(); + const key = `debug.listen.${request}`; + notifications().notify({ + group: "run", + level: "info", + key, + sticky: true, + title: `'${name}': listening for the remote JVM on port ${port}`, + body: `Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address=${host}:${port},suspend=y`, + actions: [{ label: "Cancel", run: () => void commands.cancelRequest(request) }], + }); + try { + return await call(commands.debugListenForJvm(request, host, port)); + } catch (e) { + if (isCancelled(e)) return null; + throw e; + } finally { + notifications().expire?.(key); + } +} + +/** Remote JVM Debug, Go Remote, Python / Node.js remote: attach over host:port. */ +async function startRemote(config: RunConfig, root: string) { + if (config.kind === REMOTE_JVM) { + const t = remoteJvmTarget(config); + let hostName = t.host; + let port = t.port; + if (t.mode === "listen") { + const relay = await listenForJvm(config.name, t.host, t.port); + if (relay == null) return; + [hostName, port] = ["127.0.0.1", relay]; + } + const launch = await javaAttachConfig({ name: config.name, module: t.module, hostName, port }, root); + // A listen relay accepts one debugger: rerunning listens again through the configuration. + await startSession(launch, { rerunnable: t.mode === "attach" }); + return; + } + const launch = remoteAttachConfig(config, root); + if (launch) await startSession(launch); +} + async function startConfig(arg: unknown) { const root = useProject.getState().project?.root; if (!root) return; @@ -167,6 +214,10 @@ async function startConfig(arg: unknown) { } config = configs[0]!; } + if (isRemoteConfig(config)) { + await startRemote(config, root); + return; + } if (isJavaDebuggable(config)) { await maybeEditorService() ?.saveAll() diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 7eaa0bbb1..e7d27b8c8 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -8,7 +8,12 @@ import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { valueTooltip } from "./editor/valueTooltip"; import { registerEditorContribution } from "@/editor/api"; -import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { + registerMenuGroup, + registerOverlay, + registerRunConfigType, + registerToolWindow, +} from "@/workbench/contributions"; import { registerDebugActions } from "./actions"; import { loadBreakpoints } from "./breakpoints/store"; import { loadExceptionBreakpoints } from "./breakpoints/exceptions"; @@ -19,6 +24,7 @@ import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; +import { REMOTE_TYPES } from "./remote"; export function activate(): () => void { loadWatches(); @@ -28,6 +34,8 @@ export function activate(): () => void { loadSpecialBreakpoints(useProject.getState().project?.root ?? null); const disposers = [ registerDebugActions(), + // Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration. + ...REMOTE_TYPES.map(registerRunConfigType), registerToolWindow({ id: "debug", title: "Debug", diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts new file mode 100644 index 000000000..ee86cff10 --- /dev/null +++ b/src/features/debugger/remote.ts @@ -0,0 +1,316 @@ +/** + * Remote debug run configurations: the debugger attaches to a process started + * elsewhere (another machine, a container) over host:port, like IDEA's Remote JVM + * Debug, GoLand's Go Remote, PyCharm's remote debugpy and IDEA's Attach to Node.js. + * Only Debug applies to them (`debugOnly`); they launch no process. + */ +import type { LaunchConfig, RunConfig } from "@/platform/ipc"; +import { workflowSettings } from "@/platform/workflowSettings"; +import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions"; + +export const REMOTE_JVM = "remote-jvm"; +export const GO_REMOTE = "go-remote"; +export const PYTHON_REMOTE = "python-remote"; +export const NODE_REMOTE = "node-remote"; + +type Params = Readonly>; + +/** A TCP port, or null. */ +export function parsePort(value: string | undefined): number | null { + const v = (value ?? "").trim(); + if (!/^\d+$/.test(v)) return null; + const n = Number(v); + return n >= 1 && n <= 65535 ? n : null; +} + +const hostField: RunConfigTypeField = { key: "host", label: "Host", mono: true, required: true }; +const portField: RunConfigTypeField = { + key: "port", + label: "Port", + mono: true, + required: true, + validate: (v) => (parsePort(v) == null ? "Port must be a number from 1 to 65535" : null), +}; + +const hostOf = (p: Params) => p.host?.trim() || "localhost"; +const portOf = (p: Params, fallback: number) => parsePort(p.port) ?? fallback; + +// --------------------------------------------------------------------------- +// Remote JVM Debug + +export type JdwpMode = "attach" | "listen"; +export type JdkVersion = "9" | "5" | "1.4"; + +/** + * The agent the remote JVM is started with, as IDEA shows it ("Command line arguments + * for remote JVM"). Attach: the JVM listens (`server=y`); JDK 9+ binds only to localhost + * unless the address names a host, hence `*:`. Listen: the JVM connects to the IDE. + */ +export function jdwpAgentArgs(p: Params): string { + const mode: JdwpMode = p.mode === "listen" ? "listen" : "attach"; + const jdk = (p.jdk || "9") as JdkVersion; + const port = portOf(p, 5005); + const opts = + mode === "listen" + ? `transport=dt_socket,server=n,address=${hostOf(p)}:${port},suspend=y` + : `transport=dt_socket,server=y,suspend=n,address=${jdk === "9" ? "*:" : ""}${port}`; + return jdk === "1.4" ? `-Xdebug -Xrunjdwp:${opts}` : `-agentlib:jdwp=${opts}`; +} + +const JVM_KINDS = new Set(["application", "spring-boot", "maven", "gradle", "java-file"]); + +export const remoteJvmType: RunConfigTypeContribution = { + id: REMOTE_JVM, + label: "Remote JVM Debug", + icon: "attach-to-process", + description: "Attach the debugger to a JVM started with a JDWP agent, here or on another machine", + defaults: { mode: "attach", host: "localhost", port: "5005", jdk: "9" }, + debugOnly: true, + debugAdapter: "java-debug", + fields: [ + { + key: "mode", + label: "Debugger mode", + control: "select", + options: [ + { value: "attach", label: "Attach to remote JVM" }, + { value: "listen", label: "Listen to remote JVM" }, + ], + }, + hostField, + portField, + { + key: "jdk", + label: "JDK version of the remote JVM", + control: "select", + options: [ + { value: "9", label: "JDK 9 or later" }, + { value: "5", label: "JDK 5 - 8" }, + { value: "1.4", label: "JDK 1.4.x" }, + ], + }, + { + key: "args", + label: "Command line arguments for remote JVM", + control: "output", + mono: true, + value: jdwpAgentArgs, + hint: "Add to the remote JVM's command line (or JAVA_TOOL_OPTIONS)", + }, + { + key: "module", + label: "Use module classpath", + mono: true, + placeholder: "Project root", + hint: "Module directory whose sources the debugger shows", + suggestions: (configs) => [...new Set(configs.filter((c) => JVM_KINDS.has(c.kind) && c.cwd).map((c) => c.cwd!))], + }, + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Go Remote (Delve headless server) + +/** How to start the remote side, as GoLand shows it. */ +export function delveServerCommand(p: Params): string { + return `dlv debug --headless --listen=:${portOf(p, 2345)} --api-version=2 --accept-multiclient`; +} + +const localRootField: RunConfigTypeField = { + key: "localRoot", + label: "Local root", + mono: true, + placeholder: "Project root", +}; + +const remoteRootField = (placeholder: string): RunConfigTypeField => ({ + key: "remoteRoot", + label: "Remote root", + mono: true, + placeholder, + hint: "Where the project's sources are on the remote machine, when the paths differ", +}); + +export const goRemoteType: RunConfigTypeContribution = { + id: GO_REMOTE, + label: "Go Remote", + icon: "file-go", + description: "Attach to a Delve server (dlv --headless) here or on another machine", + defaults: { host: "localhost", port: "2345" }, + debugOnly: true, + debugAdapter: "delve", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: delveServerCommand, + hint: "Delve 1.7.3 or later; dlv exec ./app or dlv attach take the same flags", + }, + localRootField, + remoteRootField("e.g. /go/src/app"), + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Python remote (debugpy --listen) + +export function debugpyServerCommand(p: Params): string { + return `python -m debugpy --listen 0.0.0.0:${portOf(p, 5678)} --wait-for-client app.py`; +} + +export const pythonRemoteType: RunConfigTypeContribution = { + id: PYTHON_REMOTE, + label: "Python Remote Debug (debugpy)", + icon: "file-python", + description: "Attach to a Python process listening with debugpy here or on another machine", + defaults: { host: "localhost", port: "5678" }, + debugOnly: true, + debugAdapter: "debugpy", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: debugpyServerCommand, + hint: "pip install debugpy on the remote machine", + }, + localRootField, + remoteRootField("e.g. /app"), + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Attach to Node.js (inspector) + +export function nodeInspectCommand(p: Params): string { + return `node --inspect=0.0.0.0:${portOf(p, 9229)} app.js`; +} + +export const nodeRemoteType: RunConfigTypeContribution = { + id: NODE_REMOTE, + label: "Attach to Node.js", + icon: "file-js", + description: "Attach to a Node.js process started with --inspect here or on another machine", + defaults: { host: "localhost", port: "9229" }, + debugOnly: true, + debugAdapter: "js-debug", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: nodeInspectCommand, + }, + localRootField, + remoteRootField("e.g. /usr/src/app"), + ], + commandLine: () => null, +}; + +export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [ + remoteJvmType, + goRemoteType, + pythonRemoteType, + nodeRemoteType, +]; + +export const isRemoteConfig = (c: Pick) => REMOTE_TYPES.some((t) => t.id === c.kind); + +// --------------------------------------------------------------------------- +// Attach requests + +function isAbsolute(p: string) { + return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); +} + +function join(base: string, p: string) { + if (isAbsolute(p)) return p; + const rel = p.replace(/^\.\//, ""); + return rel === "." || rel === "" ? base : `${base.replace(/[\\/]$/, "")}/${rel}`; +} + +/** Local root (absolute) ↔ remote root, or null when the paths are the same on both sides. */ +function rootMapping(p: Params, root: string): { local: string; remote: string } | null { + const remote = p.remoteRoot?.trim(); + if (!remote) return null; + const local = p.localRoot?.trim() ? join(root, p.localRoot.trim()) : root; + return { local, remote }; +} + +/** `host:port` for `debugServer` (IPv6 literals in brackets). */ +export function serverAddress(host: string, port: number): string { + return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; +} + +function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record): LaunchConfig { + return { + name: rc.name, + adapter, + request: "attach", + program: null, + args: [], + cwd: root, + env: {}, + extraJson: JSON.stringify(extra), + }; +} + +/** + * The attach request of a Go / Python / Node.js remote configuration. Delve's headless + * server and `debugpy --listen` speak DAP themselves (`debugServer` connects there); + * js-debug runs here and connects to the inspector. + */ +export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null { + const p = rc.params ?? {}; + const host = hostOf(p); + const map = rootMapping(p, root); + const skip = workflowSettings().debugger.skipLibraryCode; + switch (rc.kind) { + case GO_REMOTE: + return attachBase(rc, "delve", root, { + debugServer: serverAddress(host, portOf(p, 2345)), + mode: "remote", + ...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}), + }); + case PYTHON_REMOTE: + return attachBase(rc, "debugpy", root, { + debugServer: serverAddress(host, portOf(p, 5678)), + justMyCode: skip, + ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), + }); + case NODE_REMOTE: + return attachBase(rc, "js-debug", root, { + type: "pwa-node", + address: host, + port: portOf(p, 9229), + ...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}), + skipFiles: skip ? ["/**", "**/node_modules/**"] : [], + }); + default: + return null; + } +} + +/** What a Remote JVM Debug configuration attaches to (before the listen mode relay). */ +export function remoteJvmTarget(rc: RunConfig): { mode: JdwpMode; host: string; port: number; module: string } { + const p = rc.params ?? {}; + return { + mode: p.mode === "listen" ? "listen" : "attach", + host: hostOf(p), + port: portOf(p, 5005), + module: p.module?.trim() ?? "", + }; +} diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index e24b20666..1b0d4698a 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -170,6 +170,8 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi } const compound = config.kind === COMPOUND; + // Compound and debug-only types (Remote JVM Debug) start no process of their own. + const noProcess = compound || !!type?.debugOnly; const params = compound ? {} : formParams(config, type); const update = (patch: Partial) => onChange({ ...config, ...patch }); const setParam = (key: string, value: string) => { @@ -199,7 +201,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi // A terminal has no separate input file: the two options exclude each other. const blocked = (id: OptionId) => (id === "emulateTerminal" && isShown("stdinFile")) || (id === "stdinFile" && !!config.emulateTerminal); - const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !compound || !g.process).flatMap((g): MenuItem[] => [ + const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !noProcess || !g.process).flatMap((g): MenuItem[] => [ { kind: "header", label: g.label }, ...g.options .filter( @@ -269,7 +271,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi "isTest", ] as const ).filter( - (id) => (!compound || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault), + (id) => (!noProcess || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault), ); return ( @@ -332,7 +334,13 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi type.fields .filter((f) => f.visible?.(params) ?? true) .map((f) => ( - + )} - {!compound && ( + {!noProcess && ( <> {!type?.ownsWorkingDirectory && ( @@ -545,7 +553,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi )} - {!compound && !template && ( + {!noProcess && !template && (
Command line @@ -586,6 +594,7 @@ function TypeField({ }) { const tx = useTx(); const listId = `run-field-${field.key}`; + if (field.control === "output") return ; if (field.control === "select") { const options = [...(field.options ?? [])]; if (value && !options.some((o) => o.value === value)) options.push({ value, label: value }); @@ -635,6 +644,39 @@ function TypeField({ ); } +/** Read-only text the form computes (e.g. the JDWP agent for the remote JVM), with Copy. */ +function OutputField({ label, text }: { label: string; text: string }) { + const tx = useTx(); + const [copied, setCopied] = useState(false); + const copy = () => { + void navigator.clipboard + ?.writeText(text) + .then(() => setCopied(true)) + .catch(() => undefined); + }; + return ( +
+