fix(debugger): share breakpoint flushes across concurrent starts (#2171)

Starting several debug sessions with preset breakpoints could hang before any adapter started: each start ran its own flush and kept adding sends to the others' queue. Concurrent starts now share one drain, which includes edits made during a send, and a failed drain can be retried. Exception, function and data breakpoints use finite sends and do not have this loop.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 01:09:22 +08:00
1 джерело cad33ef119
коміт 6d8e41c2c1
2 files changed
+63 -2

No files matched your search

+52 -1
Переглянути файл
@@ -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();
});
+11 -1
Переглянути файл
@@ -96,6 +96,7 @@ 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>>();
let flushing: Promise<void> | 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<void> {
// 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);