From 5f8dad01a7813fca591b52cd2404424c479ad4b5 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:06:38 +0530 Subject: [PATCH 1/2] fix(lsp): preserve websocket clients across tab switches Keep externally managed WebSocket runtimes alive when editor views detach, retain eager cleanup for owned runtimes --- src/cm/lsp/clientManager.ts | 5 +- src/cm/lsp/runtimes/externalWebSocket.ts | 1 + src/cm/lsp/types.ts | 6 + .../lspExternalWebSocketLifecycle.test.js | 224 ++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 tests/unit/lspExternalWebSocketLifecycle.test.js diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index b4d3373be..6711860d7 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -1108,6 +1108,7 @@ export class LspClientManager { server, client, transportHandle, + keepAliveWhenIdle: runtimeProvider.keepAliveWhenIdle === true, normalizedRootUri, originalRootUri: scope === "document" ? null : originalRootUri, }); @@ -1121,6 +1122,7 @@ export class LspClientManager { server: LspServerDefinition; client: LSPClient; transportHandle: TransportHandle; + keepAliveWhenIdle: boolean; normalizedRootUri: string | null; originalRootUri: string | null; }): ClientState { @@ -1129,6 +1131,7 @@ export class LspClientManager { server, client, transportHandle, + keepAliveWhenIdle, normalizedRootUri, originalRootUri, } = params; @@ -1206,7 +1209,7 @@ export class LspClientManager { } } - if (!fileRefs.size) { + if (!fileRefs.size && !keepAliveWhenIdle) { this.options.onClientIdle?.({ server, client, diff --git a/src/cm/lsp/runtimes/externalWebSocket.ts b/src/cm/lsp/runtimes/externalWebSocket.ts index 0d0d7e6ad..8da8b7867 100644 --- a/src/cm/lsp/runtimes/externalWebSocket.ts +++ b/src/cm/lsp/runtimes/externalWebSocket.ts @@ -16,6 +16,7 @@ export const externalWebSocketRuntimeProvider: LspRuntimeProvider = { id: EXTERNAL_WEBSOCKET_RUNTIME_ID, label: "External WebSocket", priority: -50, + keepAliveWhenIdle: true, canHandle(server, context) { if (context.runtimeAction && server.launcher) { diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 1ffa6c4bd..8778c5f9c 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -140,6 +140,12 @@ export interface LspRuntimeProvider { id: string; label: string; priority?: number; + /** + * Keep an initialized client alive while no editor view is attached. + * Externally managed servers can be expensive to initialize and are not + * owned by Acode, so a temporary tab switch must not tear them down. + */ + keepAliveWhenIdle?: boolean; canHandle: ( server: LspServerDefinition, context: LspRuntimeContext, diff --git a/tests/unit/lspExternalWebSocketLifecycle.test.js b/tests/unit/lspExternalWebSocketLifecycle.test.js new file mode 100644 index 000000000..300d44e22 --- /dev/null +++ b/tests/unit/lspExternalWebSocketLifecycle.test.js @@ -0,0 +1,224 @@ +// @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 {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 DISPOSABLE_RUNTIME_ID = "disposable-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}); + } +} + +let originalWebSocket; +let manager; +let view; + +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(DISPOSABLE_RUNTIME_ID); + globalThis.WebSocket = originalWebSocket; + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("external WebSocket LSP lifecycle", () => { + it("reuses one client and socket when switching files in a workspace", 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, + }); + manager.detach(`${rootUri}/first.rs`, view); + + await manager.getExtensionsForFile({ + uri: `${rootUri}/second.rs`, + rootUri, + languageId: LANGUAGE_ID, + view, + }); + + expect(onClientIdle).not.toHaveBeenCalled(); + expect(manager.getActiveClients()).toHaveLength(1); + expect(TestWebSocket.instances).toHaveLength(1); + expect(TestWebSocket.instances[0].closeCalls).toBe(0); + }); + + it("preserves eager idle cleanup for runtimes without keep-alive", async () => { + registerRuntimeProvider( + { + id: DISPOSABLE_RUNTIME_ID, + label: "Disposable test runtime", + priority: 100, + canHandle: () => true, + start: async (server) => ({ + kind: "websocket", + providerId: DISPOSABLE_RUNTIME_ID, + url: server.transport.url, + }), + }, + {replace: true}, + ); + registry.servers[0].runtimes = [DISPOSABLE_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, + }); + manager.detach(uri, view); + await Promise.resolve(); + + expect(onClientIdle).toHaveBeenCalledOnce(); + expect(manager.getActiveClients()).toHaveLength(0); + expect(TestWebSocket.instances[0].closeCalls).toBe(1); + }); +}); From 3a45592c38256a72b7556cbe94748ce2c9a504bb Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:25:22 +0530 Subject: [PATCH 2/2] fix(lsp): dispose idle clients after grace period --- src/cm/lsp/clientManager.ts | 28 ++++- src/cm/lsp/runtimes/externalWebSocket.ts | 1 - src/cm/lsp/types.ts | 8 +- .../lspExternalWebSocketLifecycle.test.js | 113 +++++++++++++++--- 4 files changed, 122 insertions(+), 28 deletions(-) diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 6711860d7..11bb6f540 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -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({ // File-level marker used by the autocomplete override path. If any attached // server exposes completion, keep the shared LSP completion source available. @@ -1108,7 +1110,6 @@ export class LspClientManager { server, client, transportHandle, - keepAliveWhenIdle: runtimeProvider.keepAliveWhenIdle === true, normalizedRootUri, originalRootUri: scope === "document" ? null : originalRootUri, }); @@ -1122,7 +1123,6 @@ export class LspClientManager { server: LspServerDefinition; client: LSPClient; transportHandle: TransportHandle; - keepAliveWhenIdle: boolean; normalizedRootUri: string | null; originalRootUri: string | null; }): ClientState { @@ -1131,7 +1131,6 @@ export class LspClientManager { server, client, transportHandle, - keepAliveWhenIdle, normalizedRootUri, originalRootUri, } = params; @@ -1139,12 +1138,20 @@ export class LspClientManager { const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; let disposed = false; + let idleTimer: ReturnType | 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); @@ -1168,6 +1175,7 @@ export class LspClientManager { const dispose = async (): Promise => { if (disposed) return; disposed = true; + cancelIdleTimer(); disposePullDiagnostics(client); this.#clients.delete(key); for (const views of fileRefs.values()) { @@ -1209,14 +1217,24 @@ export class LspClientManager { } } - if (!fileRefs.size && !keepAliveWhenIdle) { + 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 { diff --git a/src/cm/lsp/runtimes/externalWebSocket.ts b/src/cm/lsp/runtimes/externalWebSocket.ts index 8da8b7867..0d0d7e6ad 100644 --- a/src/cm/lsp/runtimes/externalWebSocket.ts +++ b/src/cm/lsp/runtimes/externalWebSocket.ts @@ -16,7 +16,6 @@ export const externalWebSocketRuntimeProvider: LspRuntimeProvider = { id: EXTERNAL_WEBSOCKET_RUNTIME_ID, label: "External WebSocket", priority: -50, - keepAliveWhenIdle: true, canHandle(server, context) { if (context.runtimeAction && server.launcher) { diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 8778c5f9c..ad47e72df 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -140,12 +140,6 @@ export interface LspRuntimeProvider { id: string; label: string; priority?: number; - /** - * Keep an initialized client alive while no editor view is attached. - * Externally managed servers can be expensive to initialize and are not - * owned by Acode, so a temporary tab switch must not tear them down. - */ - keepAliveWhenIdle?: boolean; canHandle: ( server: LspServerDefinition, context: LspRuntimeContext, @@ -417,6 +411,8 @@ export interface ClientManagerOptions { displayFile?: (uri: string) => Promise; openFile?: (uri: string) => Promise; resolveLanguageId?: (uri: string) => string | null; + /** Delay before an unreferenced client is reported as idle. */ + clientIdleGracePeriodMs?: number; onClientIdle?: (info: ClientIdleInfo) => void; allowNonTerminalWorkspace?: boolean; } diff --git a/tests/unit/lspExternalWebSocketLifecycle.test.js b/tests/unit/lspExternalWebSocketLifecycle.test.js index 300d44e22..65b66dbcb 100644 --- a/tests/unit/lspExternalWebSocketLifecycle.test.js +++ b/tests/unit/lspExternalWebSocketLifecycle.test.js @@ -65,7 +65,10 @@ vi.mock("cm/lsp/tooltipExtensions", () => ({ signatureHelp: () => [], })); -import {LspClientManager} from "cm/lsp/clientManager"; +import { + DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS, + LspClientManager, +} from "cm/lsp/clientManager"; import { registerRuntimeProvider, unregisterRuntimeProvider, @@ -74,7 +77,7 @@ import externalWebSocketRuntimeProvider from "cm/lsp/runtimes/externalWebSocket" const SERVER_ID = "external-websocket-lifecycle-test"; const LANGUAGE_ID = "external-websocket-lifecycle-test"; -const DISPOSABLE_RUNTIME_ID = "disposable-lifecycle-test"; +const TRANSPORT_RUNTIME_ID = "transport-lifecycle-test"; class TestWebSocket { static CONNECTING = 0; @@ -125,9 +128,41 @@ class TestWebSocket { } } +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; @@ -154,14 +189,15 @@ afterEach(async () => { await manager?.dispose(); view?.destroy(); registry.servers = []; - unregisterRuntimeProvider(DISPOSABLE_RUNTIME_ID); + unregisterRuntimeProvider(TRANSPORT_RUNTIME_ID); globalThis.WebSocket = originalWebSocket; document.body.replaceChildren(); + vi.useRealTimers(); vi.restoreAllMocks(); }); -describe("external WebSocket LSP lifecycle", () => { - it("reuses one client and socket when switching files in a workspace", async () => { +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"; @@ -172,7 +208,9 @@ describe("external WebSocket LSP lifecycle", () => { 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`, @@ -180,6 +218,7 @@ describe("external WebSocket LSP lifecycle", () => { languageId: LANGUAGE_ID, view, }); + await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS + 1); expect(onClientIdle).not.toHaveBeenCalled(); expect(manager.getActiveClients()).toHaveLength(1); @@ -187,22 +226,56 @@ describe("external WebSocket LSP lifecycle", () => { expect(TestWebSocket.instances[0].closeCalls).toBe(0); }); - it("preserves eager idle cleanup for runtimes without keep-alive", async () => { + 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: DISPOSABLE_RUNTIME_ID, - label: "Disposable test runtime", + id: TRANSPORT_RUNTIME_ID, + label: "Transport test runtime", priority: 100, canHandle: () => true, - start: async (server) => ({ - kind: "websocket", - providerId: DISPOSABLE_RUNTIME_ID, - url: server.transport.url, - }), + 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 = [DISPOSABLE_RUNTIME_ID]; + registry.servers[0].runtimes = [TRANSPORT_RUNTIME_ID]; const onClientIdle = vi.fn(({dispose}) => void dispose()); manager = new LspClientManager({onClientIdle}); const rootUri = "file:///workspace"; @@ -214,11 +287,19 @@ describe("external WebSocket LSP lifecycle", () => { languageId: LANGUAGE_ID, view, }); + vi.useFakeTimers(); manager.detach(uri, view); - await Promise.resolve(); + 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(TestWebSocket.instances[0].closeCalls).toBe(1); + expect(testTransport.disposeCalls).toBe(1); }); });