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>
This commit is contained in:
Daniel Adámek
2026-11-06 06:56:51 +08:00
committed by GitHub
co-authored by DanielADK
parent 1f76e71621
commit b450f4028b
14 changed files with 292 additions and 8 deletions
+140
View File
@@ -0,0 +1,140 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
@@ -35,6 +35,7 @@ const {
xdebugCommand,
remoteAttachConfig,
remoteJvmTarget,
remoteWaitingNote,
} = remote;
function configOf(type: (typeof REMOTE_TYPES)[number], params: Record<string, string> = {}): RunConfig {
@@ -180,6 +181,25 @@ describe("remote attach requests", () => {
expect(plain).toEqual({ port: 9003 });
});
it.each([
[{ remoteRoot: "/app", localRoot: "web" }, { "/app": "/p/web" }],
[{ remoteRoot: "/app", localRoot: "/srv/site" }, { "/app": "/srv/site" }],
])("PHP Remote Debug maps the server path %j to the local root", (params, pathMappings) => {
const l = remoteAttachConfig(configOf(phpRemoteType, params), "/p")!;
expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings });
expect(l).toMatchObject({ args: [], env: {} });
});
it("PHP Remote Debug keeps its form when Port, its only value, is cleared", () => {
const cleared = withParams(configOf(phpRemoteType), {}, phpRemoteType);
expect(missingParams(cleared, phpRemoteType)).toEqual({ port: "Port is required" });
});
it("PHP Remote Debug says which port it waits on; the attaching types don't", () => {
expect(remoteWaitingNote(configOf(phpRemoteType, { port: "9010" }))).toBe("Listening for Xdebug on port 9010");
for (const t of REMOTE_TYPES.filter((x) => x !== phpRemoteType)) expect(remoteWaitingNote(configOf(t))).toBeNull();
});
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",
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { IpcError, type LaunchConfig } from "@/platform/ipc";
import { provideNotificationService } from "@/workbench/contributions";
import { startFailureMessage } from "../startFailure";
import { startSession, useDebug } from "../state";
const ipc = vi.hoisted(() => ({ debugStart: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const PORT_IN_USE =
"Port 9003 is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.";
describe("debug start failures", () => {
it.each([
"launch failed: listen EADDRINUSE: address already in use :::9003",
"launch failed: listen EADDRINUSE: address already in use 127.0.0.1:9003",
])("names the port another listener holds: %s", (message) => {
expect(startFailureMessage(new IpcError({ kind: "invalidInput", message }))).toBe(PORT_IN_USE);
});
it("keeps other failures as they are", () => {
expect(startFailureMessage(new Error("launch failed: Attach requests are not supported"))).toBe(
"launch failed: Attach requests are not supported",
);
expect(startFailureMessage("debug adapter exited")).toBe("debug adapter exited");
});
it("are what the notification of a failed start says", async () => {
const notify = vi.fn();
provideNotificationService({ notify });
useDebug.setState({ sessions: {}, active: null, console: {} });
const message = "launch failed: listen EADDRINUSE: address already in use :::9003";
ipc.debugStart.mockResolvedValueOnce({ status: "error", error: { kind: "invalidInput", message, data: null } });
const listen: LaunchConfig = {
name: "Listen for Xdebug",
adapter: "php-debug",
request: "launch",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ port: 9003 }),
};
expect(await startSession(listen)).toBeNull();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Cannot start debugger for Listen for Xdebug", body: PORT_IN_USE }),
);
});
});
@@ -28,6 +28,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
const confirmTerminate = vi.hoisted(() => vi.fn(async () => false));
vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
import type { RunConfig } from "@/platform/ipc";
import { useDebug, type DebugSession } from "../state";
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
@@ -63,6 +64,33 @@ describe("debug tool window", () => {
expect(hot()).toEqual([true, true]);
});
it("says a PHP Remote Debug session listens for Xdebug while nothing is suspended", () => {
const runConfig = {
id: "php-remote:web",
name: "web",
kind: "php-remote",
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: "php-debug",
params: { port: "9010" },
} as RunConfig;
const waiting = { ...session, adapter: "php-debug", status: "running" as const, threads: [], frames: [] };
useDebug.setState({ sessions: { 1: { ...waiting, runConfig } }, active: 1 });
const { unmount } = render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Listening for Xdebug on port 9010")).toBeTruthy();
expect(screen.queryByText("Running…")).toBeNull();
unmount();
useDebug.setState({ sessions: { 1: waiting }, active: 1 });
render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Running…")).toBeTruthy();
});
it("shows preparation output, preserves a chosen tab and opens variables on pause", () => {
useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 });
render(<DebugToolWindow />);
+7
View File
@@ -5,6 +5,7 @@
* 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";
@@ -383,6 +384,12 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
}
}
/** 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 ?? {};
+16
View File
@@ -0,0 +1,16 @@
/**
* Why a debug session didn't start, as the user reads it. An adapter that listens for
* the debuggee (php-debug waiting for Xdebug) fails with Node's
* `listen EADDRINUSE: address already in use :::9003` when another IDE holds the port.
*/
import { tx } from "@/platform/i18n";
const PORT_IN_USE = /\bEADDRINUSE\b.*:(\d+)\s*$/;
export function startFailureMessage(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
const port = PORT_IN_USE.exec(message)?.[1];
return port
? tx`Port ${port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.`
: message;
}
+2 -1
View File
@@ -30,6 +30,7 @@ import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
import { missingAdapter } from "./missingAdapter";
import { startFailureMessage } from "./startFailure";
export type SessionStatus = "starting" | "running" | "paused" | "terminated";
@@ -607,7 +608,7 @@ export async function startSession(
group: "run",
level: "error",
title: `Cannot start debugger for ${launch.name}`,
body: e instanceof Error ? e.message : String(e),
body: startFailureMessage(e),
key: "debug.start",
});
return null;
+6 -1
View File
@@ -37,6 +37,7 @@ import {
useDebug,
type DebugSession,
} from "../state";
import { remoteWaitingNote } from "../remote";
import { DebugConsole } from "./DebugConsole";
import { VariablesView, type VarRoot } from "./VariablesView";
import { useExpressionCompletion } from "./ExpressionCompletion";
@@ -304,7 +305,11 @@ function FramesPane({ session }: { session: DebugSession }) {
onBlur={() => blurDebugView("frames")}
>
{!paused && (
<div className={styles.note}>{session.status === "terminated" ? tx("Finished") : tx("Running…")}</div>
<div className={styles.note}>
{session.status === "terminated"
? tx("Finished")
: (remoteWaitingNote(rootOf(session).runConfig) ?? tx("Running…"))}
</div>
)}
{items.map((item) =>
item.kind === "hidden" ? (
+2 -3
View File
@@ -49,8 +49,8 @@ import { TomcatSettings } from "./TomcatSettings";
import styles from "./Run.module.css";
/**
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`,
* `debugger/remote.ts`), verified against small programs. Others (dart, …) only got
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`),
* verified against small programs. Others (dart, php-debug, …) only got
* generic launch arguments and are left out until they have one; a configuration that
* already names one keeps it.
*/
@@ -63,7 +63,6 @@ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "delve", label: "Delve (Go)" },
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
{ id: "php-debug", label: "php-debug (PHP)" },
];
/** Options "Modify options" adds to the form: value fields (shown once added) and flags (chips). */
+10 -1
View File
@@ -676,7 +676,12 @@ describe("debug-only types (Remote JVM Debug)", () => {
debugOnly: true,
debugAdapter: "java-debug",
fields: [
{ key: "port", label: "Port", validate: (v) => (/^\d+$/.test(v) ? null : "Port must be a number") },
{
key: "port",
label: "Port",
required: true,
validate: (v) => (/^\d+$/.test(v) ? null : "Port must be a number"),
},
{ key: "agent", label: "Agent", control: "output", value: (p) => `address=*:${p.port ?? ""}` },
],
commandLine: () => null,
@@ -703,6 +708,10 @@ describe("debug-only types (Remote JVM Debug)", () => {
expect(screen.queryByText("Command line")).toBeNull();
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "x" } });
expect(screen.getByRole("alert").textContent).toBe("Port must be a number");
// Emptying the only field keeps the form: there is no command line to edit instead.
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "" } });
expect(screen.getByRole("alert").textContent).toBe("Port is required");
expect(screen.queryByLabelText("Command")).toBeNull();
});
it("are valid without a command, get the type's adapter and are not run", async () => {
+2
View File
@@ -473,6 +473,8 @@ export function formParams(config: RunConfig, type = typeFor(config.kind)): Reco
if (config.params && Object.keys(config.params).length && (type.id !== "go" || config.params.goKind))
return { ...config.params };
if (type.id === COMPOUND) return {};
// No command line to fall back to: the form stays, and an emptied field shows as required.
if (type.debugOnly) return { ...config.params };
const derived = type.paramsFrom?.(config);
if (!derived) return null;
// Only when the form reproduces exactly what runs today.
+4 -1
View File
@@ -33,5 +33,8 @@
"{removed} lines deleted": "{removed} lines deleted",
"PHP Remote Debug (Xdebug)": "PHP Remote Debug (Xdebug)",
"Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)": "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)",
"For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container"
"For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
"e.g. /var/www/html": "e.g. /var/www/html",
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port."
}
+3
View File
@@ -3656,6 +3656,9 @@
"PHP Remote Debug (Xdebug)": "PHP 远程调试 (Xdebug)",
"Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)": "监听 Xdebug 连接:调试在其他地方(php-fpm、容器中)提供服务的 PHP 请求",
"For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "对于 Web 请求:设置 XDEBUG_SESSION cookie 或查询参数;当 PHP 运行在容器中时,将 xdebug.client_host 指向本机",
"e.g. /var/www/html": "例如 /var/www/html",
"Listening for Xdebug on port {port}": "正在端口 {port} 上监听 Xdebug",
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "端口 {port} 已被占用:可能有其他 IDE 或调试会话正在监听。请停止它或选择其他端口。",
"Copied": "已复制",
"'{name}' can only be debugged": "'{name}' 只能调试",
"It attaches the debugger to a process started elsewhere.": "它将调试器附加到在别处启动的进程。",
-1
View File
@@ -86,7 +86,6 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
const UNTRANSLATED = new Set<string>([
"dotnet test",
"netcoredbg (.NET)",
"php-debug (PHP)",
// Product names
"Cargo",
"CMake",