Files
openidea/src/features/debugger/remote.ts
T
Daniel AdámekandDanielADK b450f4028b fix(debugger): finish PHP Remote Debug: taken port, waiting state, Port field (#1639)
A taken port now says which port is in use instead of Node's EADDRINUSE text; a listening PHP Remote Debug session shows "Listening for Xdebug on port N" instead of "Running…"; clearing the only field of a debug-only remote type keeps the form and shows the field as required; php-debug leaves the generic Debug adapter list until it has a launch template (#1623).

Co-authored-by: DanielADK <18214958+DanielADK@users.noreply.github.com>
2026-11-06 06:56:51 +08:00

403 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
* PHP Remote Debug is the exception: its php-debug listens and Xdebug connects to it.
* Only Debug applies to them (`debugOnly`); they launch no process.
*/
import { tx } from "@/platform/i18n";
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";
export const GODOT_ATTACH = "godot-attach";
export const PHP_REMOTE = "php-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,
};
// ---------------------------------------------------------------------------
// PHP Remote Debug (Xdebug): php-debug listens, the remote PHP connects to it
/** How to start the remote side, so Xdebug connects back to the listening php-debug. */
export function xdebugCommand(p: Params): string {
return `php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=${portOf(p, 9003)} script.php`;
}
export const phpRemoteType: RunConfigTypeContribution = {
id: PHP_REMOTE,
label: "PHP Remote Debug (Xdebug)",
icon: "file-php",
description: "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)",
defaults: { port: "9003" },
debugOnly: true,
debugAdapter: "php-debug",
fields: [
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: xdebugCommand,
hint: "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
},
localRootField,
remoteRootField("e.g. /var/www/html"),
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
export const godotAttachType: RunConfigTypeContribution = {
id: GODOT_ATTACH,
label: "Attach to Godot",
icon: "file-gdscript",
description: "Debug the game the Godot editor is running, through the editor's debug adapter",
defaults: { host: "localhost", port: "6006" },
debugOnly: true,
debugAdapter: "godot",
fields: [
hostField,
portField,
{
key: "hint",
label: "Before attaching",
control: "output",
value: () => "Open the project in the Godot editor and run a scene (F5 / F6)",
},
],
commandLine: () => null,
};
export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
remoteJvmType,
goRemoteType,
pythonRemoteType,
nodeRemoteType,
phpRemoteType,
godotAttachType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
// ---------------------------------------------------------------------------
// Attach / listen 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 requestBase(
rc: RunConfig,
adapter: string,
root: string,
request: "attach" | "launch",
extra: Record<string, unknown>,
): LaunchConfig {
return {
name: rc.name,
adapter,
request,
program: null,
args: [],
cwd: root,
env: {},
extraJson: JSON.stringify(extra),
};
}
/**
* The request of a Go / Python / Node.js / PHP remote configuration. Delve's headless
* server and `debugpy --listen` speak DAP themselves (`debugServer` connects there);
* js-debug runs here and connects to the inspector. PHP Remote Debug is the one
* `launch`: php-debug is the side that listens, and each Xdebug connection becomes
* a thread, so nothing attaches and no program is named.
*/
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 requestBase(rc, "delve", root, "attach", {
debugServer: serverAddress(host, portOf(p, 2345)),
mode: "remote",
...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}),
});
case PYTHON_REMOTE:
return requestBase(rc, "debugpy", root, "attach", {
debugServer: serverAddress(host, portOf(p, 5678)),
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case GODOT_ATTACH:
return requestBase(rc, "godot", root, "attach", {
debugServer: serverAddress(host, portOf(p, 6006)),
project: root,
});
case NODE_REMOTE:
return requestBase(rc, "js-debug", root, "attach", {
type: "pwa-node",
address: host,
port: portOf(p, 9229),
...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}),
skipFiles: skip ? ["<node_internals>/**", "**/node_modules/**"] : [],
});
case PHP_REMOTE:
return requestBase(rc, "php-debug", root, "launch", {
port: portOf(p, 9003),
...(map ? { pathMappings: { [map.remote]: map.local } } : {}),
});
default:
return null;
}
}
/** What a session of `rc` shows while nothing is suspended, when it waits for the debuggee to connect. */
export function remoteWaitingNote(rc: Pick<RunConfig, "kind" | "params"> | undefined): string | null {
if (rc?.kind !== PHP_REMOTE) return null;
return tx`Listening for Xdebug on port ${portOf(rc.params ?? {}, 9003)}`;
}
/** 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() ?? "",
};
}