diff --git a/packages/coding-agent/bench/edit-lsp-writethrough.bench.ts b/packages/coding-agent/bench/edit-lsp-writethrough.bench.ts new file mode 100644 index 000000000..ef3b70890 --- /dev/null +++ b/packages/coding-agent/bench/edit-lsp-writethrough.bench.ts @@ -0,0 +1,108 @@ +/** + * Edit/write LSP-writethrough latency probe. + * + * The pure hashline apply is sub-2ms for normal files (see + * `packages/hashline/bench/apply-edit.ts`). The real source of "applying an + * edit takes a LOT of time" is the LSP writethrough's *synchronous* wait for + * fresh diagnostics: + * + * runLspWritethrough -> getDiagnosticsForFile -> waitForDiagnostics + * + * `waitForDiagnostics` polls every 100ms. Servers that echo the edited + * document version are accepted immediately; servers that omit or mismatch it + * (typescript-language-server) settle on the latest publish after a 250ms quiet + * window so stale in-flight publishes can be superseded without burning the + * full timeout. + * + * Gated by settings: + * - edit tool: `lsp.diagnosticsOnEdit` (default FALSE — edits fast by default) + * - write tool: `lsp.diagnosticsOnWrite` (default TRUE — writes pay it by default) + * - both: `lsp.formatOnWrite` (default FALSE — ~24ms when on, fine) + * + * Requires a TypeScript language server on PATH and a tsconfig at the repo + * root. Mutates a temp .ts file inside the repo so tsserver resolves it under + * the project, then deletes it. + * + * Run: `bun run packages/coding-agent/bench/edit-lsp-writethrough.bench.ts` + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { createLspWritethrough, writethroughNoop } from "../src/lsp"; + +const REPO = path.resolve(import.meta.dir, "../../.."); +const target = path.join(REPO, "packages/coding-agent/src/__bench_lsp_tmp.ts"); + +function body(n: number): string { + return `// bench scratch file with an intentional type diagnostic +export function benchAdd_${n}(a: number, b: number): number { + const result = a + b; + return result; +} +export const benchValue_${n}: string = benchAdd_${n}(${n}, ${n + 1}); +`; +} + +async function timeCall(label: string, fn: () => Promise): Promise { + const t0 = Bun.nanoseconds(); + await fn(); + console.log(` ${label.padEnd(46)} ${((Bun.nanoseconds() - t0) / 1e6).toFixed(1).padStart(9)} ms`); +} + +/** + * Build a one-shot deferred handle mirroring the edit tool's + * `beginDeferredDiagnosticsForPath`: `onDeferredDiagnostics` is the late-injection + * sink, `signal` keeps the background fetch alive, `finalize` reports whether the + * inline result arrived. Logs when late diagnostics land so #2 is observable. + */ +function makeDeferred(label: string) { + const controller = new AbortController(); + const lateAt = { t: 0 }; + const startedAt = Bun.nanoseconds(); + return { + handle: { + onDeferredDiagnostics: (_d: unknown) => { + lateAt.t = (Bun.nanoseconds() - startedAt) / 1e6; + console.log(` └─ ${label}: late diagnostics injected at +${lateAt.t.toFixed(0)} ms`); + }, + signal: controller.signal, + finalize: (_d: unknown) => {}, + }, + controller, + }; +} + +await fs.writeFile(target, body(0)); +try { + console.log("\n--- writethroughNoop (LSP off — default edit path) ---"); + for (let i = 1; i <= 3; i++) { + await timeCall(`noop write #${i}`, () => writethroughNoop(target, body(i), undefined, Bun.file(target))); + } + + console.log("\n--- diagnostics, NO deferred channel (blocks until settle/timeout) ---"); + const wtDiag = createLspWritethrough(REPO, { enableDiagnostics: true, enableFormat: false }); + for (let i = 10; i <= 14; i++) { + const label = i === 10 ? "write #1 (COLD: spawn+warm)" : `write #${i - 9} (warm)`; + await timeCall(label, () => wtDiag(target, body(i), undefined, Bun.file(target))); + } + + console.log("\n--- diagnostics, WITH deferred channel (short inline wait, then late) ---"); + for (let i = 30; i <= 34; i++) { + const { handle } = makeDeferred(`write #${i - 29}`); + await timeCall(`write #${i - 29} (inline)`, () => + wtDiag(target, body(i), undefined, Bun.file(target), undefined, () => handle), + ); + } + // Give any in-flight late fetches a moment to land before teardown. + await Bun.sleep(6000); + + console.log("\n--- format writethrough (formatOnWrite) ---"); + const wtFmt = createLspWritethrough(REPO, { enableDiagnostics: false, enableFormat: true }); + for (let i = 20; i <= 22; i++) { + await timeCall(`write #${i - 19}`, () => wtFmt(target, body(i), undefined, Bun.file(target))); + } +} finally { + await fs.rm(target, { force: true }); +} + +console.log("\n(done)"); +process.exit(0); diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 7864a9265..ac54086d4 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; -import { createLspWritethrough } from "@oh-my-pi/pi-coding-agent/lsp"; +import { createLspWritethrough, type FileDiagnosticsResult } from "@oh-my-pi/pi-coding-agent/lsp"; import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { Diagnostic, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; @@ -147,4 +147,63 @@ describe("LSP diagnostics freshness", () => { expect(result?.messages.some(m => m.includes("stale error"))).toBe(false); expect(elapsed).toBeLessThan(1500); }); + + it("returns promptly and delivers diagnostics via the deferred channel when the server is slow", async () => { + const filePath = path.join(tempDir.path(), "example.ts"); + const uri = fileToUri(filePath); + const client = createClient(tempDir.path(), TEST_SERVER); + client.openFiles.set(uri, { version: 1, languageId: "typescript" }); + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + const syncedUri = fileToUri(syncedFilePath); + mockClient.diagnostics.delete(syncedUri); + const openFile = mockClient.openFiles.get(syncedUri); + if (openFile) { + openFile.version += 1; + } else { + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + } + }); + // Publish well after the inline window so the writethrough must defer. + vi.spyOn(lspClient, "notifySaved").mockImplementation(async (mockClient, savedFilePath) => { + const savedUri = fileToUri(savedFilePath); + setTimeout(() => { + publishDiagnostics(mockClient, savedUri, [createDiagnostic("deferred error")], null); + }, 900); + }); + + const late = Promise.withResolvers(); + const handle = { + onDeferredDiagnostics: (d: FileDiagnosticsResult) => late.resolve(d), + signal: new AbortController().signal, + finalize: () => {}, + }; + + const writethrough = createLspWritethrough(tempDir.path(), { enableFormat: false, enableDiagnostics: true }); + const t0 = Date.now(); + const inline = await writethrough( + filePath, + "export const value: number = 'x';\n", + undefined, + undefined, + undefined, + () => handle, + ); + const elapsed = Date.now() - t0; + + // Inline returns promptly without blocking on the slow publish... + expect(inline).toBeUndefined(); + expect(elapsed).toBeLessThan(800); + + // ...and the diagnostics arrive afterwards via the deferred channel. + const lateResult = await late.promise; + expect(lateResult.errored).toBe(true); + expect(lateResult.messages.some(m => m.includes("deferred error"))).toBe(true); + + // The edit still landed on disk regardless of diagnostics timing. + expect(await Bun.file(filePath).text()).toBe("export const value: number = 'x';\n"); + }); });