diff --git a/src/editor/lsp/availability.test.ts b/src/editor/lsp/availability.test.ts index e1c310bd..7cb59024 100644 --- a/src/editor/lsp/availability.test.ts +++ b/src/editor/lsp/availability.test.ts @@ -16,46 +16,95 @@ vi.mock("@/platform/ipc", async (orig) => { const { onServerSettled, featureBackoff, installAvailabilityEvents } = await import("./availability"); -const status = (s: string) => { - for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent); +const status = (s: string, server = 1) => { + for (const fn of [...listeners]) fn({ type: "languageServer", server, status: s } as unknown as IdeEvent); }; describe("onServerSettled", () => { + // Spells are followed once for the whole app: each test uses its own server. + const offs: (() => void)[] = []; + const settled = (fn: () => void) => offs.push(onServerSettled(fn)); afterEach(() => { vi.useRealTimers(); - listeners.clear(); + for (const off of offs.splice(0)) off(); }); it("re-runs a callback that keeps the server busy only once", () => { vi.useFakeTimers(); const fn = vi.fn(); - onServerSettled(fn); - status("starting"); - status("ready"); + settled(fn); + status("starting", 1); + status("ready", 1); expect(fn).toHaveBeenCalledTimes(1); for (let i = 0; i < 2; i++) { - status("busy"); + status("busy", 1); vi.advanceTimersByTime(2000); - status("ready"); + status("ready", 1); } expect(fn).toHaveBeenCalledTimes(2); // Indexing that starts later is a new settle. vi.advanceTimersByTime(10_000); - status("busy"); + status("busy", 1); vi.advanceTimersByTime(5000); - status("ready"); + status("ready", 1); expect(fn).toHaveBeenCalledTimes(3); }); it("ignores short busy spells", () => { vi.useFakeTimers(); const fn = vi.fn(); - onServerSettled(fn); - status("busy"); + settled(fn); + status("busy", 2); vi.advanceTimersByTime(100); - status("ready"); + status("ready", 2); expect(fn).not.toHaveBeenCalled(); }); + + it("re-runs after the project loads right after a start, however briefly", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + settled(fn); + status("starting", 3); + status("ready", 3); + expect(fn).toHaveBeenCalledTimes(1); + // vtsls loads a small project in about a second; answers given meanwhile were partial. + status("busy", 3); + vi.advanceTimersByTime(1000); + status("ready", 3); + expect(fn).toHaveBeenCalledTimes(2); + // Later short spells are requests again. + status("busy", 3); + vi.advanceTimersByTime(100); + status("ready", 3); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("tells a callback added while the project loads that it has", () => { + vi.useFakeTimers(); + settled(() => {}); + status("starting", 4); + status("ready", 4); + status("busy", 4); + // An editor restored at launch mounts now. + const fn = vi.fn(); + settled(fn); + vi.advanceTimersByTime(800); + status("ready", 4); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("ignores a short spell that begins well after a start", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + settled(fn); + status("starting", 5); + status("ready", 5); + vi.advanceTimersByTime(10_000); + status("busy", 5); + vi.advanceTimersByTime(100); + status("ready", 5); + expect(fn).toHaveBeenCalledTimes(1); + }); }); describe("featureBackoff", () => { diff --git a/src/editor/lsp/availability.ts b/src/editor/lsp/availability.ts index 414a51fc..241a0e4f 100644 --- a/src/editor/lsp/availability.ts +++ b/src/editor/lsp/availability.ts @@ -68,38 +68,81 @@ const LONG_BUSY_MS = 1500; /** A busy spell starting this soon after `fn` ran may be `fn`'s own requests. */ const ECHO_MS = 2000; +/** A busy spell starting this soon after a start is the server loading the project. */ +const LOADING_AFTER_START_MS = 5000; + +type Settle = (server: number, spellStart: number, loading: boolean) => void; + +/** Servers starting (`-Infinity`) or busy since when. */ +const spells = new Map(); +/** When servers became ready after starting, until their next spell ends. */ +const readyAfterStart = new Map(); +const settles = new Set(); +let tracking = false; + +/** + * Follows every server's spells from app start, so an editor that mounts while its + * server starts or loads (one restored at launch) still learns when that ends. + */ +function trackSpells() { + if (tracking || !isTauri()) return; + tracking = true; + onIdeEvent("languageServer", (e) => { + if (e.status === "starting") { + spells.set(e.server, -Infinity); + readyAfterStart.delete(e.server); + } else if (e.status === "busy") { + if (!spells.has(e.server)) spells.set(e.server, Date.now()); + } else if (e.status === "ready") { + const t = spells.get(e.server); + spells.delete(e.server); + if (t === undefined) return; + const startedAt = readyAfterStart.get(e.server); + if (t === -Infinity) readyAfterStart.set(e.server, Date.now()); + else readyAfterStart.delete(e.server); + const loading = startedAt !== undefined && t - startedAt < LOADING_AFTER_START_MS; + for (const settle of settles) settle(e.server, t, loading); + } else { + spells.delete(e.server); + readyAfterStart.delete(e.server); + } + }); +} + /** * Calls `fn` when a server becomes ready after starting or after a long busy * spell (indexing), when earlier answers may have been incomplete. Short busy * spells are ignored: jdtls reports progress for the very requests a caller * sends, and re-asking on each of them would never stop. For the same reason * a long spell that began right after `fn` ran (slow requests `fn` sent, or - * indexing right after start) re-runs it only once in a row. + * indexing right after start) re-runs it only once in a row. The spell right + * after a start counts whatever its length: it is the project loading (vtsls + * loads a small one in about a second), and answers given meanwhile are partial. */ export function onServerSettled(fn: () => void): () => void { if (!isTauri()) return () => {}; - const since = new Map(); + trackSpells(); const fired = new Map(); - return onIdeEvent("languageServer", (e) => { - if (e.status === "starting") { - since.set(e.server, -Infinity); - fired.delete(e.server); - } else if (e.status === "busy") { - if (!since.has(e.server)) since.set(e.server, Date.now()); - } else if (e.status === "ready") { - const t = since.get(e.server); - since.delete(e.server); - if (t === undefined || Date.now() - t < LONG_BUSY_MS) return; - const last = fired.get(e.server); - const echo = !!last && t - last.at < ECHO_MS; - if (echo && last.echoed) return; - fired.set(e.server, { at: Date.now(), echoed: echo }); - fn(); - } else since.delete(e.server); + const settle: Settle = (server, t, loading) => { + if (!loading && Date.now() - t < LONG_BUSY_MS) return; + const last = fired.get(server); + const echo = !!last && t - last.at < ECHO_MS; + if (echo && last.echoed) return; + fired.set(server, { at: Date.now(), echoed: echo }); + fn(); + }; + settles.add(settle); + const offStarting = onIdeEvent("languageServer", (e) => { + if (e.status === "starting") fired.delete(e.server); }); + return () => { + settles.delete(settle); + offStarting(); + }; } export function installAvailabilityEvents(): () => void { + trackSpells(); return onIdeEvent("languageServer", (e) => { if (e.status === "ready") clearBackoffs(); });