From a3ca877071c7e12b1b70c31edcd4bcecc66f1d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grabarz?= Date: Fri, 29 Mar 2024 16:59:47 +0100 Subject: [PATCH] Improve gui app disposal handling and close all network connections --- app/gui2/shared/dataServer.ts | 11 ++- app/gui2/shared/languageServer.ts | 21 ++++- app/gui2/shared/util/net.ts | 39 +++++++++ app/gui2/shared/websocket.ts | 5 +- app/gui2/src/appRunner.ts | 3 +- .../src/components/GraphEditor/GraphNode.vue | 36 +++++--- app/gui2/src/createApp.ts | 22 ++++- .../stores/project/computedValueRegistry.ts | 2 +- app/gui2/src/stores/project/index.ts | 86 ++++++++++--------- .../project/visualizationDataRegistry.ts | 2 +- app/gui2/src/util/crdt.ts | 2 +- app/gui2/src/util/net.ts | 23 +++-- app/gui2/stories/Widgets.story.vue | 3 - app/gui2/ydoc-server/languageServerSession.ts | 10 ++- 14 files changed, 189 insertions(+), 76 deletions(-) create mode 100644 app/gui2/shared/util/net.ts diff --git a/app/gui2/shared/dataServer.ts b/app/gui2/shared/dataServer.ts index 748ce5c2e52c2..c6ca7d7296520 100644 --- a/app/gui2/shared/dataServer.ts +++ b/app/gui2/shared/dataServer.ts @@ -29,6 +29,7 @@ import { type Offset, type Table, } from './binaryProtocol' +import type { AbortScope } from './util/net' import { uuidFromBits, uuidToBits } from './uuid' import type { WebsocketClient } from './websocket' import type { Uuid } from './yjsModel' @@ -58,8 +59,12 @@ export class DataServer extends ObservableV2 { resolveCallbacks = new Map void>() /** `websocket.binaryType` should be `ArrayBuffer`. */ - constructor(public websocket: WebsocketClient) { + constructor( + public websocket: WebsocketClient, + abort: AbortScope, + ) { super() + abort.handleDispose(this) if (websocket.connected) { this.ready = Promise.resolve() } else { @@ -95,6 +100,10 @@ export class DataServer extends ObservableV2 { }) } + dispose() { + this.resolveCallbacks.clear() + } + async initialize(clientId: Uuid) { if (!this.initialized) { this.clientId = clientId diff --git a/app/gui2/shared/languageServer.ts b/app/gui2/shared/languageServer.ts index 958e3ea6e8a79..0f2ee00b49cba 100644 --- a/app/gui2/shared/languageServer.ts +++ b/app/gui2/shared/languageServer.ts @@ -21,6 +21,7 @@ import type { VisualizationConfiguration, response, } from './languageServerTypes' +import type { AbortScope } from './util/net' import type { Uuid } from './yjsModel' const DEBUG_LOG_RPC = false @@ -107,6 +108,7 @@ export class LsRpcError extends Error { export class LanguageServer extends ObservableV2 { client: Client handlers: Map void>> + retainCount = 1 constructor(client: Client) { super() @@ -124,6 +126,7 @@ export class LanguageServer extends ObservableV2 { // The "magic bag of holding" generic that is only present in the return type is UNSOUND. // However, it is SAFE, as the return type of the API is statically known. private async request(method: string, params: object): Promise { + if (this.retainCount === 0) return Promise.reject(new Error('LanguageServer disposed')) const uuid = uuidv4() const now = performance.now() try { @@ -432,8 +435,22 @@ export class LanguageServer extends ObservableV2 { } } - dispose() { - this.client.close() + retain() { + if (this.retainCount === 0) { + throw new Error('Trying to retain already disposed language server.') + } + this.retainCount += 1 + } + + release() { + if (this.retainCount > 0) { + this.retainCount -= 1 + if (this.retainCount === 0) { + this.client.close() + } + } else { + throw new Error('Released already disposed language server.') + } } } diff --git a/app/gui2/shared/util/net.ts b/app/gui2/shared/util/net.ts new file mode 100644 index 0000000000000..f468933a30190 --- /dev/null +++ b/app/gui2/shared/util/net.ts @@ -0,0 +1,39 @@ +import type { ObservableV2 } from 'lib0/observable' + +interface Disposable { + dispose(): void +} + +export class AbortScope { + private ctrl: AbortController = new AbortController() + get signal() { + return this.ctrl.signal + } + + dispose(reason?: string) { + this.ctrl.abort(reason) + } + + handleDispose(disposable: Disposable) { + this.signal.throwIfAborted() + this.onAbort(disposable.dispose.bind(disposable)) + } + + onAbort(listener: () => void) { + if (this.signal.aborted) { + setTimeout(listener, 0) + } else { + this.signal.addEventListener('abort', listener, { once: true }) + } + } + + handleObserve< + EVENTS extends { [key in keyof EVENTS]: (...arg0: any[]) => void }, + NAME extends keyof EVENTS & string, + >(observable: ObservableV2, name: NAME, f: EVENTS[NAME]) { + if (this.signal.aborted) return + observable.on(name, f) + this.onAbort(() => observable.off(name, f)) + return f + } +} diff --git a/app/gui2/shared/websocket.ts b/app/gui2/shared/websocket.ts index a8080f03045d1..47a41e2bfa1ee 100644 --- a/app/gui2/shared/websocket.ts +++ b/app/gui2/shared/websocket.ts @@ -38,6 +38,7 @@ import * as math from 'lib0/math' import { ObservableV2 } from 'lib0/observable' import * as time from 'lib0/time' +import type { AbortScope } from './util/net' const reconnectTimeoutBase = 1200 const maxReconnectTimeout = 2500 @@ -130,12 +131,14 @@ export class WebsocketClient extends ObservableV2 { protected _checkInterval constructor( public url: string, + abort: AbortScope, { binaryType, sendPings, }: { binaryType?: 'arraybuffer' | 'blob' | null; sendPings?: boolean } = {}, ) { super() + abort.handleDispose(this) this.ws = null this.binaryType = binaryType || null this.sendPings = sendPings ?? true @@ -168,7 +171,7 @@ export class WebsocketClient extends ObservableV2 { this.ws.send(encoded) } - destroy() { + dispose() { clearInterval(this._checkInterval) this.disconnect() super.destroy() diff --git a/app/gui2/src/appRunner.ts b/app/gui2/src/appRunner.ts index 3c722878883c8..a2ac38a40706b 100644 --- a/app/gui2/src/appRunner.ts +++ b/app/gui2/src/appRunner.ts @@ -24,7 +24,7 @@ async function runApp( // until GUI1 is removed, as GUI1 still needs them. const intermediateConfig = mergeConfig(baseConfig, urlParams()) const appConfig = mergeConfig(intermediateConfig, config ?? {}) - const app = await mountProjectApp( + unmount = await mountProjectApp( { config: appConfig, accessToken, @@ -32,7 +32,6 @@ async function runApp( }, pinia, ) - unmount = () => app.unmount() } function stopApp() { diff --git a/app/gui2/src/components/GraphEditor/GraphNode.vue b/app/gui2/src/components/GraphEditor/GraphNode.vue index c24eeb44bd681..aeff39abda80d 100644 --- a/app/gui2/src/components/GraphEditor/GraphNode.vue +++ b/app/gui2/src/components/GraphEditor/GraphNode.vue @@ -28,7 +28,8 @@ import { Vec2 } from '@/util/data/vec2' import { displayedIconOf } from '@/util/getIconName' import { setIfUndefined } from 'lib0/map' import type { VisualizationIdentifier } from 'shared/yjsModel' -import { computed, onUnmounted, ref, watch, watchEffect } from 'vue' +import type { EffectScope } from 'vue' +import { computed, effectScope, onScopeDispose, onUnmounted, ref, watch, watchEffect } from 'vue' const MAXIMUM_CLICK_LENGTH_MS = 300 const MAXIMUM_CLICK_DISTANCE_SQ = 50 @@ -347,25 +348,38 @@ const outputPorts = computed((): PortData[] => { }) const outputHovered = ref() -const hoverAnimations = new Map>() +const hoverAnimations = new Map, EffectScope]>() watchEffect(() => { const ports = outputPortsSet.value - for (const key of hoverAnimations.keys()) if (!ports.has(key)) hoverAnimations.delete(key) + for (const key of hoverAnimations.keys()) + if (!ports.has(key)) { + hoverAnimations.get(key)?.[1].stop() + hoverAnimations.delete(key) + } for (const port of outputPortsSet.value) { - setIfUndefined(hoverAnimations, port, () => - useApproach( - () => (outputHovered.value === port || graph.unconnectedEdge?.target === port ? 1 : 0), - 50, - 0.01, - ), - ) + setIfUndefined(hoverAnimations, port, () => { + // Because `useApproach` uses `onScopeDispose` and we are calling it dynamically (i.e. not at + // the setup top-level), we need to create a detached scope for each invocation. + const scope = effectScope(true) + const approach = scope.run(() => + useApproach( + () => (outputHovered.value === port || graph.unconnectedEdge?.target === port ? 1 : 0), + 50, + 0.01, + ), + )! + return [approach, scope] + }) } }) +// Clean up dynamically created detached scopes. +onScopeDispose(() => hoverAnimations.forEach(([_, scope]) => scope.stop())) + function portGroupStyle(port: PortData) { const [start, end] = port.clipRange return { - '--hover-animation': hoverAnimations.get(port.portId)?.value ?? 0, + '--hover-animation': hoverAnimations.get(port.portId)?.[0].value ?? 0, '--port-clip-start': start, '--port-clip-end': end, } diff --git a/app/gui2/src/createApp.ts b/app/gui2/src/createApp.ts index 752db0f6a61a7..8154e24b51a86 100644 --- a/app/gui2/src/createApp.ts +++ b/app/gui2/src/createApp.ts @@ -19,8 +19,26 @@ export async function mountProjectApp( await initializeFFI() initializePrefixes() + const usedPinia = pinia ?? createPinia() const app = createApp(App, rootProps) - app.use(pinia ?? createPinia()) + app.use(usedPinia) app.mount('#app') - return app + return () => { + app.unmount() + console.log('app unmounted') + disposePinia(usedPinia) + } +} + +// Hack: `disposePinia` is not yet officially released, but we desperately need this for correct app +// cleanup. Pasted code from git version seems to work fine. This should be replaced with pinia +// export once it is available. Code copied from: +// https://github.com/vuejs/pinia/blob/8835e98173d9443531a7d65dfed09c2a8c19975d/packages/pinia/src/createPinia.ts#L74 +export function disposePinia(pinia: Pinia) { + const anyPinia: any = pinia + anyPinia._e.stop() + anyPinia._s.clear() + anyPinia._p.splice(0) + anyPinia.state.value = {} + anyPinia._a = null } diff --git a/app/gui2/src/stores/project/computedValueRegistry.ts b/app/gui2/src/stores/project/computedValueRegistry.ts index 225192458c732..5924a3e6319f5 100644 --- a/app/gui2/src/stores/project/computedValueRegistry.ts +++ b/app/gui2/src/stores/project/computedValueRegistry.ts @@ -53,7 +53,7 @@ export class ComputedValueRegistry { return this.db.get(exprId) } - destroy() { + dispose() { this.executionContext?.off('expressionUpdates', this._updateHandler) } } diff --git a/app/gui2/src/stores/project/index.ts b/app/gui2/src/stores/project/index.ts index 0d31559e05dd7..97ff9e02cd14c 100644 --- a/app/gui2/src/stores/project/index.ts +++ b/app/gui2/src/stores/project/index.ts @@ -12,6 +12,7 @@ import { createRpcTransport, createWebsocketClient, rpcWithRetries as lsRpcWithRetries, + useAbortScope, } from '@/util/net' import { tryQualifiedName } from '@/util/qualifiedName' import { Client, RequestManager } from '@open-rpc/client-js' @@ -36,10 +37,12 @@ import type { StackItem, VisualizationConfiguration, } from 'shared/languageServerTypes' +import type { AbortScope } from 'shared/util/net' import { DistributedProject, localOrigins, type ExternalId, type Uuid } from 'shared/yjsModel' import { computed, markRaw, + onScopeDispose, reactive, ref, shallowRef, @@ -70,6 +73,7 @@ function resolveLsUrl(config: GuiConfig): LsUrls { async function initializeLsRpcConnection( clientId: Uuid, url: string, + abort: AbortScope, ): Promise<{ connection: LanguageServer contentRoots: ContentRoot[] @@ -78,6 +82,7 @@ async function initializeLsRpcConnection( const requestManager = new RequestManager([transport]) const client = new Client(requestManager) const connection = new LanguageServer(client) + abort.onAbort(() => connection.release()) const initialization = await lsRpcWithRetries(() => connection.initProtocolConnection(clientId), { onBeforeRetry: (error, _, delay) => { console.warn( @@ -93,9 +98,10 @@ async function initializeLsRpcConnection( return { connection, contentRoots } } -async function initializeDataConnection(clientId: Uuid, url: string) { - const client = createWebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false }) - const connection = new DataServer(client) +async function initializeDataConnection(clientId: Uuid, url: string, abort: AbortScope) { + const client = createWebsocketClient(url, abort, { binaryType: 'arraybuffer', sendPings: false }) + const connection = new DataServer(client, abort) + onScopeDispose(() => connection.dispose()) await connection.initialize(clientId).catch((error) => { console.error('Error initializing data connection:', error) throw error @@ -168,14 +174,14 @@ export class ExecutionContext extends ObservableV2 visSyncScheduled = false desiredStack: StackItem[] = reactive([]) visualizationConfigs: Map = new Map() - abortCtl = new AbortController() - constructor(lsRpc: Promise, entryPoint: EntryPoint) { + constructor( + lsRpc: Promise, + entryPoint: EntryPoint, + private abort: AbortScope, + ) { super() - - this.abortCtl.signal.addEventListener('abort', () => { - this.queue.clear() - }) + this.abort.handleDispose(this) this.queue = new AsyncQueue( lsRpc.then((lsRpc) => ({ @@ -194,7 +200,7 @@ export class ExecutionContext extends ObservableV2 private withBackoff(f: () => Promise, message: string): Promise { return lsRpcWithRetries(f, { onBeforeRetry: (error, _, delay) => { - if (this.abortCtl.signal.aborted) return false + if (this.abort.signal.aborted) return false console.warn( `${message}: ${error.payload.cause.message}. Retrying after ${delay}ms...\n`, error, @@ -204,11 +210,11 @@ export class ExecutionContext extends ObservableV2 } private syncVisualizations() { - if (this.visSyncScheduled) return + if (this.visSyncScheduled || this.abort.signal.aborted) return this.visSyncScheduled = true this.queue.pushTask(async (state) => { this.visSyncScheduled = false - if (!state.created) return state + if (!state.created || this.abort.signal.aborted) return state this.emit('newVisualizationConfiguration', [new Set(this.visualizationConfigs.keys())]) const promises: Promise[] = [] @@ -347,33 +353,28 @@ export class ExecutionContext extends ObservableV2 if (result.contextId !== this.id) { throw new Error('Unexpected Context ID returned by the language server.') } + state.lsRpc.retain() return { ...state, created: true } }, 'Failed to create execution context') }) - this.abortCtl.signal.addEventListener('abort', () => { - this.queue.pushTask(async (state) => { - if (!state.created) return state - await state.lsRpc.destroyExecutionContext(this.id) - return { ...state, created: false } - }) - }) } private registerHandlers() { this.queue.pushTask(async (state) => { - const expressionUpdates = state.lsRpc.on('executionContext/expressionUpdates', (event) => { + this.abort.handleObserve(state.lsRpc, 'executionContext/expressionUpdates', (event) => { if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates]) }) - const executionFailed = state.lsRpc.on('executionContext/executionFailed', (event) => { + this.abort.handleObserve(state.lsRpc, 'executionContext/executionFailed', (event) => { if (event.contextId == this.id) this.emit('executionFailed', [event.message]) }) - const executionComplete = state.lsRpc.on('executionContext/executionComplete', (event) => { + this.abort.handleObserve(state.lsRpc, 'executionContext/executionComplete', (event) => { if (event.contextId == this.id) this.emit('executionComplete', []) }) - const executionStatus = state.lsRpc.on('executionContext/executionStatus', (event) => { + this.abort.handleObserve(state.lsRpc, 'executionContext/executionStatus', (event) => { if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics]) }) - const visualizationEvaluationFailed = state.lsRpc.on( + this.abort.handleObserve( + state.lsRpc, 'executionContext/visualizationEvaluationFailed', (event) => { if (event.contextId == this.id) @@ -385,16 +386,6 @@ export class ExecutionContext extends ObservableV2 ]) }, ) - this.abortCtl.signal.addEventListener('abort', () => { - state.lsRpc.off('executionContext/expressionUpdates', expressionUpdates) - state.lsRpc.off('executionContext/executionFailed', executionFailed) - state.lsRpc.off('executionContext/executionComplete', executionComplete) - state.lsRpc.off('executionContext/executionStatus', executionStatus) - state.lsRpc.off( - 'executionContext/visualizationEvaluationFailed', - visualizationEvaluationFailed, - ) - }) return state }) } @@ -425,8 +416,13 @@ export class ExecutionContext extends ObservableV2 }) } - destroy() { - this.abortCtl.abort() + dispose() { + this.queue.pushTask(async (state) => { + if (!state.created) return state + await state.lsRpc.destroyExecutionContext(this.id) + state.lsRpc.release() + return { ...state, created: false } + }) } } @@ -436,6 +432,8 @@ export class ExecutionContext extends ObservableV2 * client, it is submitted to the language server as a document update. */ export const useProjectStore = defineStore('project', () => { + const abort = useAbortScope() + const observedFileName = ref() const doc = new Y.Doc() @@ -448,7 +446,7 @@ export const useProjectStore = defineStore('project', () => { const clientId = random.uuidv4() as Uuid const lsUrls = resolveLsUrl(config.value) - const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl) + const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl, abort) const lsRpcConnection = initializedConnection.then( ({ connection }) => connection, (error) => { @@ -463,8 +461,8 @@ export const useProjectStore = defineStore('project', () => { throw error }, ) - const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl) + const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl, abort) const rpcUrl = new URL(lsUrls.rpcUrl) const isOnLocalBackend = rpcUrl.protocol === 'mock:' || @@ -550,10 +548,14 @@ export const useProjectStore = defineStore('project', () => { }) function createExecutionContextForMain(): ExecutionContext { - return new ExecutionContext(lsRpcConnection, { - methodPointer: entryPoint.value, - positionalArgumentsExpressions: [], - }) + return new ExecutionContext( + lsRpcConnection, + { + methodPointer: entryPoint.value, + positionalArgumentsExpressions: [], + }, + abort, + ) } const firstExecution = lsRpcConnection.then( diff --git a/app/gui2/src/stores/project/visualizationDataRegistry.ts b/app/gui2/src/stores/project/visualizationDataRegistry.ts index ee6cff1ce938b..cba55a9c09a6f 100644 --- a/app/gui2/src/stores/project/visualizationDataRegistry.ts +++ b/app/gui2/src/stores/project/visualizationDataRegistry.ts @@ -86,7 +86,7 @@ export class VisualizationDataRegistry { return this.visualizationValues.get(visualizationId) ?? null } - destroy() { + dispose() { this.executionContext.off('visualizationsConfigured', this.reconfiguredHandler) this.dataServer.then((data) => { data.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, this.dataHandler) diff --git a/app/gui2/src/util/crdt.ts b/app/gui2/src/util/crdt.ts index 38bdebb419849..011c5176f6c6d 100644 --- a/app/gui2/src/util/crdt.ts +++ b/app/gui2/src/util/crdt.ts @@ -89,7 +89,7 @@ export function attachProvider( subdocProvider.dispose() }) } - return { provider, dispose: dispose } + return { provider, dispose } } interface MockYdocProviderMessages { diff --git a/app/gui2/src/util/net.ts b/app/gui2/src/util/net.ts index 83894362b2d01..bc0fb67b15a4a 100644 --- a/app/gui2/src/util/net.ts +++ b/app/gui2/src/util/net.ts @@ -5,11 +5,13 @@ import type { JSONRPCRequestData, } from '@open-rpc/client-js/build/Request' import { Transport } from '@open-rpc/client-js/build/transports/Transport' -import type { ArgumentsType } from '@vueuse/core' +import { type ArgumentsType } from '@vueuse/core' import { wait } from 'lib0/promise' import { LsRpcError } from 'shared/languageServer' import type { Notifications } from 'shared/languageServerTypes' +import { AbortScope } from 'shared/util/net' import { WebsocketClient } from 'shared/websocket' +import { onScopeDispose } from 'vue' export interface BackoffOptions { maxRetries?: number @@ -82,20 +84,22 @@ export function createRpcTransport(url: string): Transport { const mockName = url.slice('mock://'.length) return new MockTransport(mockName) } else { - return new WebSocketTransport(url) + const transport = new WebSocketTransport(url) + return transport } } export function createWebsocketClient( url: string, + abort: AbortScope, options?: { binaryType?: 'arraybuffer' | 'blob' | null; sendPings?: boolean }, ): WebsocketClient { if (url.startsWith('mock://')) { - const mockWs = new MockWebSocketClient(url) + const mockWs = new MockWebSocketClient(url, abort) if (options?.binaryType) mockWs.binaryType = options.binaryType return mockWs } else { - const client = new WebsocketClient(url, options) + const client = new WebsocketClient(url, abort, options) client.connect() return client } @@ -185,8 +189,8 @@ export class MockWebSocket extends EventTarget implements WebSocket { } export class MockWebSocketClient extends WebsocketClient { - constructor(url: string) { - super(url) + constructor(url: string, abort: AbortScope) { + super(url, abort) super.connect(new MockWebSocket(url, url.slice('mock://'.length))) } } @@ -244,3 +248,10 @@ export class AsyncQueue { return lastState } } + +/** Create an abort signal that is signalled when containing Vue scope is disposed. */ +export function useAbortScope(): AbortScope { + const scope = new AbortScope() + onScopeDispose(() => scope.dispose('Vue scope disposed.')) + return scope +} diff --git a/app/gui2/stories/Widgets.story.vue b/app/gui2/stories/Widgets.story.vue index 4e21c1b7f50f9..08447404ba426 100644 --- a/app/gui2/stories/Widgets.story.vue +++ b/app/gui2/stories/Widgets.story.vue @@ -4,7 +4,6 @@ import { computed, ref } from 'vue' import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue' import DropdownWidget from '@/components/widgets/DropdownWidget.vue' -import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue' import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue' // === Checkbox props === @@ -53,8 +52,6 @@ const values = ref(['address', 'age', 'id', 'language', 'location', 'workplace'] - - diff --git a/app/gui2/ydoc-server/languageServerSession.ts b/app/gui2/ydoc-server/languageServerSession.ts index ee7be21cf1210..e3f67455f936a 100644 --- a/app/gui2/ydoc-server/languageServerSession.ts +++ b/app/gui2/ydoc-server/languageServerSession.ts @@ -10,6 +10,7 @@ import { EnsoFileParts, combineFileParts, splitFileContents } from '../shared/en import { LanguageServer, computeTextChecksum } from '../shared/languageServer' import { Checksum, FileEdit, Path, TextEdit, response } from '../shared/languageServerTypes' import { exponentialBackoff, printingCallbacks } from '../shared/retry' +import { AbortScope } from '../shared/util/net' import { DistributedProject, ExternalId, @@ -54,8 +55,10 @@ export class LanguageServerSession { model: DistributedProject projectRootId: Uuid | null authoritativeModules: Map + clientScope: AbortScope constructor(url: string) { + this.clientScope = new AbortScope() this.clientId = random.uuidv4() as Uuid this.docs = new Map() this.retainCount = 0 @@ -92,8 +95,8 @@ export class LanguageServerSession { } private restartClient() { - this.client.close() - this.ls.destroy() + this.clientScope.dispose('Client restarted.') + this.clientScope = new AbortScope() this.connection = undefined this.setupClient() } @@ -101,6 +104,7 @@ export class LanguageServerSession { private setupClient() { this.client = createOpenRPCClient(this.url) this.ls = new LanguageServer(this.client) + this.clientScope.onAbort(() => this.ls.release()) this.ls.on('file/event', async (event) => { if (DEBUG_LOG_SYNC) { console.log('file/event', event) @@ -224,7 +228,7 @@ export class LanguageServerSession { const moduleDisposePromises = Array.from(modules, (mod) => mod.dispose()) this.authoritativeModules.clear() this.model.doc.destroy() - this.ls.dispose() + this.clientScope.dispose('LangueServerSession disposed.') LanguageServerSession.sessions.delete(this.url) await Promise.all(moduleDisposePromises) }