From 5a48fb67cebf50c41f03ee8eb14b8956168fd640 Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr <tortmayr@eclipsesource.com> Date: Fri, 6 Dec 2024 11:19:48 +0100 Subject: [PATCH] GLSP-1438: Improve GLSPClient default implementations (#402) - Introduce `onCurrentStateChanged` event for `GLSPClient` - Ensure that `start` and `initializeServer` behave as expected if they are called again while the previous promise is still pending. - Restructure members of the default implementations so that get/setters and their _property are grouped together - Introduce a dedicated namespace for the `Event` interface with utility functions to - listen on a certain event exactly once - waitUntil a certain event is fired the next time - Adapt test cases to verify new behavior Also: - Update `GLSPActionDispatcher` to use direct injection for registry instead of async provider --- packages/client/src/base/action-dispatcher.ts | 16 ++- .../base-glsp-client.spec.ts | 54 +++++++--- .../base-glsp-client.ts | 66 +++++++++---- .../src/client-server-protocol/glsp-client.ts | 5 + .../jsonrpc/base-jsonrpc-glsp-client.spec.ts | 89 +++++++++++++++-- .../jsonrpc/base-jsonrpc-glsp-client.ts | 99 ++++++++++++------- packages/protocol/src/utils/event.spec.ts | 70 +++++++++++++ packages/protocol/src/utils/event.ts | 43 ++++++++ 8 files changed, 366 insertions(+), 76 deletions(-) create mode 100644 packages/protocol/src/utils/event.spec.ts diff --git a/packages/client/src/base/action-dispatcher.ts b/packages/client/src/base/action-dispatcher.ts index bdd36355..d6c7aa84 100644 --- a/packages/client/src/base/action-dispatcher.ts +++ b/packages/client/src/base/action-dispatcher.ts @@ -16,12 +16,14 @@ import { Action, ActionDispatcher, + ActionHandlerRegistry, EMPTY_ROOT, GModelRoot, IActionDispatcher, RequestAction, ResponseAction, - SetModelAction + SetModelAction, + TYPES } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { GLSPActionHandlerRegistry } from './action-handler-registry'; @@ -37,6 +39,12 @@ export class GLSPActionDispatcher extends ActionDispatcher implements IGModelRoo @inject(ModelInitializationConstraint) protected initializationConstraint: ModelInitializationConstraint; + @inject(ActionHandlerRegistry) + protected override actionHandlerRegistry: ActionHandlerRegistry; + + /** @deprecated No longer in used. The {@link ActionHandlerRegistry} is now directly injected */ + // eslint-disable-next-line deprecation/deprecation + @inject(TYPES.ActionHandlerRegistryProvider) protected override actionHandlerRegistryProvider: () => Promise<ActionHandlerRegistry>; protected postUpdateQueue: Action[] = []; override initialize(): Promise<void> { @@ -47,10 +55,8 @@ export class GLSPActionDispatcher extends ActionDispatcher implements IGModelRoo } protected async doInitialize(): Promise<void> { - const registry = await this.actionHandlerRegistryProvider(); - this.actionHandlerRegistry = registry; - if (registry instanceof GLSPActionHandlerRegistry) { - registry.initialize(); + if (this.actionHandlerRegistry instanceof GLSPActionHandlerRegistry) { + this.actionHandlerRegistry.initialize(); } this.handleAction(SetModelAction.create(EMPTY_ROOT)).catch(() => { /* Logged in handleAction method */ diff --git a/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts b/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts index d8f1683a..d24ac730 100644 --- a/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts +++ b/packages/protocol/src/client-server-protocol/base-glsp-client.spec.ts @@ -70,31 +70,48 @@ describe('Node GLSP Client', () => { describe('start', () => { it('should fail if no server is configured', async () => { resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); client.setStartupTimeout(5); await expectToThrowAsync(() => client.start()); expect(client.currentState).to.be.equal(ClientState.StartFailed); + expect(stateChangeHandler.calledWith(ClientState.StartFailed)).to.be.true; }); it('Should resolve when server is configured', async () => { resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); + const started = client.start(); + expect(client.currentState).to.be.equal(ClientState.Starting); + expect(stateChangeHandler.calledWith(ClientState.Starting)).to.be.true; client.configureServer(server); - const result = await client.start(); - expect(result).to.be.undefined; + await started; expect(client.currentState).to.be.equal(ClientState.Running); + expect(stateChangeHandler.calledWith(ClientState.Running)).to.be.true; }); }); describe('stop & onStop', () => { - beforeEach(() => resetClient()); it('onStop should not resolve if stop has not been called', () => { + resetClient(); expect(util.inspect(client.onStop())).to.include('pending'); }); it('should be in stopped state and onStop should resolve', async () => { + resetClient(); expect(client.currentState).to.be.not.equal(ClientState.Stopped); - const stopResult = await client.stop(); - expect(stopResult).to.be.undefined; + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); + await client.stop(); + expect(client.currentState).to.be.equal(ClientState.Stopped); + expect(stateChangeHandler.calledWith(ClientState.Stopping)).to.be.true; + expect(stateChangeHandler.calledWith(ClientState.Stopped)).to.be.true; + expect(server.shutdown.calledOnce).to.be.true; + }); + it('should only stop a running client once, if stop is called multiple times ', async () => { + resetClient(); + client.stop(); + await client.stop(); expect(client.currentState).to.be.equal(ClientState.Stopped); - const onStopResult = await client.onStop(); - expect(onStopResult).to.be.undefined; expect(server.shutdown.calledOnce).to.be.true; }); }); @@ -104,12 +121,14 @@ describe('Node GLSP Client', () => { resetClient(false); await expectToThrowAsync(() => client.initializeServer({ applicationId: '', protocolVersion: '' })); expect(server.initialize.called).to.be.false; + expect(client.initializeResult).to.be.undefined; }); it('should fail if client is not running', async () => { resetClient(false); client.configureServer(server); await expectToThrowAsync(() => client.initializeServer({ applicationId: '', protocolVersion: '' })); expect(server.initialize.called).to.be.false; + expect(client.initializeResult).to.be.undefined; }); it('should invoke the corresponding server method', async () => { resetClient(); @@ -122,17 +141,17 @@ describe('Node GLSP Client', () => { expect(client.initializeResult).to.be.equal(result); }); it('should return cached result on consecutive invocation', async () => { - await resetClient(); + resetClient(); const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; const params = { applicationId: 'id', protocolVersion: '1.0.0' }; server.initialize.returns(Promise.resolve(expectedResult)); - client['_initializeResult'] = expectedResult; + client.initializeServer(params); const result = await client.initializeServer(params); expect(result).to.be.deep.equal(client.initializeResult); - expect(server.initialize.called).to.be.false; + expect(server.initialize.calledOnce).to.be.true; }); it('should fire event on first invocation', async () => { - await resetClient(); + resetClient(); const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; const params = { applicationId: 'id', protocolVersion: '1.0.0' }; server.initialize.returns(Promise.resolve(expectedResult)); @@ -144,6 +163,19 @@ describe('Node GLSP Client', () => { await client.initializeServer(params); expect(eventHandlerSpy.calledOnceWith(expectedResult)).to.be.true; }); + it('should not use cached result on consecutive invocation if previous invocation errored', async () => { + resetClient(); + const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; + const params = { applicationId: 'id', protocolVersion: '1.0.0' }; + server.initialize.throws(new Error('error')); + expectToThrowAsync(() => client.initializeServer(params)); + expect(client.initializeResult).to.be.undefined; + server.initialize.returns(Promise.resolve(expectedResult)); + const result = await client.initializeServer(params); + expect(result).to.be.deep.equal(expectedResult); + expect(server.initialize.calledTwice).to.be.true; + expect(client.initializeResult).to.be.equal(result); + }); }); describe('initializeClientSession', () => { diff --git a/packages/protocol/src/client-server-protocol/base-glsp-client.ts b/packages/protocol/src/client-server-protocol/base-glsp-client.ts index d8cc78c4..084b2711 100644 --- a/packages/protocol/src/client-server-protocol/base-glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/base-glsp-client.ts @@ -30,21 +30,49 @@ export const GLOBAL_HANDLER_ID = '*'; * directly communicates with a given {@link GLSPServer} instance. */ export class BaseGLSPClient implements GLSPClient { - protected state: ClientState; - protected _server?: GLSPServer; protected serverDeferred = new Deferred<GLSPServer>(); protected onStartDeferred = new Deferred<void>(); protected onStopDeferred = new Deferred<void>(); readonly proxy: GLSPClientProxy; protected startupTimeout = 1500; protected actionMessageHandlers: Map<string, ActionMessageHandler[]> = new Map([[GLOBAL_HANDLER_ID, []]]); - protected _initializeResult: InitializeResult; + protected pendingServerInitialize?: Promise<InitializeResult>; protected onServerInitializedEmitter = new Emitter<InitializeResult>(); get onServerInitialized(): Event<InitializeResult> { return this.onServerInitializedEmitter.event; } + protected onCurrentStateChangedEmitter = new Emitter<ClientState>(); + get onCurrentStateChanged(): Event<ClientState> { + return this.onCurrentStateChangedEmitter.event; + } + + protected _state: ClientState; + protected set state(state: ClientState) { + if (this._state !== state) { + this._state = state; + this.onCurrentStateChangedEmitter.fire(state); + } + } + protected get state(): ClientState { + return this._state; + } + + protected _server?: GLSPServer; + protected get checkedServer(): GLSPServer { + this.checkState(); + if (!this._server) { + throw new Error(`No server is configured for GLSPClient with id '${this.id}'`); + } + return this._server; + } + + protected _initializeResult?: InitializeResult; + get initializeResult(): InitializeResult | undefined { + return this._initializeResult; + } + constructor(protected options: GLSPClient.Options) { this.state = ClientState.Initial; this.proxy = this.createProxy(); @@ -71,7 +99,7 @@ export class BaseGLSPClient implements GLSPClient { } start(): Promise<void> { - if (this.state === ClientState.Running) { + if (this.state === ClientState.Running || this.state === ClientState.Starting) { return this.onStartDeferred.promise; } @@ -96,15 +124,25 @@ export class BaseGLSPClient implements GLSPClient { } async initializeServer(params: InitializeParameters): Promise<InitializeResult> { - if (!this._initializeResult) { + if (this.initializeResult) { + return this.initializeResult; + } else if (this.pendingServerInitialize) { + return this.pendingServerInitialize; + } + + const initializeDeferred = new Deferred<InitializeResult>(); + try { + this.pendingServerInitialize = initializeDeferred.promise; this._initializeResult = await this.checkedServer.initialize(params); this.onServerInitializedEmitter.fire(this._initializeResult); + initializeDeferred.resolve(this._initializeResult); + this.pendingServerInitialize = undefined; + } catch (error) { + initializeDeferred.reject(error); + this._initializeResult = undefined; + this.pendingServerInitialize = undefined; } - return this._initializeResult; - } - - get initializeResult(): InitializeResult | undefined { - return this._initializeResult; + return initializeDeferred.promise; } initializeClientSession(params: InitializeClientSessionParameters): Promise<void> { @@ -174,14 +212,6 @@ export class BaseGLSPClient implements GLSPClient { } } - protected get checkedServer(): GLSPServer { - this.checkState(); - if (!this._server) { - throw new Error(`No server is configured for GLSPClient with id '${this.id}'`); - } - return this._server; - } - setStartupTimeout(ms: number): void { this.startupTimeout = ms; } diff --git a/packages/protocol/src/client-server-protocol/glsp-client.ts b/packages/protocol/src/client-server-protocol/glsp-client.ts index 177380df..c7dc0620 100644 --- a/packages/protocol/src/client-server-protocol/glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/glsp-client.ts @@ -74,6 +74,10 @@ export interface GLSPClient { * Current client state. */ readonly currentState: ClientState; + /** + * Event that is fired whenever the client state changes. + */ + readonly onCurrentStateChanged: Event<ClientState>; /** * Initializes the client and the server connection. During the start procedure the client is in the @@ -134,6 +138,7 @@ export interface GLSPClient { /** * Stops the client and disposes unknown resources. During the stop procedure the client is in the `Stopping` state and will * transition to either `Stopped` or `ServerError`. + * Calling the method if client is already stopped has no effect. * * @returns A promise that resolves after the server was stopped and disposed. */ diff --git a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts index 5538f763..6d2290f1 100644 --- a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts +++ b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.spec.ts @@ -94,39 +94,78 @@ describe('Base JSON-RPC GLSP Client', () => { describe('start', () => { it('should successfully start & activate the connection', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); expect(client.currentState).to.be.equal(ClientState.Initial); - client.start(); + const startCompleted = client.start(); expect(client.currentState).to.be.equal(ClientState.Starting); - const result = await client.start(); - expect(result).to.be.undefined; + expect(stateChangeHandler.calledWith(ClientState.Starting)).to.be.true; + await startCompleted; expect(client.currentState).to.be.equal(ClientState.Running); expect(client.isConnectionActive()).to.be.true; + expect(stateChangeHandler.calledWith(ClientState.Running)).to.be.true; + }); + it('should fail to start if connecting to the server fails', async () => { + await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); + expect(client.currentState).to.be.equal(ClientState.Initial); + connection.listen.throws(new Error('Connection failed')); + await client.start(); + expect(client.currentState).to.be.equal(ClientState.StartFailed); + expect(stateChangeHandler.calledWith(ClientState.StartFailed)).to.be.true; + }); + it('should not start another connection if another start is already in progress', async () => { + await resetClient(false); + client.start(); + await client.start(); + expect(client.currentState).to.be.equal(ClientState.Running); + expect(client.isConnectionActive()).to.be.true; + expect(connection.listen.calledOnce).to.be.true; }); }); describe('stop', () => { it('should successfully stop if the client was not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); expect(client.currentState).to.be.equal(ClientState.Initial); - const stopResult = await client.stop(); - expect(stopResult).to.be.undefined; + await client.stop(); expect(client.currentState).to.be.equal(ClientState.Stopped); + expect(stateChangeHandler.calledWith(ClientState.Stopped)).to.be.true; + expect(connection.dispose.called).to.be.false; }); it('should successfully stop if the client was running', async () => { + await resetClient(); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); + const stopped = client.stop(); + expect(client.currentState).to.be.equal(ClientState.Stopping); + expect(stateChangeHandler.calledWith(ClientState.Stopping)).to.be.true; + await stopped; + expect(client.currentState).to.be.equal(ClientState.Stopped); + expect(stateChangeHandler.calledWith(ClientState.Stopped)).to.be.true; + expect(connection.dispose.called).to.be.true; + }); + it('should only stop a running client once, if stop is called multiple times', async () => { await resetClient(); client.stop(); expect(client.currentState).to.be.equal(ClientState.Stopping); - const stopResult = await client.stop(); - expect(stopResult).to.be.undefined; + await client.stop(); expect(client.currentState).to.be.equal(ClientState.Stopped); + expect(connection.dispose.calledOnce).to.be.true; }); }); describe('initialize', () => { it('should fail if client is not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); await expectToThrowAsync(() => client.initializeServer({ applicationId: '', protocolVersion: '' })); expect(connection.sendRequest.called).to.be.false; + expect(stateChangeHandler.called).to.be.false; }); it('should forward the corresponding initialize request and cache result', async () => { await resetClient(); @@ -146,10 +185,10 @@ describe('Base JSON-RPC GLSP Client', () => { const params = { applicationId: 'id', protocolVersion: '1.0.0' }; const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params); initializeMock.returns(expectedResult); - client['_initializeResult'] = expectedResult; + client.initializeServer({ applicationId: 'id', protocolVersion: '1.0.0' }); const result = await client.initializeServer({ applicationId: 'id', protocolVersion: '1.0.0' }); expect(result).to.be.deep.equal(client.initializeResult); - expect(initializeMock.called).to.be.false; + expect(initializeMock.calledOnce).to.be.true; }); it('should fire event on first invocation', async () => { await resetClient(); @@ -164,13 +203,30 @@ describe('Base JSON-RPC GLSP Client', () => { await client.initializeServer(params); expect(eventHandlerSpy.calledOnceWith(expectedResult)).to.be.true; }); + it('should not use cached result on consecutive invocation if previous invocation errored', async () => { + await resetClient(); + const expectedResult = { protocolVersion: '1.0.0', serverActions: {} }; + const params = { applicationId: 'id', protocolVersion: '1.0.0' }; + const initializeMock = connection.sendRequest.withArgs(JsonrpcGLSPClient.InitializeRequest, params); + initializeMock.throws(new Error('SomeError')); + expectToThrowAsync(() => client.initializeServer(params)); + expect(client.initializeResult).to.be.undefined; + initializeMock.returns(expectedResult); + const result = await client.initializeServer(params); + expect(result).to.be.deep.equal(expectedResult); + expect(initializeMock.calledTwice).to.be.true; + expect(client.initializeResult).to.be.equal(result); + }); }); describe('initializeClientSession', () => { it('should fail if client is not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); await expectToThrowAsync(() => client.initializeClientSession({ clientSessionId: '', diagramType: '', clientActionKinds: [] })); expect(connection.sendRequest.called).to.be.false; + expect(stateChangeHandler.called).to.be.false; }); it('should invoke the corresponding server method', async () => { await resetClient(); @@ -185,8 +241,11 @@ describe('Base JSON-RPC GLSP Client', () => { describe('disposeClientSession', () => { it('should fail if client is not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); await expectToThrowAsync(() => client.disposeClientSession({ clientSessionId: '' })); expect(connection.sendRequest.called).to.be.false; + expect(stateChangeHandler.called).to.be.false; }); it('should invoke the corresponding server method', async () => { await resetClient(); @@ -201,8 +260,11 @@ describe('Base JSON-RPC GLSP Client', () => { describe('shutdownServer', () => { it('should fail if client is not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); expect(() => client.shutdownServer()).to.throw(); expect(connection.sendNotification.called).to.be.false; + expect(stateChangeHandler.called).to.be.false; }); it('should invoke the corresponding server method', async () => { await resetClient(); @@ -216,8 +278,11 @@ describe('Base JSON-RPC GLSP Client', () => { describe('sendActionMessage', () => { it('should fail if client is not running', async () => { await resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); expect(() => client.sendActionMessage({ action: { kind: '' }, clientId: '' })).to.throw(); expect(connection.sendNotification.called).to.be.false; + expect(stateChangeHandler.called).to.be.false; }); it('should invoke the corresponding server method', async () => { await resetClient(); @@ -257,6 +322,8 @@ describe('Base JSON-RPC GLSP Client', () => { it('Should be in error state after connection error', async () => { // mock setup resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); const listeners: ((e: unknown) => unknown)[] = []; connection.onError.callsFake(listener => { listeners.push(listener); @@ -266,10 +333,13 @@ describe('Base JSON-RPC GLSP Client', () => { await client.start(); listeners.forEach(listener => listener(new Error('SomeError'))); expect(client.currentState).to.be.equal(ClientState.ServerError); + expect(stateChangeHandler.calledWith(ClientState.ServerError)).to.be.true; }); it('Should be in error state after connection close while running', async () => { // mock setup resetClient(false); + const stateChangeHandler = sinon.spy(); + client.onCurrentStateChanged(stateChangeHandler); const listeners: ((e: unknown) => unknown)[] = []; connection.onClose.callsFake(listener => { listeners.push(listener); @@ -279,6 +349,7 @@ describe('Base JSON-RPC GLSP Client', () => { await client.start(); listeners.forEach(listener => listener(undefined)); expect(client.currentState).to.be.equal(ClientState.ServerError); + expect(stateChangeHandler.calledWith(ClientState.ServerError)).to.be.true; }); }); }); diff --git a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts index 6577a95d..40e3d963 100644 --- a/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts +++ b/packages/protocol/src/client-server-protocol/jsonrpc/base-jsonrpc-glsp-client.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Deferred } from 'sprotty-protocol'; import { Disposable, Message, MessageConnection } from 'vscode-jsonrpc'; import { ActionMessage } from '../../action-protocol/base-protocol'; import { Emitter, Event } from '../../utils/event'; @@ -26,9 +27,8 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { protected readonly connectionProvider: ConnectionProvider; protected connectionPromise?: Promise<MessageConnection>; protected resolvedConnection?: MessageConnection; - protected state: ClientState; protected onStop?: Promise<void>; - protected _initializeResult: InitializeResult | undefined; + protected pendingServerInitialize?: Promise<InitializeResult>; protected onServerInitializedEmitter = new Emitter<InitializeResult>(); get onServerInitialized(): Event<InitializeResult> { @@ -40,25 +40,69 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { return this.onActionMessageNotificationEmitter.event; } + protected onCurrentStateChangedEmitter = new Emitter<ClientState>(); + get onCurrentStateChanged(): Event<ClientState> { + return this.onCurrentStateChangedEmitter.event; + } + + protected _state: ClientState; + protected set state(state: ClientState) { + if (this._state !== state) { + this._state = state; + this.onCurrentStateChangedEmitter.fire(state); + } + } + protected get state(): ClientState { + return this._state; + } + + protected _initializeResult?: InitializeResult; + get initializeResult(): InitializeResult | undefined { + return this._initializeResult; + } constructor(options: JsonrpcGLSPClient.Options) { - Object.assign(this, options); + this.connectionProvider = options.connectionProvider; this.state = ClientState.Initial; } - - shutdownServer(): void { - this.checkedConnection.sendNotification(JsonrpcGLSPClient.ShutdownNotification); + async start(): Promise<void> { + if (this.state === ClientState.Running || this.state === ClientState.StartFailed) { + return; + } else if (this.state === ClientState.Starting) { + await Event.waitUntil(this.onCurrentStateChanged, state => state === ClientState.Running || state === ClientState.StartFailed); + return; + } + try { + this.state = ClientState.Starting; + const connection = await this.resolveConnection(); + connection.listen(); + this.resolvedConnection = connection; + this.state = ClientState.Running; + } catch (error) { + JsonrpcGLSPClient.error('Failed to start connection to server', error); + this.state = ClientState.StartFailed; + } } async initializeServer(params: InitializeParameters): Promise<InitializeResult> { - if (!this._initializeResult) { + if (this.initializeResult) { + return this.initializeResult; + } else if (this.pendingServerInitialize) { + return this.pendingServerInitialize; + } + + const initializeDeferred = new Deferred<InitializeResult>(); + try { + this.pendingServerInitialize = initializeDeferred.promise; this._initializeResult = await this.checkedConnection.sendRequest(JsonrpcGLSPClient.InitializeRequest, params); this.onServerInitializedEmitter.fire(this._initializeResult); + initializeDeferred.resolve(this._initializeResult); + this.pendingServerInitialize = undefined; + } catch (error) { + initializeDeferred.reject(error); + this._initializeResult = undefined; + this.pendingServerInitialize = undefined; } - return this._initializeResult; - } - - get initializeResult(): InitializeResult | undefined { - return this._initializeResult; + return initializeDeferred.promise; } initializeClientSession(params: InitializeClientSessionParameters): Promise<void> { @@ -81,27 +125,8 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { this.checkedConnection.sendNotification(JsonrpcGLSPClient.ActionMessageNotification, message); } - protected get checkedConnection(): MessageConnection { - if (!this.isConnectionActive()) { - throw new Error(JsonrpcGLSPClient.ClientNotReadyMsg); - } - return this.resolvedConnection!; - } - - async start(): Promise<void> { - if (this.state === ClientState.Running) { - return; - } - try { - this.state = ClientState.Starting; - const connection = await this.resolveConnection(); - connection.listen(); - this.resolvedConnection = connection; - this.state = ClientState.Running; - } catch (error) { - JsonrpcGLSPClient.error('Failed to start connection to server', error); - this.state = ClientState.StartFailed; - } + shutdownServer(): void { + this.checkedConnection.sendNotification(JsonrpcGLSPClient.ShutdownNotification); } stop(): Promise<void> { @@ -118,11 +143,19 @@ export class BaseJsonrpcGLSPClient implements GLSPClient { this.state = ClientState.Stopped; this.onStop = undefined; this.onActionMessageNotificationEmitter.dispose(); + this.onCurrentStateChangedEmitter.dispose(); this.connectionPromise = undefined; this.resolvedConnection = undefined; })); } + protected get checkedConnection(): MessageConnection { + if (!this.isConnectionActive()) { + throw new Error(JsonrpcGLSPClient.ClientNotReadyMsg); + } + return this.resolvedConnection!; + } + protected resolveConnection(): Promise<MessageConnection> { if (!this.connectionPromise) { this.connectionPromise = this.doCreateConnection(); diff --git a/packages/protocol/src/utils/event.spec.ts b/packages/protocol/src/utils/event.spec.ts new file mode 100644 index 00000000..6d114fd3 --- /dev/null +++ b/packages/protocol/src/utils/event.spec.ts @@ -0,0 +1,70 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Emitter, Event } from './event'; + +describe('Event', () => { + let emitter: Emitter<string>; + + beforeEach(() => { + emitter = new Emitter<string>(); + }); + + describe('once', () => { + it('should invoke the listener when the event is fired', () => { + const listener = sinon.spy((e: string) => {}); + Event.once(emitter.event, listener); + emitter.fire('test'); + expect(listener.calledOnce).to.be.true; + expect(listener.calledWith('test')).to.be.true; + }); + it('should invoke the listener only once when the event is fired multiple times', () => { + const listener = sinon.spy((e: string) => {}); + Event.once(emitter.event, listener); + emitter.fire('test'); + emitter.fire('test1'); + expect(listener.calledOnce).to.be.true; + expect(listener.calledWith('test')).to.be.true; + }); + it('should not invoke the listener when its disposed before the event fired', () => { + const listener = sinon.spy((e: string) => {}); + const disposable = Event.once(emitter.event, listener); + disposable.dispose(); + emitter.fire('test'); + expect(listener.called).to.be.false; + }); + }); + + describe('waitUntil', () => { + it('should resolve the promise when the event is fired', async () => { + const promise = Event.waitUntil(emitter.event); + emitter.fire('test'); + const result = await promise; + expect(result).to.equal('test'); + }); + + it('should resolve the promise only when the predicate matches', async () => { + const predicate = (e: string): boolean => e === 'match'; + const promise = Event.waitUntil(emitter.event, predicate); + emitter.fire('no-match'); + emitter.fire('match'); + const result = await promise; + expect(result).to.equal('match'); + }); + }); +}); diff --git a/packages/protocol/src/utils/event.ts b/packages/protocol/src/utils/event.ts index 3505f4cd..ef61fa8e 100644 --- a/packages/protocol/src/utils/event.ts +++ b/packages/protocol/src/utils/event.ts @@ -46,6 +46,49 @@ export interface Event<T> extends jsonrpc.Event<T> { (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]): Disposable; } +export namespace Event { + /** + * Utility function to register a one-time listener for an event. The listener will be disposed + * automatically after the next event is fired. + * @param event The event to listen to + * @param listener The listener function that will be called when the event happens. + * @param thisArgs The 'this' which will be used when calling the event listener. + * @param disposables An array to which the {@link Disposable} for removing the listener will be added. + * @returns a {@link Disposable} to remove the listener again. + */ + export function once<T>(event: Event<T>, listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]): Disposable { + const toDispose = event( + e => { + listener(e); + toDispose.dispose(); + }, + thisArgs, + disposables + ); + return toDispose; + } + + /** + * Utility function to wait for an event to happen. The function will return a promise that will be resolved + * when the event is fired. Optionally a predicate can be provided that will be used to filter the event. + * If a predicate is provided, the promise will only be resolved when the predicate returns true. + * The underlying listener will be disposed automatically when the promise is resolved. + * @param event The event to listen to + * @param predicate An optional predicate that will be used to filter the event + * @returns a promise that will be resolved when the event is fired (and the optional predicate matches) + */ + export function waitUntil<T>(event: Event<T>, predicate?: (e: T) => boolean): Promise<T> { + return new Promise<any>(resolve => { + const toDispose = event(e => { + if (!predicate || predicate(e)) { + resolve(e); + toDispose.dispose(); + } + }); + }); + } +} + /** * Optional options that can be passed to the constructor * of an {@link Emitter}.