fix(debugger): Java source paths on Windows and breakpoints before session start (#387)

java-debug treated forward-slash Windows drive paths as URIs, so breakpoints never verified; source paths are now sent with native separators and mapped back for stack frames and breakpoint events. Debug sessions also flush every file's pending breakpoint updates before starting, so configurationDone never runs with an empty breakpoint snapshot (all adapters, launch and attach).

Not verified on a real Windows machine; the path conversion is covered by unit tests.

Fixes #380
Refs #374
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 17:46:58 +08:00
1 джерело 3655961642
коміт e017154c75
5 files changed
+244 -12

No files matched your search

+131
Переглянути файл
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { addBreakpoint, loadBreakpoints, removeAllBreakpoints, useBreakpoints } from "../breakpoints/store";
import { startSession, useDebug } from "../state";
import type { LaunchConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideToolWindowService,
type ToolWindowService,
} from "@/workbench/contributions";
const ipc = vi.hoisted(() => ({
debugSetBreakpoints: vi.fn(),
debugStart: vi.fn(async () => ({ status: "ok", data: 42 })),
debugCapabilities: vi.fn(async () => ({ status: "ok", data: {} })),
debugExceptionFilters: vi.fn(async () => ({ status: "ok", data: [] })),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const config = (request: string): LaunchConfig => ({
name: "Java",
adapter: "java-debug",
request,
program: null,
args: [],
cwd: null,
env: {},
extraJson: null,
});
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
localStorage.clear();
useBreakpoints.setState({ root: "/p", byFile: {}, muted: false, status: {} });
useDebug.setState({ sessions: {}, active: null, console: {} });
provideNotificationService({ notify: vi.fn() });
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
});
afterEach(() => vi.useRealTimers());
it.each(["launch", "attach"])("awaits every file before %s can send configurationDone", async (request) => {
const completed: string[] = [];
const release: Array<() => void> = [];
ipc.debugSetBreakpoints.mockImplementation(
(path: string) =>
new Promise((resolve) => {
release.push(() => {
completed.push(path);
resolve({ status: "ok", data: [] });
});
}),
);
addBreakpoint("/p/Main.java", 4);
addBreakpoint("/p/Other.java", 7);
const starting = startSession(config(request));
await vi.advanceTimersByTimeAsync(0);
expect(ipc.debugSetBreakpoints).toHaveBeenCalledTimes(2);
expect(ipc.debugStart).not.toHaveBeenCalled();
release[0]!();
await vi.advanceTimersByTimeAsync(0);
expect(ipc.debugStart).not.toHaveBeenCalled();
release[1]!();
await starting;
expect(completed).toEqual(["/p/Main.java", "/p/Other.java"]);
expect(ipc.debugStart).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(120);
expect(ipc.debugSetBreakpoints).toHaveBeenCalledTimes(2);
});
it("drains an in-flight update and sends pending removals before startup", async () => {
let release!: () => void;
ipc.debugSetBreakpoints
.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ status: "ok", data: [] });
}),
)
.mockResolvedValue({ status: "ok", data: [] });
addBreakpoint("/p/Main.java", 4);
await vi.advanceTimersByTimeAsync(120);
removeAllBreakpoints();
const starting = startSession(config("launch"));
await vi.advanceTimersByTimeAsync(0);
expect(ipc.debugStart).not.toHaveBeenCalled();
release();
await starting;
expect(ipc.debugSetBreakpoints).toHaveBeenLastCalledWith("/p/Main.java", []);
expect(ipc.debugStart).toHaveBeenCalledOnce();
});
it("flushes persisted breakpoints immediately after project loading", async () => {
ipc.debugSetBreakpoints.mockResolvedValue({ status: "ok", data: [] });
localStorage.setItem(
"openidea.debugger.breakpoints:/p",
JSON.stringify({ files: { "/p/Main.java": [{ line: 4 }] } }),
);
loadBreakpoints("/p");
await startSession(config("launch"));
expect(ipc.debugSetBreakpoints).toHaveBeenCalledOnce();
expect(ipc.debugSetBreakpoints.mock.invocationCallOrder[0]).toBeLessThan(ipc.debugStart.mock.invocationCallOrder[0]!);
});
it("flushes edits made while another file is still syncing", async () => {
let release!: () => void;
ipc.debugSetBreakpoints
.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ status: "ok", data: [] });
}),
)
.mockResolvedValue({ status: "ok", data: [] });
addBreakpoint("/p/Main.java", 4);
const starting = startSession(config("launch"));
await vi.advanceTimersByTimeAsync(0);
addBreakpoint("/p/Other.java", 7);
release();
await starting;
expect(ipc.debugSetBreakpoints).toHaveBeenCalledTimes(2);
expect(ipc.debugSetBreakpoints.mock.calls[1]![0]).toBe("/p/Other.java");
expect(ipc.debugSetBreakpoints.mock.invocationCallOrder[1]).toBeLessThan(ipc.debugStart.mock.invocationCallOrder[0]!);
});
it("does not start if the initial breakpoint sync fails", async () => {
ipc.debugSetBreakpoints.mockRejectedValueOnce(new Error("breakpoint sync failed"));
addBreakpoint("/p/Main.java", 4);
expect(await startSession(config("launch"))).toBeNull();
expect(ipc.debugStart).not.toHaveBeenCalled();
});
+28 -8
Переглянути файл
@@ -94,6 +94,7 @@ export function loadBreakpoints(root: string | null) {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
/** Ids in the order last sent per file (backend states are index-aligned). */
const sent = new Map<string, string[]>();
const inFlight = new Map<string, Promise<void>>();
const SYNC_DELAY = 120;
export function scheduleSync(path: string) {
@@ -102,12 +103,35 @@ export function scheduleSync(path: string) {
path,
setTimeout(() => {
timers.delete(path);
void syncFile(path);
void syncFile(path).catch((e) => console.warn("debugSetBreakpoints failed", e));
}, SYNC_DELAY),
);
}
export async function syncFile(path: string) {
/** Drain debounced edits before the backend takes its startup breakpoint snapshot. */
export async function flushBreakpoints() {
let paths = new Set([...timers.keys(), ...inFlight.keys(), ...Object.keys(useBreakpoints.getState().byFile)]);
while (paths.size) {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
await Promise.all([...paths].map((path) => syncFile(path)));
paths = new Set([...timers.keys(), ...inFlight.keys()]);
}
}
export function syncFile(path: string): Promise<void> {
const previous = inFlight.get(path) ?? Promise.resolve();
const next = previous.catch(() => {}).then(() => sendFile(path));
inFlight.set(path, next);
void next
.finally(() => {
if (inFlight.get(path) === next) inFlight.delete(path);
})
.catch(() => {});
return next;
}
async function sendFile(path: string) {
if (!isTauri()) return;
const { byFile, muted } = useBreakpoints.getState();
const list = byFile[path] ?? [];
@@ -115,12 +139,8 @@ export async function syncFile(path: string) {
path,
list.map((b) => b.id),
);
try {
const states = await call(commands.debugSetBreakpoints(path, toBackend(list, muted, path)));
applyStates(path, states);
} catch (e) {
console.warn("debugSetBreakpoints failed", e);
}
const states = await call(commands.debugSetBreakpoints(path, toBackend(list, muted, path)));
applyStates(path, states);
}
/** Re-reads verification states after a `breakpointsChanged` event. */
+2 -1
Переглянути файл
@@ -22,7 +22,7 @@ import {
type Variable,
} from "@/platform/ipc";
import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions";
import { breakpointsOf, consumeTemporary, refreshStates, resetStatuses } from "./breakpoints/store";
import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store";
import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
@@ -306,6 +306,7 @@ export async function startSession(
const startedAt = Date.now();
pendingStarts++;
try {
await flushBreakpoints();
const id = await call(commands.debugStart(launch));
owned.add(id);
useDebug.setState((s) => {