Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/cm/lsp/clientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import type {
} from "./types";
import AcodeWorkspace from "./workspace";

export const DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS = 15_000;

export const lspCompletionEnabled = Facet.define<boolean, boolean>({
// File-level marker used by the autocomplete override path. If any attached
// server exposes completion, keep the shared LSP completion source available.
Expand Down Expand Up @@ -1136,12 +1138,20 @@ export class LspClientManager {
const uriAliases = new Map<string, string>();
const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null;
let disposed = false;
let idleTimer: ReturnType<typeof setTimeout> | null = null;

const cancelIdleTimer = (): void => {
if (idleTimer === null) return;
clearTimeout(idleTimer);
idleTimer = null;
};

const attach = (
uri: string,
view: EditorView,
aliases: string[] = [],
): void => {
cancelIdleTimer();
const existing = fileRefs.get(uri) ?? new Set();
existing.add(view);
fileRefs.set(uri, existing);
Expand All @@ -1165,6 +1175,7 @@ export class LspClientManager {
const dispose = async (): Promise<void> => {
if (disposed) return;
disposed = true;
cancelIdleTimer();
disposePullDiagnostics(client);
this.#clients.delete(key);
for (const views of fileRefs.values()) {
Expand Down Expand Up @@ -1206,14 +1217,24 @@ export class LspClientManager {
}
}

if (!fileRefs.size) {
if (fileRefs.size || idleTimer !== null) return;

const configuredGracePeriod = this.options.clientIdleGracePeriodMs;
const gracePeriod =
typeof configuredGracePeriod === "number" &&
Number.isFinite(configuredGracePeriod)
? Math.max(0, configuredGracePeriod)
: DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS;
idleTimer = setTimeout(() => {
idleTimer = null;
if (disposed || fileRefs.size) return;
this.options.onClientIdle?.({
server,
client,
rootUri: effectiveRoot,
dispose,
});
}
}, gracePeriod);
};

return {
Expand Down
2 changes: 2 additions & 0 deletions src/cm/lsp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ export interface ClientManagerOptions {
displayFile?: (uri: string) => Promise<EditorView | null>;
openFile?: (uri: string) => Promise<EditorView | null>;
resolveLanguageId?: (uri: string) => string | null;
/** Delay before an unreferenced client is reported as idle. */
clientIdleGracePeriodMs?: number;
onClientIdle?: (info: ClientIdleInfo) => void;
allowNonTerminalWorkspace?: boolean;
}
Expand Down
305 changes: 305 additions & 0 deletions tests/unit/lspExternalWebSocketLifecycle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
// @vitest-environment happy-dom

import {StateEffect} from "@codemirror/state";
import {EditorView} from "@codemirror/view";
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";

const registry = vi.hoisted(() => ({servers: []}));

// Keep the lifecycle test independent of app UI modules that use JSX in .js
// files, which Vitest's native import analysis does not transform.
vi.mock("cm/lsp/serverRegistry", () => ({
default: {
getServersForLanguage: (languageId) =>
registry.servers.filter(
(server) => server.enabled !== false && server.languages.includes(languageId),
),
},
}));

vi.mock("components/lspStatusBar", () => ({
default: {
show: vi.fn(),
update: vi.fn(),
hideById: vi.fn(),
},
}));

vi.mock("components/settingsPage", () => ({default: vi.fn()}));
vi.mock("components/checkbox", () => ({
default: vi.fn(),
updateSwitchHandle: vi.fn(),
}));

vi.mock("lib/notificationManager", () => ({
default: {add: vi.fn()},
}));

vi.mock("lib/settings", () => ({
default: {value: {lsp: {}}},
}));

vi.mock("cm/lsp/diagnostics", () => ({
clearDiagnosticsEffect: StateEffect.define(),
disposePullDiagnostics: vi.fn(),
lspDiagnosticsAutoSyncExtension: () => [],
}));

vi.mock("cm/lsp/documentColors", () => ({
documentColorsExtension: () => [],
}));

vi.mock("cm/lsp/formattingSupport", () => ({
supportsBuiltinFormatting: () => false,
}));

vi.mock("cm/lsp/inlayHints", () => ({
inlayHintsExtension: () => [],
}));

vi.mock("cm/lsp/logs", () => ({addLspLog: vi.fn()}));

vi.mock("cm/lsp/tooltipExtensions", () => ({
hoverTooltips: () => [],
resolveLspHoverHighlightLanguage: vi.fn(),
signatureHelp: () => [],
}));

import {
DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS,
LspClientManager,
} from "cm/lsp/clientManager";
import {
registerRuntimeProvider,
unregisterRuntimeProvider,
} from "cm/lsp/runtimeProviders";
import externalWebSocketRuntimeProvider from "cm/lsp/runtimes/externalWebSocket";

const SERVER_ID = "external-websocket-lifecycle-test";
const LANGUAGE_ID = "external-websocket-lifecycle-test";
const TRANSPORT_RUNTIME_ID = "transport-lifecycle-test";

class TestWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances = [];

readyState = TestWebSocket.CONNECTING;
onopen = null;
onmessage = null;
onerror = null;
onclose = null;
sent = [];
closeCalls = 0;

constructor(url) {
this.url = url;
TestWebSocket.instances.push(this);
queueMicrotask(() => {
this.readyState = TestWebSocket.OPEN;
this.onopen?.({type: "open"});
});
}

send(data) {
if (this.readyState !== TestWebSocket.OPEN) {
throw new Error("socket is not open");
}
this.sent.push(data);
const message = JSON.parse(data);
if (message.method !== "initialize") return;
queueMicrotask(() => {
this.onmessage?.({
data: JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {capabilities: {}},
}),
});
});
}

close(code = 1000) {
this.closeCalls++;
this.readyState = TestWebSocket.CLOSED;
this.onclose?.({code, wasClean: code === 1000});
}
}

class TestTransport {
handler = null;
disposeCalls = 0;

send(data) {
const message = JSON.parse(data);
if (message.method !== "initialize") return;
queueMicrotask(() => {
this.handler?.(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {capabilities: {}},
}),
);
});
}

subscribe(handler) {
this.handler = handler;
}

unsubscribe(handler) {
if (this.handler === handler) this.handler = null;
}

dispose() {
this.disposeCalls++;
}
}

let originalWebSocket;
let manager;
let view;
let testTransport;

beforeEach(() => {
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = TestWebSocket;
TestWebSocket.instances = [];
registerRuntimeProvider(externalWebSocketRuntimeProvider, {replace: true});
registry.servers = [
{
id: SERVER_ID,
label: SERVER_ID,
enabled: true,
priority: 0,
languages: [LANGUAGE_ID],
transport: {
kind: "websocket",
url: "ws://localhost:3030",
},
},
];
view = new EditorView({doc: "fn main() {}", parent: document.body});
});

afterEach(async () => {
await manager?.dispose();
view?.destroy();
registry.servers = [];
unregisterRuntimeProvider(TRANSPORT_RUNTIME_ID);
globalThis.WebSocket = originalWebSocket;
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("LSP client idle lifecycle", () => {
it("reuses an external WebSocket client when a file attaches during the grace period", async () => {
const onClientIdle = vi.fn(({dispose}) => void dispose());
manager = new LspClientManager({onClientIdle});
const rootUri = "file:///workspace";

await manager.getExtensionsForFile({
uri: `${rootUri}/first.rs`,
rootUri,
languageId: LANGUAGE_ID,
view,
});
vi.useFakeTimers();
manager.detach(`${rootUri}/first.rs`, view);
await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS - 1);

await manager.getExtensionsForFile({
uri: `${rootUri}/second.rs`,
rootUri,
languageId: LANGUAGE_ID,
view,
});
await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS + 1);

expect(onClientIdle).not.toHaveBeenCalled();
expect(manager.getActiveClients()).toHaveLength(1);
expect(TestWebSocket.instances).toHaveLength(1);
expect(TestWebSocket.instances[0].closeCalls).toBe(0);
});

it("disposes an unused external WebSocket client after the grace period", async () => {
const onClientIdle = vi.fn(({dispose}) => void dispose());
manager = new LspClientManager({onClientIdle});
const rootUri = "file:///workspace-one";
const uri = `${rootUri}/only.rs`;

await manager.getExtensionsForFile({
uri,
rootUri,
languageId: LANGUAGE_ID,
view,
});
vi.useFakeTimers();
manager.detach(uri, view);
await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS - 1);

expect(onClientIdle).not.toHaveBeenCalled();
expect(manager.getActiveClients()).toHaveLength(1);
expect(TestWebSocket.instances[0].closeCalls).toBe(0);

await vi.advanceTimersByTimeAsync(1);

expect(onClientIdle).toHaveBeenCalledOnce();
expect(manager.getActiveClients()).toHaveLength(0);
expect(TestWebSocket.instances[0].closeCalls).toBe(1);
});

it("applies the same grace period to other runtimes", async () => {
registerRuntimeProvider(
{
id: TRANSPORT_RUNTIME_ID,
label: "Transport test runtime",
priority: 100,
canHandle: () => true,
start: async () => {
testTransport = new TestTransport();
return {
kind: "transport",
providerId: TRANSPORT_RUNTIME_ID,
transport: {
transport: testTransport,
ready: Promise.resolve(),
dispose: () => testTransport.dispose(),
},
};
},
},
{replace: true},
);
registry.servers[0].runtimes = [TRANSPORT_RUNTIME_ID];
const onClientIdle = vi.fn(({dispose}) => void dispose());
manager = new LspClientManager({onClientIdle});
const rootUri = "file:///workspace";
const uri = `${rootUri}/only.rs`;

await manager.getExtensionsForFile({
uri,
rootUri,
languageId: LANGUAGE_ID,
view,
});
vi.useFakeTimers();
manager.detach(uri, view);
await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS - 1);

expect(onClientIdle).not.toHaveBeenCalled();
expect(manager.getActiveClients()).toHaveLength(1);
expect(TestWebSocket.instances).toHaveLength(0);
expect(testTransport.disposeCalls).toBe(0);

await vi.advanceTimersByTimeAsync(1);

expect(onClientIdle).toHaveBeenCalledOnce();
expect(manager.getActiveClients()).toHaveLength(0);
expect(testTransport.disposeCalls).toBe(1);
});
});