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}.