fix(debugger): show the output of Maven, Gradle and script debugging in the Debug console (#2578)

Debugging a configuration the Run feature starts with a JDWP agent (Maven, Gradle, Spring Boot, Tomcat, scripts set to the Java debugger) attaches the debugger to that process, and its output stayed in the Run tab while the Debug console was empty. The run feature now provides its sessions' output by process (`RunOutputService`), and a debug session attached to one follows it in its Console until the session is closed.

Fixes #2494
This commit is contained in:
l0ng-ai
2026-11-11 02:14:27 +08:00
committed by GitHub
parent 835a9c883c
commit 1a6fc7fd21
8 changed files with 193 additions and 5 deletions
@@ -0,0 +1,73 @@
import { beforeEach, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import type { LaunchConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideRunOutput,
provideToolWindowService,
type ToolWindowService,
} from "@/workbench/contributions";
import { removeSession, 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 attach: LaunchConfig = {
name: "demo [spring-boot:run]",
adapter: "java-debug",
request: "attach",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ hostName: "127.0.0.1", port: 5005 }),
};
type Listener = (kind: "stdout" | "stderr" | "system", text: string) => void;
const listeners = new Map<number, Listener>();
const text = (session: number) => (useDebug.getState().console[session] ?? []).map((l) => l.text).join("");
beforeEach(() => {
useDebug.setState({ sessions: {}, active: null, console: {} });
useProject.setState({ project: { root: "/p" } as never });
provideNotificationService({ notify: vi.fn() });
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
listeners.clear();
provideRunOutput({
subscribe: (process, listener) => {
// The output so far comes first.
listener("system", "mvn spring-boot:run\n");
listener("stdout", "[INFO] Building demo\n");
listeners.set(process, listener);
return () => void listeners.delete(process);
},
});
ipc.debugStart.mockResolvedValue({ status: "ok", data: 21 });
});
it("shows the output of the Run process a debugger attached to in its Console", async () => {
expect(await startSession(attach, { process: 7, rerunnable: false })).toBe(21);
listeners.get(7)!("stdout", "Tomcat started on port 8080 (http)\n");
listeners.get(7)!("stderr", "WARN something\n");
await vi.waitFor(() => expect(text(21)).toContain("WARN something"));
expect(text(21)).toBe(
"mvn spring-boot:run\n[INFO] Building demo\nTomcat started on port 8080 (http)\nWARN something\n",
);
const categories = (useDebug.getState().console[21] ?? []).map((l) => l.category);
expect(categories).toEqual(["system", "stdout", "stdout", "stderr"]);
});
it("stops following the process once the session is closed", async () => {
await startSession(attach, { process: 7, rerunnable: false });
expect(listeners.has(7)).toBe(true);
removeSession(21);
expect(listeners.has(7)).toBe(false);
});
it("follows nothing for a session the debugger launched itself", async () => {
await startSession({ ...attach, request: "launch" });
expect(listeners.size).toBe(0);
});
+28 -2
View File
@@ -24,7 +24,13 @@ import {
type Variable,
} from "@/platform/ipc";
import { confirmTerminate } from "@/workbench/confirmTerminate";
import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions";
import {
maybeEditorService,
notifications,
runOutput,
toolWindowService,
useDebugRunSessions,
} from "@/workbench/contributions";
import { startedPort } from "@/platform/serverPort";
import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store";
import { interpolateLog, logParts, suspends } from "./breakpoints/model";
@@ -382,6 +388,21 @@ function republishForPorts() {
if (published.some((d) => d.httpPort !== sessionHttpPort(d.id))) publishContext();
}
/** Sessions attached to a process Run started: their Console follows its output. */
const processOutput = new Map<DebugSessionId, () => void>();
/** Shows the output of the Run process `process` in the Console of `session`, as a launched program's. */
function followProcessOutput(session: DebugSessionId, process: RunId) {
processOutput.get(session)?.();
const unsubscribe = runOutput()?.subscribe(process, (kind, text) => appendConsoleQueued(session, kind, text));
if (unsubscribe) processOutput.set(session, unsubscribe);
}
function unfollowProcessOutput(session: DebugSessionId) {
processOutput.get(session)?.();
processOutput.delete(session);
}
export function appendConsole(session: DebugSessionId, category: string, raw: string) {
if (!raw) return;
flushConsoleOutput();
@@ -615,6 +636,7 @@ export async function startSession(
};
});
settleStart(id);
if (options.process != null) followProcessOutput(id, options.process);
publishContext();
void refreshExceptionFilters();
void loadCapabilities(id);
@@ -739,7 +761,10 @@ export function removeSession(id: DebugSessionId) {
active: st.active != null && sessions[st.active] ? st.active : neighbour,
console,
});
for (const goneId of gone) owned.delete(goneId);
for (const goneId of gone) {
owned.delete(goneId);
unfollowProcessOutput(goneId);
}
updateExecutionPoint();
publishContext();
}
@@ -1130,6 +1155,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
export function resetSessions() {
const { sessions } = useDebug.getState();
for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id);
for (const id of [...processOutput.keys()]) unfollowProcessOutput(id);
pendingOutput.length = 0;
cancelOutputFlush();
ansiStreams.clear();
+5
View File
@@ -125,6 +125,11 @@ export class ConsoleModel {
if (line) this.onLine?.(line.text, line.kind);
}
/** The buffered output as plain-text chunks (the line still being written without a line break). */
textChunks(): OutputChunk[] {
return this.lines.map((l) => ({ kind: l.kind, text: l === this.open ? l.text : `${l.text}\n` }));
}
/** Completes a trailing partial line (at process exit). */
flush() {
if (this.open) this.closeLine();
+4
View File
@@ -13,6 +13,7 @@ import {
menuGroupItems,
useDebugRunSessions,
notifications,
provideRunOutput,
registerMenuGroup,
registerOverlay,
registerRunAnythingProvider,
@@ -80,6 +81,7 @@ import {
startRun,
startSampleOutput,
stopSession,
subscribeOutput,
trackRecentConfigurations,
useRun,
isLive,
@@ -727,6 +729,8 @@ export function activate(): () => void {
setBeforeLaunchRunner(runBeforeLaunch);
disposers.push(() => setBeforeLaunchRunner(null));
provideRunOutput({ subscribe: subscribeOutput });
disposers.push(() => provideRunOutput(undefined));
if (hasProject()) void loadConfigs();
// Reload when run.toml or build files change on disk (cheap; detection is fast).
+2 -1
View File
@@ -3,7 +3,8 @@
* build tool starts the JVM with a JDWP agent that waits for a debugger
* (`spring-boot:run` → `-Dspring-boot.run.jvmArguments`, Surefire →
* `-Dmaven.surefire.debug`, Gradle → `--debug-jvm`); when the agent reports
* its port, the Java debugger attaches. Output and the test tree stay in the Run tab.
* its port, the Java debugger attaches. The test tree stays in the Run tab; the
* Debug console shows the output as well.
* Maven goals that run the application inside Maven's JVM (`tomcat7:run`, `jetty:run`,
* `exec:java`) get the agent through `MAVEN_OPTS`; any other configuration set to the Java
* debugger (a script such as `catalina.sh run`) through `JAVA_TOOL_OPTIONS`, which every JVM reads.
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startSampleOutput, subscribeOutput, useRun } from "./store";
describe("run output for other views", () => {
afterEach(() => vi.useRealTimers());
it("gives a subscriber the output so far, then each new chunk until it unsubscribes", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame", "performance"],
});
const done = startSampleOutput(40, 10);
await vi.advanceTimersByTimeAsync(40);
const session = useRun.getState().sessions.at(-1)!;
// The sample has no process; give it one, as a started run gets.
useRun.setState((s) => ({ sessions: s.sessions.map((x) => (x.id === session.id ? { ...x, runId: 7 } : x)) }));
const seen: string[] = [];
const unsubscribe = subscribeOutput(7, (kind, text) => seen.push(`${kind}:${text}`));
const before = seen.length;
expect(before).toBeGreaterThan(0);
expect(seen.every((c) => c.endsWith("\n"))).toBe(true);
await vi.advanceTimersByTimeAsync(100);
expect(seen.length).toBeGreaterThan(before);
unsubscribe();
const after = seen.length;
await vi.advanceTimersByTimeAsync(2_000);
await done;
expect(seen.length).toBe(after);
// Every line arrived once: the snapshot and the stream don't overlap.
const lines = seen.join("").split("\n").filter(Boolean);
expect(new Set(lines).size).toBe(lines.length);
});
it("gives nothing for a process no session owns", () => {
const seen: string[] = [];
subscribeOutput(12345, (_, text) => seen.push(text))();
expect(seen).toEqual([]);
});
});
+23 -1
View File
@@ -116,6 +116,8 @@ interface SessionRuntime {
generation: number;
/** Called with every complete output line (e.g. waiting for a JDWP agent). */
lineListeners: Array<(text: string) => void>;
/** Called with output as shown (the Debug console of a debugger attached to this process). */
outputListeners: Set<OutputListener>;
/** Called once the process exited (run executors: collect a coverage report…). */
onExit?: (exitCode: number | null, stopped: boolean) => void;
/** User input typed into the console, not yet sent (IDEA: Enter sends the line). */
@@ -542,7 +544,9 @@ function flush(sessionId: string) {
const chunks = rt.queue.splice(0);
for (const c of chunks) {
const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text;
if (text) rt.console.append({ ...c, text });
if (!text) continue;
rt.console.append({ ...c, text });
for (const l of rt.outputListeners) l(c.kind, text);
}
bump(sessionId);
}
@@ -552,6 +556,7 @@ function enqueue(sessionId: string, chunk: OutputChunk) {
if (!rt) return;
if (rt.terminal) {
rt.terminal.append(chunk);
for (const l of rt.outputListeners) l(chunk.kind, chunk.text);
return;
}
rt.queue.push(chunk);
@@ -664,6 +669,7 @@ function newRuntime(config: RunConfig): SessionRuntime {
flushedAt: -Infinity,
generation: 0,
lineListeners: [],
outputListeners: new Set(),
input: "",
terminal: null,
terminalSize: null,
@@ -1159,6 +1165,22 @@ function notifyExit(sessionId: string, code: number | null) {
});
}
type OutputListener = (kind: OutputChunk["kind"], text: string) => void;
/**
* Follows the output of the session running `process`: the output so far, then
* each chunk as it is shown, until the returned function is called.
*/
export function subscribeOutput(process: RunId, listener: OutputListener): () => void {
const session = useRun.getState().sessions.find((s) => s.runId === process);
const rt = session && runtimes.get(session.id);
if (!rt) return () => {};
flush(session.id);
if (!rt.terminal) for (const c of rt.console.textChunks()) listener(c.kind, c.text);
rt.outputListeners.add(listener);
return () => void rt.outputListeners.delete(listener);
}
/** Handles the `runExited` backend event. */
export function onRunExited(runId: RunId, code: number | null) {
const session = useRun.getState().sessions.find((s) => s.runId === runId);
+18 -1
View File
@@ -17,7 +17,7 @@ import {
type ReactNode,
type RefObject,
} from "react";
import type { CommitSelection, FileMatches, SearchQuery, RunConfig } from "@/bindings";
import type { CommitSelection, FileMatches, SearchQuery, RunConfig, RunId } from "@/bindings";
import type { MenuItem } from "@/ui/Menu";
import type { DragData } from "@/ui/pointerDrag";
import type { TreeReorder } from "@/ui/Tree";
@@ -677,11 +677,25 @@ export type EmbeddedTerminalFactory = (
options: EmbeddedTerminalOptions,
) => Promise<EmbeddedTerminal>;
/**
* The output of Run sessions, by process: lets the Debug console of a debugger
* attached to a process the Run feature started (Maven, Gradle, Spring Boot…)
* show that process's output. Provided by the run feature.
*/
export interface RunOutputService {
/**
* Calls `listener` with the output so far, then with each new chunk, until the
* returned function is called. A process no Run session owns gets nothing.
*/
subscribe(process: RunId, listener: (kind: "stdout" | "stderr" | "system", text: string) => void): () => void;
}
const services: {
editor?: EditorService;
toolWindows?: ToolWindowService;
notifications?: NotificationService;
embeddedTerminal?: EmbeddedTerminalFactory;
runOutput?: RunOutputService;
} = {};
function need<T>(s: T | undefined, name: string): T {
@@ -693,6 +707,7 @@ export const provideEditorService = (s: EditorService) => void (services.editor
export const provideToolWindowService = (s: ToolWindowService) => void (services.toolWindows = s);
export const provideNotificationService = (s: NotificationService) => void (services.notifications = s);
export const provideEmbeddedTerminal = (f: EmbeddedTerminalFactory | undefined) => void (services.embeddedTerminal = f);
export const provideRunOutput = (s: RunOutputService | undefined) => void (services.runOutput = s);
export const editorService = (): EditorService => need(services.editor, "editor");
export const toolWindowService = (): ToolWindowService => need(services.toolWindows, "toolWindows");
@@ -702,6 +717,8 @@ export const notifications = (): NotificationService =>
export const maybeEditorService = (): EditorService | undefined => services.editor;
/** Creates terminal widgets; provided by the terminal feature. */
export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal;
/** Run output by process; provided by the run feature. */
export const runOutput = (): RunOutputService | undefined => services.runOutput;
/**
* The commit being prepared (the Commit tool window or dialog), for plugins: