diff --git a/crates/ide-dap/tests/php_debug.rs b/crates/ide-dap/tests/php_debug.rs new file mode 100644 index 00000000..8493760b --- /dev/null +++ b/crates/ide-dap/tests/php_debug.rs @@ -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= 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 = ">, 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>> = 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()); +} diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts index d70ff6fb..71e1c18d 100644 --- a/src/features/debugger/__tests__/remote.test.ts +++ b/src/features/debugger/__tests__/remote.test.ts @@ -35,6 +35,7 @@ const { xdebugCommand, remoteAttachConfig, remoteJvmTarget, + remoteWaitingNote, } = remote; function configOf(type: (typeof REMOTE_TYPES)[number], params: Record = {}): 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", diff --git a/src/features/debugger/__tests__/startFailure.test.ts b/src/features/debugger/__tests__/startFailure.test.ts new file mode 100644 index 00000000..43ac10ad --- /dev/null +++ b/src/features/debugger/__tests__/startFailure.test.ts @@ -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(); + 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 }), + ); + }); +}); diff --git a/src/features/debugger/__tests__/toolWindow.test.tsx b/src/features/debugger/__tests__/toolWindow.test.tsx index 405badef..5efc9ae2 100644 --- a/src/features/debugger/__tests__/toolWindow.test.tsx +++ b/src/features/debugger/__tests__/toolWindow.test.tsx @@ -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(); + 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(); + 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(); diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts index 4dc7a6b5..5a990895 100644 --- a/src/features/debugger/remote.ts +++ b/src/features/debugger/remote.ts @@ -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 | 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 ?? {}; diff --git a/src/features/debugger/startFailure.ts b/src/features/debugger/startFailure.ts new file mode 100644 index 00000000..497bb94b --- /dev/null +++ b/src/features/debugger/startFailure.ts @@ -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; +} diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index c77fe0fc..0582c0d1 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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; diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index e457f0fd..3faa48e2 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -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 && ( -
{session.status === "terminated" ? tx("Finished") : tx("Running…")}
+
+ {session.status === "terminated" + ? tx("Finished") + : (remoteWaitingNote(rootOf(session).runConfig) ?? tx("Running…"))} +
)} {items.map((item) => item.kind === "hidden" ? ( diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index d00a285e..c9c2ef8f 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -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). */ diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index bdff433c..d6654072 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -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 () => { diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 6216e32f..2db89726 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -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. diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index df41de2f..67914402 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ec835dae..07e99227 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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.": "它将调试器附加到在别处启动的进程。", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 6ff3df72..3ec10d8e 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -86,7 +86,6 @@ const BUILT_AT_RUNTIME = new Set([]); const UNTRANSLATED = new Set([ "dotnet test", "netcoredbg (.NET)", - "php-debug (PHP)", // Product names "Cargo", "CMake",