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 <[email protected]>
Цей коміт міститься в:
Daniel AdámekтаDanielADK authored and GitHub committed 2026-11-06 06:56:51 +08:00
1 джерело 1f76e71621
коміт b450f4028b
14 files changed
+292 -8

No files matched your search

+20
Переглянути файл
@@ -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",
+52
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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" ? (