/** Tests embedded LSP runtime JSON-RPC, tool behavior, and cleanup. */ import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { OwnedStdioCleanupError, type OwnedStdioProcess } from "../process/owned-stdio.js"; import { createDeferredCore } from "../shared/deferred.js"; import { createBundleLspToolRuntime as createProductionBundleLspToolRuntime, disposeAllBundleLspRuntimes, } from "./agent-bundle-lsp-runtime.js"; import { createAgentCleanupScope } from "./run-cleanup-timeout.js"; const spawnMock = vi.fn(); const loadEmbeddedAgentLspConfigMock = vi.fn(); function createBundleLspToolRuntime( params: Parameters[0], ) { return createProductionBundleLspToolRuntime({ ...params, dependencies: { loadLspConfig: loadEmbeddedAgentLspConfigMock, spawnServerProcess: spawnMock, }, }); } function encodeLspMessage(body: unknown): string { const json = JSON.stringify(body); return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`; } function parseWrittenLspBody(text: string): Record | null { const bodyStart = text.indexOf("\r\n\r\n"); if (bodyStart === -1) { return null; } return JSON.parse(text.slice(bodyStart + 4)) as Record; } class MockChildProcess extends EventEmitter implements OwnedStdioProcess { exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; killed = false; pid = 4321; readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); readonly stdin: Writable; readonly receivedMessages: Record[] = []; readonly initializeRequest = createDeferredCore(); readonly supportsRawOutput = true; readonly closed = createDeferredCore<{ code: number | null; signal: NodeJS.Signals | null }>(); readonly extinction = createDeferredCore(); holdExtinction = false; private firstError?: { error: Error; source: "process" | "stdin" | "stdout" | "stderr" }; private readonly errorListeners = new Set[0]>(); constructor( private readonly initializeResponsePrefix = "", private readonly respondMethods?: ReadonlySet, private readonly frameResponse: ( body: Record, method: string, ) => string | readonly string[] = encodeLspMessage, ) { super(); this.stdin = new Writable({ write: (chunk, _encoding, callback) => { this.respondToRequest(chunk.toString("utf8")); callback(); }, }); const observeError = (source: "process" | "stdin" | "stdout" | "stderr") => (error: Error) => { this.firstError ??= { error, source }; for (const listener of this.errorListeners) { listener(error, source); } }; this.on("error", observeError("process")); this.stdin.on("error", observeError("stdin")); this.stdout.on("error", observeError("stdout")); this.stderr.on("error", observeError("stderr")); this.on("close", (code: number | null, signal: NodeJS.Signals | null) => { this.closed.resolve({ code, signal }); if (!this.holdExtinction) { this.extinction.resolve(); } }); void this.extinction.promise.catch(() => {}); } onExit: OwnedStdioProcess["onExit"] = (listener) => { this.on("exit", listener); if (this.exitCode !== null || this.signalCode !== null) { listener(this.exitCode, this.signalCode); } }; onError: OwnedStdioProcess["onError"] = (listener) => { this.errorListeners.add(listener); if (this.firstError) { listener(this.firstError.error, this.firstError.source); } }; onStdout: OwnedStdioProcess["onStdout"] = (listener, onRaw) => { this.stdout.on("data", (chunk: Buffer) => { onRaw?.(chunk); listener(chunk.toString("utf8")); }); }; onStderr: OwnedStdioProcess["onStderr"] = (listener) => { this.stderr.on("data", (chunk: Buffer) => listener(chunk.toString("utf8"))); }; wait = () => this.closed.promise; waitForExtinction = () => this.extinction.promise; dispose = vi.fn(); kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => { this.killed = true; this.signalCode = signal; this.emit("exit", null, signal); this.emit("close", null, signal); return true; }); private respondToRequest(text: string): void { const body = parseWrittenLspBody(text); if (!body) { return; } this.receivedMessages.push(body); if (body.method === "exit") { queueMicrotask(() => { this.exitCode = 0; this.emit("exit", 0, null); this.emit("close", 0, null); }); } if (typeof body.id !== "number" || typeof body.method !== "string") { return; } const method = body.method; if (method === "initialize") { this.initializeRequest.resolve(body.id); } if (this.respondMethods && !this.respondMethods.has(method)) { return; } const result = method === "initialize" ? { capabilities: { hoverProvider: true, definitionProvider: true, referencesProvider: true, }, } : null; queueMicrotask(() => { const response = { jsonrpc: "2.0", id: body.id, result }; const frame = this.frameResponse(response, method); const chunks = typeof frame === "string" ? [frame] : frame; for (const [index, chunk] of chunks.entries()) { this.stdout.write(`${index === 0 ? this.initializeResponsePrefix : ""}${chunk}`); } }); } } function configureSingleLspServer(): void { loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: { typescript: { command: "typescript-language-server", args: ["--stdio"], }, }, diagnostics: [], }); } function waitForLspInitialization(child: MockChildProcess, creation: Promise) { return Promise.race([ child.initializeRequest.promise, creation.then(() => { throw new Error("LSP preparation completed before the initialize request"); }), ]); } describe("bundle LSP runtime", () => { afterEach(async () => { await disposeAllBundleLspRuntimes(); spawnMock.mockReset(); loadEmbeddedAgentLspConfigMock.mockReset(); }); it("reuses the prepared plugin manifest registry for bundle discovery", async () => { loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: {}, diagnostics: [] }); const manifestRegistry = { plugins: [] }; await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace", manifestRegistry, }); expect(loadEmbeddedAgentLspConfigMock).toHaveBeenCalledWith({ workspaceDir: "/tmp/workspace", cfg: undefined, manifestRegistry, }); }); it("starts configured LSP servers and exposes their tools", async () => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(spawnMock).toHaveBeenCalledTimes(1); expect(spawnMock).toHaveBeenCalledWith( { command: "typescript-language-server", args: ["--stdio"], cwd: undefined, env: undefined, }, { abortSignal: undefined }, ); expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript"); await runtime.dispose(); expect(child.dispose).toHaveBeenCalledOnce(); expect(child.kill).not.toHaveBeenCalled(); }); it("fails LSP startup immediately when the child process cannot spawn", async () => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockImplementation(() => { queueMicrotask(() => child.emit("error", new Error("spawn ENOENT"))); return child; }); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(runtime.sessions).toEqual([]); expect(runtime.tools).toEqual([]); expect(child.dispose).toHaveBeenCalledOnce(); }); it("starts no LSP children when preparation is already aborted", async () => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockReturnValue(child); const reason = new Error("attempt stopped before LSP preparation"); const creation = createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace", abortSignal: AbortSignal.abort(reason), }); try { await expect(creation).rejects.toBe(reason); expect(spawnMock).not.toHaveBeenCalled(); } finally { await creation.then( (runtime) => runtime.dispose(), () => {}, ); } }); it("joins LSP cleanup when preparation aborts during initialize", async () => { vi.useFakeTimers(); configureSingleLspServer(); const child = new MockChildProcess("", new Set(["shutdown"])); child.holdExtinction = true; spawnMock.mockReturnValue(child); const controller = new AbortController(); const reason = new Error("attempt stopped during LSP initialization"); const creation = createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace", abortSignal: controller.signal, }); let settled = false; const outcome = creation.then( (runtime) => { settled = true; return { runtime }; }, (error: unknown) => { settled = true; return { error }; }, ); let initializeId: number | undefined; try { initializeId = await waitForLspInitialization(child, creation); controller.abort(reason); await vi.advanceTimersByTimeAsync(1_000); expect(child.kill).toHaveBeenCalled(); expect(settled).toBe(false); expect(child.dispose).not.toHaveBeenCalled(); child.extinction.resolve(); const completed = await outcome; expect("error" in completed ? completed.error : undefined).toBe(reason); expect(child.dispose).toHaveBeenCalledOnce(); expect(child.receivedMessages.some((message) => message.method === "initialized")).toBe( false, ); } finally { if (initializeId !== undefined) { child.stdout.write(encodeLspMessage({ jsonrpc: "2.0", id: initializeId, result: {} })); } child.extinction.resolve(); const completed = await outcome; if ("runtime" in completed) { await completed.runtime.dispose(); } vi.useRealTimers(); } }); it("does not notify initialized when preparation aborts after the response resolves", async () => { vi.useFakeTimers(); configureSingleLspServer(); const child = new MockChildProcess("", new Set(["shutdown"])); spawnMock.mockReturnValue(child); const controller = new AbortController(); const reason = new Error("attempt stopped before initialized notification"); const creation = createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace", abortSignal: controller.signal, }); const outcome = creation.then( (runtime) => ({ runtime }), (error: unknown) => ({ error }), ); try { const initializeId = await waitForLspInitialization(child, creation); child.stdout.write( encodeLspMessage({ jsonrpc: "2.0", id: initializeId, result: { capabilities: { hoverProvider: true } }, }), ); controller.abort(reason); await vi.advanceTimersByTimeAsync(1_000); expect(child.receivedMessages.some((message) => message.method === "initialized")).toBe( false, ); const completed = await outcome; expect("error" in completed ? completed.error : undefined).toBe(reason); expect(child.dispose).toHaveBeenCalledOnce(); } finally { const completed = await outcome; if ("runtime" in completed) { await completed.runtime.dispose(); } vi.useRealTimers(); } }); it("cleans initialized and pending LSP servers on preparation abort without starting siblings", async () => { vi.useFakeTimers(); loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: { first: { command: "first-language-server" }, second: { command: "second-language-server" }, third: { command: "third-language-server" }, }, diagnostics: [], }); const first = new MockChildProcess(); const second = new MockChildProcess("", new Set(["shutdown"])); const third = new MockChildProcess(); spawnMock.mockReturnValueOnce(first).mockReturnValueOnce(second).mockReturnValue(third); const controller = new AbortController(); const reason = new Error("attempt stopped while preparing the second LSP server"); const creation = createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace", abortSignal: controller.signal, }); let settled = false; const outcome = creation.then( (runtime) => { settled = true; return { runtime }; }, (error: unknown) => { settled = true; return { error }; }, ); let initializeId: number | undefined; try { initializeId = await waitForLspInitialization(second, creation); expect(first.receivedMessages.some((message) => message.method === "initialized")).toBe(true); controller.abort(reason); await vi.advanceTimersByTimeAsync(1_000); expect(settled).toBe(true); const completed = await outcome; expect("error" in completed ? completed.error : undefined).toBe(reason); expect(first.dispose).toHaveBeenCalledOnce(); expect(second.dispose).toHaveBeenCalledOnce(); expect(second.receivedMessages.some((message) => message.method === "initialized")).toBe( false, ); expect(spawnMock).toHaveBeenCalledTimes(2); expect(third.receivedMessages).toEqual([]); } finally { if (initializeId !== undefined) { second.stdout.write(encodeLspMessage({ jsonrpc: "2.0", id: initializeId, result: {} })); } const completed = await outcome; if ("runtime" in completed) { await completed.runtime.dispose(); } vi.useRealTimers(); } }); it.each([ { name: "stdout fails", fail: (child: MockChildProcess) => child.stdout.emit("error", new Error("stdout failed")), message: "stdout failed", }, { name: "stdin fails", fail: (child: MockChildProcess) => child.stdin.emit("error", new Error("stdin failed")), message: "stdin failed", }, ])("rejects pending and future LSP requests when $name", async ({ fail, message }) => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hoverTool) { throw new Error("expected hover tool"); } const hoverParams = { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }; const request = hoverTool.execute("call-1", hoverParams); fail(child); await expect(request).rejects.toThrow(message); await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(message); await runtime.dispose(); }); it("blocks new LSP requests on exit while allowing a final stdout response to drain", async () => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hoverTool) { throw new Error("expected hover tool"); } const hoverParams = { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }; const pendingRequest = hoverTool.execute("call-1", hoverParams); child.exitCode = 1; child.emit("exit", 1, null); await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow( 'LSP server "typescript" exited (1)', ); child.stdout.write( encodeLspMessage({ jsonrpc: "2.0", id: 2, result: { contents: "final hover" } }), ); await expect(pendingRequest).resolves.toMatchObject({ details: { lspServer: "typescript", lspMethod: "hover" }, }); child.emit("close", 1, null); await runtime.dispose(); }); it("rejects undrained LSP requests when the exited process closes", async () => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hoverTool) { throw new Error("expected hover tool"); } const request = hoverTool.execute("call-1", { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }); child.exitCode = 1; child.emit("exit", 1, null); child.emit("close", 1, null); await expect(request).rejects.toThrow('LSP server "typescript" exited (1)'); await runtime.dispose(); }); it.each([ ["lsp_hover_typescript", "textDocument/hover"], ["lsp_definition_typescript", "textDocument/definition"], ["lsp_references_typescript", "textDocument/references"], ])("cancels pending %s requests when the tool signal aborts", async (toolName, method) => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize", "shutdown"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const tool = runtime.tools.find((candidate) => candidate.name === toolName); if (!tool) { throw new Error(`expected ${toolName} tool`); } const controller = new AbortController(); const request = tool.execute( "call-1", { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }, controller.signal, ); const settled = request.then( () => "resolved", () => "rejected", ); const lspRequest = child.receivedMessages.find((message) => message.method === method); controller.abort(new Error("agent stopped")); await expect( Promise.race([ settled, new Promise((resolve) => { setTimeout(() => resolve("still pending"), 100); }), ]), ).resolves.toBe("rejected"); expect(child.receivedMessages).toContainEqual({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id: lspRequest?.id }, }); await runtime.dispose(); }); it("keeps LSP framing aligned after multibyte messages in the same chunk", async () => { configureSingleLspServer(); const prefix = encodeLspMessage({ jsonrpc: "2.0", method: "window/logMessage", params: { message: "ready té" }, }); const child = new MockChildProcess(prefix); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript"); await runtime.dispose(); }); it("accepts a Content-Type header alongside Content-Length", async () => { configureSingleLspServer(); const child = new MockChildProcess("", undefined, (body) => { const json = JSON.stringify(body); return `Content-Type: application/vscode-jsonrpc; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`; }); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript"); await runtime.dispose(); }); it.each([1, 4096])( "preserves consecutive multibyte tool responses delivered in %i-byte chunks", async (chunkSize) => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize", "shutdown"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const tool = runtime.tools.find((candidate) => candidate.name === "lsp_hover_typescript"); if (!tool) { throw new Error("expected hover tool"); } const results = [{ contents: "té🙂".repeat(2048) }, { contents: "次の結果".repeat(1024) }]; const requests = results.map((_, index) => tool.execute(`call-${index}`, { uri: "file:///tmp/workspace/index.ts", line: index, character: 0, }), ); const calls = child.receivedMessages.filter( (message) => message.method === "textDocument/hover", ); const bytes = Buffer.from( results .map((result, index) => encodeLspMessage({ jsonrpc: "2.0", id: calls[index]?.id, result }), ) .join(""), ); for (let offset = 0; offset < bytes.length; offset += chunkSize) { child.stdout.write(bytes.subarray(offset, offset + chunkSize)); } expect((await Promise.all(requests)).map((result) => result.content)).toEqual( results.map((result) => [{ type: "text", text: JSON.stringify(result, null, 2) }]), ); await runtime.dispose(); }, ); it("accepts a maximum-size header when its separator is split across chunks", async () => { configureSingleLspServer(); const child = new MockChildProcess("", undefined, (body) => { const json = JSON.stringify(body); const headerPrefix = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\nX-Padding: `; const header = `${headerPrefix}${"x".repeat(8 * 1024 - headerPrefix.length)}`; const frame = `${header}\r\n\r\n${json}`; return [frame.slice(0, header.length + 1), frame.slice(header.length + 1)]; }); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript"); await runtime.dispose(); }); it("rejects invalid UTF-8 in LSP JSON bodies", async () => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hoverTool) { throw new Error("expected hover tool"); } const request = hoverTool.execute("call-1", { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }); const hoverRequest = child.receivedMessages.find( (message) => message.method === "textDocument/hover", ); if (typeof hoverRequest?.id !== "number") { throw new Error("expected numeric hover request id"); } const body = Buffer.concat([ Buffer.from(`{"jsonrpc":"2.0","id":${hoverRequest.id},"result":{"contents":"`), Buffer.from([0xff]), Buffer.from('"}}'), ]); const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"); child.stdout.write(Buffer.concat([header, body])); await expect(request).rejects.toThrow(/LSP framing error: body is not valid UTF-8/i); expect(child.kill).toHaveBeenCalledWith("SIGKILL"); await runtime.dispose(); }); it.each([ { name: "a suffixed Content-Length value", frame: (body: Record) => { const json = JSON.stringify(body); return `Content-Length: ${Buffer.byteLength(json, "utf-8")}junk\r\n\r\n${json}`; }, }, { name: "duplicate Content-Length fields", frame: (body: Record) => { const json = JSON.stringify(body); const length = Buffer.byteLength(json, "utf-8"); return `Content-Length: ${length}\r\nContent-Length: ${length}\r\n\r\n${json}`; }, }, { name: "a colonless header line", frame: (body: Record) => { const json = JSON.stringify(body); const length = Buffer.byteLength(json, "utf-8"); return `Content-Length: ${length}\r\nbroken\r\n\r\n${json}`; }, }, { name: "an oversized declared body", frame: () => `Content-Length: ${64 * 1024 * 1024 + 1}\r\n\r\n`, }, { name: "an oversized unterminated header", frame: () => `X-Header: ${"x".repeat(8 * 1024)}`, }, ])("fails the LSP session immediately for $name", async ({ frame }) => { configureSingleLspServer(); const child = new MockChildProcess( "", new Set(["initialize", "textDocument/hover"]), (body, method) => (method === "initialize" ? encodeLspMessage(body) : frame(body)), ); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hoverTool) { throw new Error("expected hover tool"); } const request = hoverTool.execute("call-1", { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }); const outcome = await Promise.race([ request.then( () => "resolved", (error: unknown) => (error instanceof Error ? error.message : String(error)), ), new Promise((resolve) => { setTimeout(() => resolve("still pending"), 100); }), ]); expect(outcome).toMatch(/LSP framing error/i); expect(child.kill).toHaveBeenCalledWith("SIGKILL"); await runtime.dispose(); }); it("disposes active LSP sessions from the global shutdown sweep", async () => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); await disposeAllBundleLspRuntimes(); expect(child.dispose).toHaveBeenCalledOnce(); await runtime.dispose(); expect(child.dispose).toHaveBeenCalledOnce(); }); it("joins repeated disposal until the exited LSP server's descendants settle", async () => { configureSingleLspServer(); const child = new MockChildProcess(); child.holdExtinction = true; spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const cleanupScope = createAgentCleanupScope(); let settled = false; const cleanup = cleanupScope .run(async () => { await Promise.all([runtime.dispose(), runtime.dispose()]); }) .then(() => { settled = true; }); try { await vi.waitFor(() => expect(child.exitCode).toBe(0)); expect(settled).toBe(false); expect(child.dispose).not.toHaveBeenCalled(); } finally { child.extinction.resolve(); await cleanup; } expect(cleanupScope.outcome).toBe("closed"); expect(child.dispose).toHaveBeenCalledOnce(); expect(child.kill).not.toHaveBeenCalled(); expect(child.receivedMessages.filter((message) => message.method === "shutdown")).toHaveLength( 1, ); }); it.each(["rejected", "unsupported"] as const)( "records uncertain cleanup when LSP descendant settlement is %s", async (extinction) => { configureSingleLspServer(); const child = new MockChildProcess(); if (extinction === "rejected") { child.extinction.reject(new Error("descendant settlement failed")); } else { Object.defineProperty(child, "waitForExtinction", { value: undefined }); } spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript"); const cleanupScope = createAgentCleanupScope(); // Global/manual cleanup can precede an automatic owner joining the same resources. await runtime.dispose(); await cleanupScope.run(() => runtime.dispose()); expect(cleanupScope.outcome).toBe("uncertain"); expect(child.dispose).toHaveBeenCalledOnce(); }, ); it("records uncertain cleanup when process construction cannot confirm reclamation", async () => { configureSingleLspServer(); spawnMock.mockRejectedValue(new OwnedStdioCleanupError("startup cleanup was not confirmed")); const cleanupScope = createAgentCleanupScope(); const runtime = await cleanupScope.run(() => createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }), ); expect(runtime.tools).toEqual([]); expect(cleanupScope.outcome).toBe("uncertain"); }); it.each([ ["lsp_hover_typescript", "textDocument/hover"], ["lsp_definition_typescript", "textDocument/definition"], ["lsp_references_typescript", "textDocument/references"], ])("rejects retained %s requests throughout disposal", async (toolName, method) => { configureSingleLspServer(); const child = new MockChildProcess(); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const tool = runtime.tools.find((candidate) => candidate.name === toolName); if (!tool) { throw new Error(`expected ${toolName} tool`); } const input = { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0 }; await tool.execute("before-dispose", input); const disposal = runtime.dispose(); const during = await tool.execute("during-dispose", input).then( () => "accepted", (error: unknown) => (error instanceof Error ? error.message : String(error)), ); await disposal; const after = await tool.execute("after-dispose", input).then( () => "accepted", (error: unknown) => (error instanceof Error ? error.message : String(error)), ); expect({ during, after }).toEqual({ during: "LSP session disposed", after: "LSP session disposed", }); expect(child.receivedMessages.filter((message) => message.method === method)).toHaveLength(1); expect(child.receivedMessages.filter((message) => message.method === "shutdown")).toHaveLength( 1, ); expect(child.receivedMessages.filter((message) => message.method === "exit")).toHaveLength(1); expect(child.dispose).toHaveBeenCalledOnce(); }); it("rejects outstanding requests before shutdown and detaches their abort listeners", async () => { configureSingleLspServer(); const child = new MockChildProcess("", new Set(["initialize"])); spawnMock.mockReturnValue(child); const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" }); const hover = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript"); if (!hover) { throw new Error("expected hover tool"); } const controller = new AbortController(); const pending = hover .execute( "pending", { uri: "file:///tmp/workspace/index.ts", line: 0, character: 0, }, controller.signal, ) .then( () => "accepted", (error: unknown) => (error instanceof Error ? error.message : String(error)), ); const disposal = runtime.dispose(); const shutdown = child.receivedMessages.find((message) => message.method === "shutdown"); controller.abort(new Error("caller aborted during shutdown")); const outcome = await pending; child.stdout.write(encodeLspMessage({ jsonrpc: "2.0", id: shutdown?.id, result: null })); await disposal; expect(outcome).toBe("LSP session disposed"); expect(child.receivedMessages.map((message) => message.method)).toEqual([ "initialize", "initialized", "textDocument/hover", "shutdown", "exit", ]); expect(child.dispose).toHaveBeenCalledOnce(); }); });