feat(debugger): Remote debug run configurations (Remote JVM, Go, Python, Node.js) (#50)

Root cause: run configurations could only launch a process; there was no "attach to a debugger that is already listening" type, and debugServer only connected to 127.0.0.1.
Fix (IDEA / GoLand / PyCharm): debug-only Remote JVM Debug (attach / listen, live JDWP agent args per JDK version), Go Remote (dlv --headless, substitutePath), Python Remote Debug (debugpy --listen, pathMappings) and Attach to Node.js (inspector, localRoot/remoteRoot); debugServer accepts host:port and [v6]:port; JVM listen mode via a cancellable relay so java-debug can attach.
Verified: ide-dap tests (host:port, relay handshake, cancel), run/debugger vitest, in-app on macOS: Python Remote Debug attached to a debugpy server, paused at app.py:3 with variables. Remote JVM/Go/Node not exercised in-app.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 10:38:38 +08:00
1 джерело 642b80cef0
коміт e150101244
20 files changed
+944 -36

No files matched your search

+159
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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<string, string> = {}): 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");
});
});
+52 -1
Переглянути файл
@@ -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<number | null> {
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()
+9 -1
Переглянути файл
@@ -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",
+316
Переглянути файл
@@ -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<Record<string, string>>;
/** 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 <pid> 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<RunConfig, "kind">) => 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<string, unknown>): 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_internals>/**", "**/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() ?? "",
};
}