From f33b56b92b05afebc55b3bebd27238ac6d34f1a9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:16:55 +0800 Subject: [PATCH] perf: measure code completion over a 2,000-item list The harness's probe can answer completion with generated items through the command route; language server requests go through a route when one is installed, not only under Tauri. The scenario opens the popup by typing, narrows it letter by letter and moves the selection, with budgets for each. --- perf/budgets.json | 8 ++++++ perf/scenarios.ts | 43 +++++++++++++++++++++++++++++ src/editor/lsp/lsp.test.ts | 18 ++++++++++++- src/editor/lsp/lsp.ts | 4 ++- src/platform/tauriCore.ts | 5 ++++ src/workbench/perfProbe.ts | 55 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 2 deletions(-) diff --git a/perf/budgets.json b/perf/budgets.json index 8555fa299..a362fa397 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -32,6 +32,14 @@ "typingAngular.mainThreadMsPerKey": 30, "typingAngular.tbtMs": 50, + "completion.openMs": 150, + "completion.narrowP95Ms": 20, + "completion.narrowMissedFramePct": 2, + "completion.narrowMainThreadMsPerKey": 35, + "completion.arrowP95Ms": 20, + "completion.arrowMissedFramePct": 2, + "completion.tbtMs": 50, + "keyRepeat.p95Ms": 20, "keyRepeat.maxMs": 33, "keyRepeat.missedFramePct": 2, diff --git a/perf/scenarios.ts b/perf/scenarios.ts index 567dcb5d4..1022c6cdc 100644 --- a/perf/scenarios.ts +++ b/perf/scenarios.ts @@ -110,6 +110,48 @@ const typingAngular: Scenario = { }, }; +/** + * Code completion with a language server's 2,000 items: the first letter opens + * the popup (after CodeMirror's 100 ms typing delay), each further letter + * narrows it, arrows move the selection. + */ +const completion: Scenario = { + id: "completion", + title: "Code completion over 2,000 items", + async run(h) { + await h.page.evaluate(() => + (window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(2_000), + ); + try { + await h.openFile(`${ROOT}/src/completion/Orders.ts`, tsSource(2_000, "completion")); + const st = await h.editorState(); + await h.setCursor(Math.floor((st?.length ?? 0) / 2)); + await h.parserIdle(); + await h.page.keyboard.press("End"); + await h.page.keyboard.press("Enter"); + const refresh = await h.refreshMs(); + const openMs = await h.measure(() => h.page.keyboard.press("g"), ".cm-tooltip-autocomplete li", "Result"); + const t0 = await h.now(); + const before = await h.counters(); + const narrow = await h.typeMeasured("etOrd", 150); + const work = Harness.delta(before, await h.counters()); + const arrows = await h.pressMeasured(Array(20).fill("ArrowDown"), 60); + await h.page.keyboard.press("Escape"); + return { + openMs, + ...keySummary(narrow, refresh, "narrow"), + narrowMainThreadMsPerKey: work.taskMs / 5, + ...keySummary(arrows, refresh, "arrow"), + tbtMs: totalBlockingTime(await h.longTasksSince(t0)), + }; + } finally { + await h.page.evaluate(() => + (window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(0), + ); + } + }, +}; + /** Held key: auto-repeat at ~30 Hz must never fall behind. */ const keyRepeat: Scenario = { id: "keyRepeat", @@ -369,6 +411,7 @@ export const SCENARIOS: Scenario[] = [ typing, typingLarge, typingAngular, + completion, keyRepeat, scroll, tabs, diff --git a/src/editor/lsp/lsp.test.ts b/src/editor/lsp/lsp.test.ts index 6590de5a0..9308d3ea2 100644 --- a/src/editor/lsp/lsp.test.ts +++ b/src/editor/lsp/lsp.test.ts @@ -3,13 +3,15 @@ import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { _addEditor, _removeEditor, documentSynced, type EditorDocContext } from "@/editor/api"; +let tauri = true; vi.mock("@/platform/ipc", async (orig) => { const actual = await orig(); - return { ...actual, isTauri: () => true }; + return { ...actual, isTauri: () => tauri }; }); const { lspRequest, reportLspError } = await import("./lsp"); const { IpcError } = await import("@/platform/ipc"); +const { setInvokeRoute } = await import("@/platform/tauriCore"); describe("lspRequest", () => { it("waits until open editors' edits reached the backend", async () => { @@ -27,6 +29,20 @@ describe("lspRequest", () => { _removeEditor(handle); view.destroy(); }); + + it("outside Tauri, goes through an installed command route only", async () => { + tauri = false; + try { + const run = vi.fn(async () => ({ status: "ok" as const, data: 2 })); + await expect(lspRequest(run)).rejects.toMatchObject({ kind: "unavailable" }); + expect(run).not.toHaveBeenCalled(); + setInvokeRoute(() => null); + expect(await lspRequest(run)).toBe(2); + } finally { + setInvokeRoute(null); + tauri = true; + } + }); }); describe("reportLspError", () => { diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 6b301f9aa..ff4d49848 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -3,6 +3,7 @@ import { Facet } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { whenAllSynced, type EditorDocContext } from "@/editor/api"; import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc"; +import { hasInvokeRoute } from "@/platform/tauriCore"; import { tx } from "@/platform/i18n"; import type { IdeError, ReferencesIncomplete } from "@/bindings"; import { showHint } from "./hint"; @@ -25,7 +26,8 @@ type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError * meanwhile is not sent (the backend ignores cancelling an id it hasn't seen). */ export async function lspRequest(run: () => Promise>, wanted?: () => boolean): Promise { - if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" }); + if (!isTauri() && !hasInvokeRoute()) + throw new IpcError({ kind: "unavailable", message: "No language server available" }); try { // Edits typed just before (⌘B right after typing) must reach the server first. await whenAllSynced(); diff --git a/src/platform/tauriCore.ts b/src/platform/tauriCore.ts index dc03bdac4..547c1acf2 100644 --- a/src/platform/tauriCore.ts +++ b/src/platform/tauriCore.ts @@ -19,6 +19,11 @@ export function setInvokeRoute(next: InvokeRoute | null) { route = next; } +/** Whether commands have somewhere to go besides this process's Tauri runtime. */ +export function hasInvokeRoute(): boolean { + return route !== null; +} + export function invoke(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise { return (route?.(cmd, args, options) ?? invokeLocal(cmd, args, options)) as Promise; } diff --git a/src/workbench/perfProbe.ts b/src/workbench/perfProbe.ts index e7290e1e1..8c80e56c5 100644 --- a/src/workbench/perfProbe.ts +++ b/src/workbench/perfProbe.ts @@ -8,6 +8,8 @@ import { executeAction } from "@/platform/actions"; import { bindingsFor } from "@/platform/keymap"; import { activeEditor } from "@/editor/api"; import { docBackend, type MemoryBackend } from "@/editor/core/backend"; +import type { CompletionItem, CompletionKind } from "@/bindings"; +import { setInvokeRoute } from "@/platform/tauriCore"; import { editorService } from "./contributions"; export interface PerfProbe { @@ -23,6 +25,48 @@ export interface PerfProbe { parsing(): boolean; /** Document length and caret of the active editor. */ editorState(): { path: string; length: number; lines: number; head: number } | null; + /** + * Answers code completion with `items` generated items (a language server's + * list); other language server requests report no server. `0` removes it. + */ + fakeCompletion(items: number): void; +} + +const KINDS: CompletionKind[] = [ + "method", + "function", + "field", + "variable", + "class", + "interface", + "property", + "keyword", +]; + +function completionItem(i: number): CompletionItem { + const kind = KINDS[i % KINDS.length]!; + // Shared prefixes so typing narrows the list gradually, like real symbols. + const label = `${["get", "set", "create", "update", "find", "to", "is", "on"][i % 8]}${["Order", "Item", "Customer", "Invoice", "Payment", "Status"][(i >> 3) % 6]}${i}`; + return { + label, + labelDetail: kind === "method" || kind === "function" ? "(id: string, options?: Options)" : null, + detail: kind === "class" || kind === "interface" ? `com.acme.${label}` : "Promise>", + kind, + documentation: null, + sortText: String(i).padStart(5, "0"), + filterText: null, + preselect: false, + deprecated: i % 97 === 0, + insertText: label, + isSnippet: false, + replaceRange: null, + replaceRangeTab: null, + additionalEdits: [], + commitCharacters: [], + command: null, + data: null, + server: 1, + }; } export function installPerfProbe(): () => void { @@ -45,6 +89,17 @@ export function installPerfProbe(): () => void { const view = activeEditor()?.view; return !!view && syntaxParserRunning(view); }, + fakeCompletion(count) { + if (!count) return setInvokeRoute(null); + const items = Array.from({ length: count }, (_, i) => completionItem(i)); + setInvokeRoute((cmd, args) => { + if (cmd === "lsp_completion") return Promise.resolve({ isIncomplete: false, items }); + if (cmd === "lsp_resolve_completion") return Promise.resolve((args as { item: CompletionItem }).item); + if (cmd.startsWith("lsp_")) + return Promise.reject({ kind: "unavailable", message: "No language server available" }); + return null; + }); + }, editorState() { const e = activeEditor(); if (!e) return null;