diff --git a/src/features/debugger/__tests__/startupBreakpoints.test.ts b/src/features/debugger/__tests__/startupBreakpoints.test.ts index b5fab268d..7b71816ae 100644 --- a/src/features/debugger/__tests__/startupBreakpoints.test.ts +++ b/src/features/debugger/__tests__/startupBreakpoints.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { addBreakpoint, loadBreakpoints, removeAllBreakpoints, useBreakpoints } from "../breakpoints/store"; +import { + addBreakpoint, + flushBreakpoints, + loadBreakpoints, + removeAllBreakpoints, + useBreakpoints, +} from "../breakpoints/store"; import { handleDebugEvent, startPreparedSession, startSession, useDebug } from "../state"; import type { LaunchConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -167,3 +173,48 @@ it("does not start if the initial breakpoint sync fails", async () => { expect(await startSession(config("launch"))).toBeNull(); expect(ipc.debugStart).not.toHaveBeenCalled(); }); + +it.each(["launch", "attach"])("shares the breakpoint drain between concurrent %s starts", async (request) => { + let release!: () => void; + ipc.debugSetBreakpoints.mockImplementation(() => { + // Bound the broken loop so a regression fails instead of hanging the test runner. + if (ipc.debugSetBreakpoints.mock.calls.length > 8) throw new Error("breakpoint flush did not settle"); + if (ipc.debugSetBreakpoints.mock.calls.length === 1) + return new Promise((resolve) => { + release = () => resolve({ status: "ok", data: [] }); + }); + return Promise.resolve({ status: "ok", data: [] }); + }); + ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 42 }).mockResolvedValueOnce({ status: "ok", data: 43 }); + addBreakpoint("/p/Main.java", 4); + const starting = Promise.all([startSession(config(request)), startSession(config(request))]); + await vi.advanceTimersByTimeAsync(0); + expect(ipc.debugStart).not.toHaveBeenCalled(); + addBreakpoint("/p/Main.java", 7); + release(); + expect(await starting).toEqual([42, 43]); + expect(ipc.debugSetBreakpoints).toHaveBeenCalledTimes(2); + expect(ipc.debugSetBreakpoints.mock.calls[1]![1]).toMatchObject([{ line: 4 }, { line: 7 }]); + await vi.advanceTimersByTimeAsync(120); + expect(ipc.debugSetBreakpoints).toHaveBeenCalledTimes(2); +}); + +it("does not resend unchanged breakpoints for concurrent flushes", async () => { + ipc.debugSetBreakpoints.mockImplementation(async () => { + if (ipc.debugSetBreakpoints.mock.calls.length > 8) throw new Error("breakpoint flush did not settle"); + return { status: "ok", data: [] }; + }); + addBreakpoint("/p/Main.java", 4); + await Promise.all([flushBreakpoints(), flushBreakpoints(), flushBreakpoints()]); + expect(ipc.debugSetBreakpoints).toHaveBeenCalledOnce(); +}); + +it("releases a failed shared drain so a later start can retry", async () => { + ipc.debugSetBreakpoints.mockRejectedValueOnce(new Error("breakpoint sync failed")); + addBreakpoint("/p/Main.java", 4); + expect(await Promise.all([startSession(config("launch")), startSession(config("attach"))])).toEqual([null, null]); + expect(ipc.debugStart).not.toHaveBeenCalled(); + ipc.debugSetBreakpoints.mockResolvedValue({ status: "ok", data: [] }); + expect(await startSession(config("launch"))).toBe(42); + expect(ipc.debugStart).toHaveBeenCalledOnce(); +}); diff --git a/src/features/debugger/breakpoints/store.ts b/src/features/debugger/breakpoints/store.ts index 01b53a947..d31b4634e 100644 --- a/src/features/debugger/breakpoints/store.ts +++ b/src/features/debugger/breakpoints/store.ts @@ -96,6 +96,7 @@ const timers = new Map>(); /** Ids in the order last sent per file (backend states are index-aligned). */ const sent = new Map(); const inFlight = new Map>(); +let flushing: Promise | null = null; const SYNC_DELAY = 120; export function scheduleSync(path: string) { @@ -110,7 +111,16 @@ export function scheduleSync(path: string) { } /** Drain debounced edits before the backend takes its startup breakpoint snapshot. */ -export async function flushBreakpoints() { +export function flushBreakpoints(): Promise { + // Concurrent session starts must not keep adding work to each other's drain. + if (flushing) return flushing; + flushing = drainBreakpoints().finally(() => { + flushing = null; + }); + return flushing; +} + +async function drainBreakpoints() { let paths = new Set([...timers.keys(), ...inFlight.keys(), ...Object.keys(useBreakpoints.getState().byFile)]); while (paths.size) { for (const timer of timers.values()) clearTimeout(timer);