Files
kandev/apps/web/lib/lsp/lsp-client-manager.continuity.test.ts
T

445 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createLspManagerHarness,
FakeWebSocket,
markerMessages,
publishDiagnostic,
} from "./lsp-client-manager.test-harness";
import {
LSP_IDLE_TIMEOUT,
LSP_RECONNECT_DELAYS_MS,
LSP_RELEASE_ACK_TIMEOUT_MS,
} from "./lsp-client-config";
import { modelUriForDocument } from "./file-uri";
const mocks = vi.hoisted(() => ({
getMonacoInstance: vi.fn(),
waitForMonacoInstance: vi.fn(),
getWebSocketClient: vi.fn(),
registerLspProviders: vi.fn(),
requestFileContent: vi.fn(),
registerBuiltinTsSuppression: vi.fn(),
}));
vi.mock("@/components/editors/monaco/monaco-init", () => ({
getMonacoInstance: mocks.getMonacoInstance,
waitForMonacoInstance: mocks.waitForMonacoInstance,
}));
vi.mock("@/components/editors/monaco/builtin-providers", () => ({
registerBuiltinTsSuppression: mocks.registerBuiltinTsSuppression,
withLspProviderRegistration: <T>(register: () => T) => register(),
}));
vi.mock("./lsp-providers", () => ({ registerLspProviders: mocks.registerLspProviders }));
vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: mocks.getWebSocketClient }));
vi.mock("@/lib/ws/workspace-files", () => ({ requestFileContent: mocks.requestFileContent }));
import { lspClientManager } from "./lsp-client-manager";
const { createMonacoHarness } = createLspManagerHarness(lspClientManager, mocks);
const SESSION_ID = "continuity-session";
const WORKSPACE_URI = "file:///workspace";
const WORKSPACE_PATH = "/workspace";
const CURRENT_DOCUMENT_TEXT = "export const current = true;";
const DOCUMENT_URI = `${WORKSPACE_URI}/Main.ts`;
const DOCUMENT_MODEL_URI = modelUriForDocument(DOCUMENT_URI, SESSION_ID);
const DID_OPEN_METHOD = "textDocument/didOpen";
const DID_CHANGE_METHOD = "textDocument/didChange";
function initializeRequest(socket: FakeWebSocket): { id: number; method: string } {
const request = socket.sent
.map((frame) => JSON.parse(frame) as { id?: number; method?: string })
.find((message) => message.method === "initialize");
if (request?.id === undefined || request.method === undefined) {
throw new Error("expected the LSP initialize request");
}
return { id: request.id, method: request.method };
}
function latestControl(
socket: FakeWebSocket,
action: string,
): { action: string; requestId: string } {
const control = socket.sent
.map((frame) => JSON.parse(frame) as { action?: string; requestId?: string })
.find((message) => message.action === action);
if (!control?.requestId) throw new Error(`expected LSP ${action} control`);
return { action, requestId: control.requestId };
}
function sendResumedHandshake(socket: FakeWebSocket): void {
socket.open();
socket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: true,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
capabilities: {
definitionProvider: true,
textDocumentSync: { openClose: true, change: 1 },
},
registrations: [],
progressTokens: [],
progress: [],
}),
);
}
async function waitForDocumentSync(socket: FakeWebSocket): Promise<void> {
await vi.waitFor(() => {
expect(socket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD)).toBe(true);
});
}
function acknowledgeAttachment(socket: FakeWebSocket): void {
const acknowledgement = latestControl(socket, "attachmentReady");
socket.emitMessage(
JSON.stringify({
kandev: "lsp",
action: "attachmentReady",
requestId: acknowledgement.requestId,
}),
);
}
async function connectContinuityReady() {
const disconnect = lspClientManager.connect(SESSION_ID, "typescript", undefined, true);
const socket = FakeWebSocket.instances.at(-1);
if (!socket) throw new Error("expected an LSP WebSocket");
socket.open();
socket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
}),
);
const initialize = initializeRequest(socket);
socket.emitMessage(
JSON.stringify({
jsonrpc: "2.0",
id: initialize.id,
result: { capabilities: { definitionProvider: true } },
}),
);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
return { disconnect, socket };
}
beforeEach(() => {
lspClientManager.disconnectAll();
FakeWebSocket.instances = [];
vi.resetAllMocks();
vi.stubGlobal("WebSocket", FakeWebSocket);
mocks.waitForMonacoInstance.mockImplementation(async () => mocks.getMonacoInstance());
mocks.registerBuiltinTsSuppression.mockReturnValue({ dispose: vi.fn() });
sessionStorage.clear();
});
afterEach(() => {
lspClientManager.disconnectAll();
sessionStorage.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("LSP browser continuity", () => {
it("reattaches without initializing again and waits for current document synchronization", async () => {
const { markersByUri } = createMonacoHarness([DOCUMENT_MODEL_URI]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(4009, "browser transport lost");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
sendResumedHandshake(resumedSocket);
await waitForDocumentSync(resumedSocket);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialize")).toBe(
false,
);
expect(mocks.registerLspProviders).toHaveBeenCalledTimes(2);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
lspClientManager.changeDocument(
SESSION_ID,
"typescript",
DOCUMENT_URI,
"export const current = false;",
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === DID_CHANGE_METHOD)).toBe(
true,
);
publishDiagnostic(resumedSocket, DOCUMENT_URI, "too early");
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toEqual([]);
acknowledgeAttachment(resumedSocket);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
publishDiagnostic(resumedSocket, DOCUMENT_URI, "fresh diagnostic");
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toContain("fresh diagnostic");
});
});
describe("LSP document reconnect synchronization", () => {
it("reopens a document when the editor effect cleans up during a reconnect", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(4009, "browser transport lost");
lspClientManager.closeDocument(SESSION_ID, "typescript", DOCUMENT_URI);
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
sendResumedHandshake(resumedSocket);
await vi.waitFor(() =>
expect(
resumedSocket.sent.some((frame) => JSON.parse(frame).action === "attachmentReady"),
).toBe(true),
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD)).toBe(
false,
);
acknowledgeAttachment(resumedSocket);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
expect(
resumedSocket.sent.filter((frame) => JSON.parse(frame).method === DID_OPEN_METHOD),
).toHaveLength(1);
});
it("sends retained documents after a fresh initialize on a replacement backend", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(1001, "backend restarting");
await new Promise((resolve) => setTimeout(resolve, 275));
const replacementSocket = FakeWebSocket.instances.at(-1);
if (!replacementSocket || replacementSocket === firstSocket)
throw new Error("expected a replacement backend socket");
replacementSocket.open();
replacementSocket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-2",
resumed: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
}),
);
const initialize = initializeRequest(replacementSocket);
replacementSocket.emitMessage(
JSON.stringify({
jsonrpc: "2.0",
id: initialize.id,
result: { capabilities: { definitionProvider: true } },
}),
);
await vi.waitFor(() =>
expect(
replacementSocket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD),
).toBe(true),
);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" });
});
});
describe("LSP initialization continuation", () => {
it("finishes the one-time initialized notification when resuming mid-handshake", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
firstSocket.failClosed(4009, "browser transport lost");
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
resumedSocket.open();
resumedSocket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: true,
initialized: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
capabilities: { definitionProvider: true },
registrations: [],
progressTokens: [],
progress: [],
}),
);
await vi.waitFor(() =>
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialized")).toBe(
true,
),
);
expect(
resumedSocket.sent.findIndex((frame) => JSON.parse(frame).method === "initialized"),
).toBeLessThan(
resumedSocket.sent.findIndex((frame) => JSON.parse(frame).action === "attachmentReady"),
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialize")).toBe(
false,
);
});
});
describe("LSP browser continuity controls", () => {
it("retries a graceful backend shutdown after the browser reconnects", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
socket.failClosed(1001, "backend restarting");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
await new Promise((resolve) => setTimeout(resolve, LSP_RECONNECT_DELAYS_MS[0] + 25));
expect(FakeWebSocket.instances).toHaveLength(2);
});
it("does not let an old Stop acknowledgement timeout clean a replacement socket", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
vi.useFakeTimers();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4009, "browser transport lost during release");
await vi.advanceTimersByTimeAsync(LSP_RECONNECT_DELAYS_MS[0]);
const replacementSocket = FakeWebSocket.instances.at(-1);
if (!replacementSocket || replacementSocket === socket)
throw new Error("expected a replacement socket after the release transport closed");
replacementSocket.open();
await vi.advanceTimersByTimeAsync(LSP_RELEASE_ACK_TIMEOUT_MS + 10);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
expect(FakeWebSocket.instances).toContain(replacementSocket);
});
it("cleans up a Stop when the backend returns a terminal close code", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4002, "session not found");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({
state: "unavailable",
reason: expect.any(String),
cause: "workspace_unavailable",
});
});
it("does not reconnect after the backend stops the task runtime", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4010, "task runtime stopped");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "disabled" });
expect(FakeWebSocket.instances).toHaveLength(1);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("treats confirmed process exit as actionable and does not retry", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
socket.failClosed(4006, "language server exited");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({
state: "error",
reason: "language server exited",
});
expect(FakeWebSocket.instances).toHaveLength(1);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("releases its window lease only after explicit Stop is acknowledged", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "stopping" });
const release = latestControl(socket, "release");
socket.emitMessage(
JSON.stringify({ kandev: "lsp", action: "released", requestId: release.requestId }),
);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "disabled" }),
);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("releases after the last editor idle timeout", async () => {
vi.useFakeTimers();
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket, disconnect } = await connectContinuityReady();
disconnect();
await vi.advanceTimersByTimeAsync(LSP_IDLE_TIMEOUT);
const release = latestControl(socket, "release");
expect(
JSON.parse(socket.sent.find((frame) => JSON.parse(frame).action === "release") ?? "null"),
).toMatchObject({
reason: "editor_idle",
});
socket.emitMessage(
JSON.stringify({ kandev: "lsp", action: "released", requestId: release.requestId }),
);
await Promise.resolve();
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
});