diff --git a/packages/server/src/browser/di/app-module.ts b/packages/server/src/browser/di/app-module.ts index 004e77b..023b89b 100644 --- a/packages/server/src/browser/di/app-module.ts +++ b/packages/server/src/browser/di/app-module.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 EclipseSource and others. + * Copyright (c) 2022-2026 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 @@ -15,12 +15,16 @@ ********************************************************************************/ import { ContainerModule } from 'inversify'; -import { InjectionContainer, LogLevel, LoggerConfigOptions, configureConsoleLogger } from '../../common/'; +import { ActionDispatchScope, InjectionContainer, LogLevel, LoggerConfigOptions, configureConsoleLogger } from '../../common/'; +import { BrowserActionDispatchScope } from './browser-action-dispatch-scope'; export function createAppModule(options: LoggerConfigOptions = {}): ContainerModule { const resolvedOptions: LoggerConfigOptions = { consoleLog: true, logLevel: LogLevel.info, ...options }; return new ContainerModule((bind, unbind, isBound, rebind) => { bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container); + // Transient on purpose: a singleton at the server-container level would be shared across + // sessions and leak the browser flag between them. + bind(ActionDispatchScope).to(BrowserActionDispatchScope); const context = { bind, unbind, isBound, rebind }; configureConsoleLogger(context, resolvedOptions); }); diff --git a/packages/server/src/browser/di/browser-action-dispatch-scope.spec.ts b/packages/server/src/browser/di/browser-action-dispatch-scope.spec.ts new file mode 100644 index 0000000..b910496 --- /dev/null +++ b/packages/server/src/browser/di/browser-action-dispatch-scope.spec.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (c) 2026 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 { Action } from '@eclipse-glsp/protocol'; +import { expect } from 'chai'; +import { ClientAction } from '../../common/protocol/client-action'; +import * as mock from '../../common/test/mock-util'; +import { BrowserActionDispatchScope } from './browser-action-dispatch-scope'; + +describe('BrowserActionDispatchScope', () => { + const action: Action = { kind: 'foo' }; + const markedClientAction = ((): Action => { + const a: Action = { kind: 'bar' }; + ClientAction.mark(a); + return a; + })(); + + let scope: BrowserActionDispatchScope; + beforeEach(() => { + scope = new BrowserActionDispatchScope(); + }); + + it('isReentrant is false outside enter()', () => { + expect(scope.isReentrant(action)).to.be.false; + }); + + it('isReentrant is true during a synchronous enter()', () => { + scope.enter(() => { + expect(scope.isReentrant(action)).to.be.true; + }); + expect(scope.isReentrant(action)).to.be.false; + }); + + it('isReentrant is true during an async enter() and false after settle', async () => { + const probe: Promise = scope.enter(async () => { + await Promise.resolve(); + return scope.isReentrant(action); + }); + expect(await probe).to.be.true; + expect(scope.isReentrant(action)).to.be.false; + }); + + it('resets active flag when callback throws synchronously', () => { + expect(() => + scope.enter(() => { + throw new Error('boom'); + }) + ).to.throw('boom'); + expect(scope.isReentrant(action)).to.be.false; + }); + + it('resets active flag when async callback rejects', async () => { + await mock.expectToThrowAsync(() => scope.enter(() => Promise.reject(new Error('boom'))), 'boom'); + expect(scope.isReentrant(action)).to.be.false; + }); + + it('isReentrant is false for client-originated actions even when scope is active', () => { + scope.enter(() => { + expect(scope.isReentrant(markedClientAction)).to.be.false; + expect(scope.isReentrant(action)).to.be.true; + }); + }); +}); diff --git a/packages/server/src/browser/di/browser-action-dispatch-scope.ts b/packages/server/src/browser/di/browser-action-dispatch-scope.ts new file mode 100644 index 0000000..f9d3825 --- /dev/null +++ b/packages/server/src/browser/di/browser-action-dispatch-scope.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2026 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 { Action } from '@eclipse-glsp/protocol'; +import { injectable } from 'inversify'; +import { ActionDispatchScope } from '../../common/actions/action-dispatcher'; +import { ClientAction } from '../../common/protocol/client-action'; + +/** + * Browser-compatible {@link ActionDispatchScope} backed by a single boolean flag, used because + * available `AsyncLocalStorage` polyfills do not work reliably across browser engines (e.g. V8). + * + * The flag cannot distinguish "still inside the handler's async continuation" from "unrelated + * event fired during the handler's await". Any dispatch arriving in such a gap is observed as + * reentrant and routed inline. Client-originated actions are explicitly treated as non-reentrant + * to cover the dominant case, but server-side dispatches from non-handler contexts (timer + * callbacks, event listeners, adopter code) cannot be filtered this way and may interleave with + * the in-flight handler. + * + * The dispatcher normally serializes handler execution; the inline interleaving breaks that + * guarantee. A handler that pauses on `await` may resume to find that another handler has mutated + * state in between (model state, command stack, caches), leading to unexpected behavior. + * Avoid dispatching from non-handler contexts where possible. + */ +@injectable() +export class BrowserActionDispatchScope implements ActionDispatchScope { + protected active = false; + + // Assumes serial invocation by the dispatcher's queue processor; concurrent enter() calls + // would corrupt the prior-restore logic and leave the flag stuck. + enter(callback: () => R): R { + const prior = this.active; + this.active = true; + let result: R; + try { + result = callback(); + } catch (error) { + this.active = prior; + throw error; + } + if (result instanceof Promise) { + // Cast required because TS cannot prove the .finally() result matches the generic R. + return result.finally(() => { + this.active = prior; + }) as unknown as R; + } + this.active = prior; + return result; + } + + isReentrant(action: Action): boolean { + return this.active && !ClientAction.is(action); + } +} diff --git a/packages/server/src/browser/index.ts b/packages/server/src/browser/index.ts index 2f5a370..ca99842 100644 --- a/packages/server/src/browser/index.ts +++ b/packages/server/src/browser/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 EclipseSource and others. + * Copyright (c) 2022-2026 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 @@ -14,5 +14,6 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ export * from './di/app-module'; +export * from './di/browser-action-dispatch-scope'; export * from './launch/worker-server-launcher'; export * from './reexport'; diff --git a/packages/server/src/common/actions/action-dispatcher.spec.ts b/packages/server/src/common/actions/action-dispatcher.spec.ts deleted file mode 100644 index 35286f3..0000000 --- a/packages/server/src/common/actions/action-dispatcher.spec.ts +++ /dev/null @@ -1,291 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics 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 { Action, UpdateModelAction } from '@eclipse-glsp/protocol'; -import { expect } from 'chai'; -import { Container, ContainerModule } from 'inversify'; -import * as sinon from 'sinon'; -import { ClientActionKinds, ClientId } from '../di/service-identifiers'; -import { ClientSessionManager } from '../session/client-session-manager'; -import * as mock from '../test/mock-util'; -import { Logger } from '../utils/logger'; -import { DefaultActionDispatcher } from './action-dispatcher'; -import { ActionHandler } from './action-handler'; -import { ActionHandlerRegistry } from './action-handler-registry'; -import { ClientActionForwarder } from './client-action-handler'; - -function waitSync(timeInMillis: number): void { - const start = Date.now(); - let now = start; - while (now - start < timeInMillis) { - now = Date.now(); - } -} - -describe('test DefaultActionDispatcher', () => { - const container = new Container(); - const clientId = 'myClientId'; - const actionHandlerRegistry = new ActionHandlerRegistry(); - let registry_get_stub: sinon.SinonStub<[string], ActionHandler[]>; - const sandbox = sinon.createSandbox(); - - container.load( - new ContainerModule(bind => { - bind(Logger).toConstantValue(new mock.StubLogger()); - bind(ClientSessionManager).toConstantValue(new mock.StubClientSessionManager()); - bind(ClientId).toConstantValue(clientId); - bind(ActionHandlerRegistry).toConstantValue(actionHandlerRegistry); - bind(ClientActionKinds).toConstantValue(['response', 'response1', 'response2']); - bind(ClientActionForwarder).toConstantValue(sinon.createStubInstance(ClientActionForwarder)); - }) - ); - const actionDispatcher = container.resolve(DefaultActionDispatcher); - - beforeEach(() => { - registry_get_stub = sandbox.stub(actionHandlerRegistry, 'get'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('test with one-way actions (no response actions)', () => { - it('dispatch- unhandled action', async () => { - mock.expectToThrowAsync(() => actionDispatcher.dispatch({ kind: 'unhandled' })); - }); - - it('dispatch - one action', async () => { - // Mock setup - const action = 'action'; - const handler = new mock.StubActionHandler([action]); - const getHandler = (kind: string): ActionHandler[] => (kind === action ? [handler] : []); - registry_get_stub.callsFake(getHandler); - const spy_handler_execute = sinon.spy(handler, 'execute'); - // Test execution - await actionDispatcher.dispatch({ kind: action }); - expect(spy_handler_execute.calledOnce).true; - }); - - describe('test multi dispatch with single-handled actions', () => { - // Mock Setup - const action1 = 'a1'; - const action2 = 'a2'; - const action3 = 'a3'; - - const handler1 = new mock.StubActionHandler([action1]); - const handler2 = new mock.StubActionHandler([action2]); - const handler3 = new mock.StubActionHandler([action3]); - - const spy_handler1_execute = sinon.stub(handler1, 'execute').returns([]); - const spy_handler2_execute = sinon.stub(handler2, 'execute').returns([]); - const spy_handler3_execute = sinon.stub(handler3, 'execute').returns([]); - const handlerMockImpl = (kind: string): ActionHandler[] => { - switch (kind) { - case action1: - return [handler1]; - case action2: - return [handler2]; - case action3: - return [handler3]; - default: - return []; - } - }; - - it('dispatch - multiple actions', async () => { - // Mock setup - registry_get_stub.callsFake(handlerMockImpl); - // Test execution - actionDispatcher.dispatch({ kind: action1 }); - actionDispatcher.dispatch({ kind: action2 }); - await actionDispatcher.dispatch({ kind: action3 }); - // Check if all handlers have been called - expect(spy_handler1_execute.called).true; - expect(spy_handler2_execute.called).true; - expect(spy_handler3_execute.called).true; - // Check if all handlers have been called in the right order - sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); - }); - - it('dispatchAll- multiple actions', async () => { - // Mock setup - registry_get_stub.callsFake(handlerMockImpl); - // Test execution - await actionDispatcher.dispatchAll([{ kind: action1 }, { kind: action2 }, { kind: action3 }]); - // Check if all handlers have been called - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); - expect(spy_handler3_execute.calledOnce); - // Check if all handlers have been called in the right order - sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); - }); - - it('dispatch - multiple actions (racing execution times)', async () => { - // Mock setup - registry_get_stub.callsFake(handlerMockImpl); - spy_handler1_execute.callsFake((_action: Action) => { - waitSync(500); - return []; - }); - spy_handler2_execute.callsFake((_action: Action) => { - waitSync(200); - return []; - }); - spy_handler3_execute.callsFake((_action: Action) => { - waitSync(100); - return []; - }); - // Test execution - actionDispatcher.dispatch({ kind: action1 }); - actionDispatcher.dispatch({ kind: action2 }); - await actionDispatcher.dispatch({ kind: action3 }); - // Check if all handlers have been called - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); - expect(spy_handler3_execute.calledOnce); - // Check if all handlers have been called in the right order - sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); - }); - }); - - it('dispatch- one action & multiple handlers', async () => { - // Mock setup - const action1 = 'a1'; - - const handler1 = new mock.StubActionHandler([action1]); - const handler2 = new mock.StubActionHandler([action1]); - - registry_get_stub.callsFake((kind: string) => (kind === action1 ? [handler1, handler2] : [])); - const spy_handler1_execute = sinon.spy(handler1, 'execute'); - const spy_handler2_execute = sinon.spy(handler2, 'execute'); - // Test execution - await actionDispatcher.dispatch({ kind: action1 }); - expect(spy_handler1_execute.calledOnce); - expect(spy_handler2_execute.calledOnce); - sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute); - }); - }); - - describe('test with handler response actions ', () => { - it('dispatch - one action & one handler response action', async () => { - // Mock setup - const request = 'request'; - const response = 'response'; - - const requestHandler = new mock.StubActionHandler([request]); - const responseHandler = new mock.StubActionHandler([response]); - - const spy_requestHandler_execute = sinon.stub(requestHandler, 'execute').returns([{ kind: response }]); - const spy_responseHandler_execute = sinon.spy(responseHandler, 'execute'); - registry_get_stub.callsFake((kind: string) => { - switch (kind) { - case request: - return [requestHandler]; - case response: - return [responseHandler]; - default: - return []; - } - }); - // Test execution - await actionDispatcher.dispatch({ kind: request }); - // Add a delay so that the action dispatcher has time to dispatch the handler response - await mock.delay(200); - // Check if all handlers have been called - expect(spy_requestHandler_execute.calledOnce); - expect(spy_responseHandler_execute.calledOnce); - }); - - it('dispatch - multiple actions & multiple response', async () => { - // Mock setup - const request1 = 'request1'; - const request2 = 'request2'; - const response1 = 'response1'; - const response2 = 'response2'; - - const responseHandler1 = new mock.StubActionHandler([response1]); - const responseHandler2 = new mock.StubActionHandler([response2]); - const requestHandler1 = new mock.StubActionHandler([request1]); - const requestHandler2 = new mock.StubActionHandler([request2]); - - const spy_requestHandler1_execute = sinon.stub(requestHandler1, 'execute').returns([{ kind: response1 }, { kind: response2 }]); - const spy_requestHandler2_execute = sinon.stub(requestHandler2, 'execute').returns([{ kind: response2 }]); - const spy_responseHandler1_execute = sinon.spy(responseHandler1, 'execute'); - const spy_responseHandler2_execute = sinon.spy(responseHandler2, 'execute'); - registry_get_stub.callsFake((kind: string) => { - switch (kind) { - case request1: - return [requestHandler1]; - case request2: - return [requestHandler2]; - case response1: - return [responseHandler1, responseHandler2]; - case response2: - return [responseHandler2]; - default: - return []; - } - }); - // Test execution - actionDispatcher.dispatch({ kind: request1 }); - await actionDispatcher.dispatch({ kind: request2 }); - - // Add a delay so that the action dispatcher has time to dispatch the handler response - await mock.delay(100); - // Check if all handlers have been called correctly - expect(spy_requestHandler1_execute.calledOnce); - expect(spy_requestHandler2_execute.calledOnce); - expect(spy_responseHandler1_execute.calledOnce); - expect(spy_responseHandler2_execute.calledThrice); - // Check if all handlers have been called in the right order - sinon.assert.callOrder(spy_requestHandler1_execute, spy_requestHandler2_execute); - sinon.assert.callOrder(spy_responseHandler1_execute, spy_responseHandler2_execute); - }); - }); - - describe('test dispatch after next update', () => { - it('dispatchAfterNextUpdate', async () => { - // Mock setup - const updateModelAction = UpdateModelAction.create({ id: 'newRoot', type: 'myType' }); - const intermediateAction = 'intermediate'; - const postUpdateAction = 'postUpdate'; - const handler = new mock.StubActionHandler([updateModelAction.kind, intermediateAction]); - const postUpdateHandler = new mock.StubActionHandler([postUpdateAction]); - - const getHandler = (kind: string): ActionHandler[] => { - if (kind === updateModelAction.kind || kind === intermediateAction) { - return [handler]; - } else if (kind === postUpdateAction) { - return [postUpdateHandler]; - } - - return []; - }; - registry_get_stub.callsFake(getHandler); - const spy_postUpdateHandler_execute = sinon.spy(postUpdateHandler, 'execute'); - - // Test execution - actionDispatcher.dispatchAfterNextUpdate({ kind: postUpdateAction }); - expect(spy_postUpdateHandler_execute.called).to.be.false; - await actionDispatcher.dispatch({ kind: intermediateAction }); - expect(spy_postUpdateHandler_execute.called).to.be.false; - await actionDispatcher.dispatch(updateModelAction); - expect(spy_postUpdateHandler_execute.calledOnce); - // Check that action does not get dispatched again - await actionDispatcher.dispatch(updateModelAction); - expect(spy_postUpdateHandler_execute.calledOnce); - }); - }); -}); diff --git a/packages/server/src/common/actions/action-dispatcher.ts b/packages/server/src/common/actions/action-dispatcher.ts index 63fc208..8605ef1 100644 --- a/packages/server/src/common/actions/action-dispatcher.ts +++ b/packages/server/src/common/actions/action-dispatcher.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -15,19 +15,21 @@ ********************************************************************************/ import { Action, + Deferred, Disposable, MaybeArray, + RejectAction, RequestAction, ResponseAction, SetModelAction, UpdateModelAction, flatPush } from '@eclipse-glsp/protocol'; -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } from 'inversify'; import { ClientId } from '../di/service-identifiers'; +import { ActionQueue } from '../utils/action-queue'; import { GLSPServerError } from '../utils/glsp-server-error'; import { Logger } from '../utils/logger'; -import { PromiseQueue } from '../utils/promise-queue'; import { ActionHandler } from './action-handler'; import { ActionHandlerRegistry } from './action-handler-registry'; import { ClientActionForwarder } from './client-action-handler'; @@ -41,6 +43,8 @@ export const ActionDispatcher = Symbol('ActionDispatcher'); export interface ActionDispatcher { /** * Processes the given action by dispatching it to all registered handlers. + * Responses matching a pending {@link request} short-circuit and resolve that + * request without being passed to handlers. * * @param action The action that should be dispatched. * @returns A promise indicating when the action processing is complete. @@ -65,10 +69,92 @@ export interface ActionDispatcher { */ dispatchAfterNextUpdate(actions: Action[]): void; dispatchAfterNextUpdate(...actions: Action[]): void; + + /** + * Dispatches a request action and returns a promise that resolves when a matching response + * action is dispatched or rejects if the response is a {@link RejectAction}. The response is + * _not_ passed to the registered action handlers. Instead, it is the responsibility of the + * caller of this method to handle the response properly. + * + * If the request's `kind` is registered in `ClientActionKinds`, it is forwarded to the client + * via {@link ClientActionForwarder}. If server-side handlers are registered, they are also + * executed. + * + * Only the first matching response resolves the request. Any additional or late responses + * are dispatched as normal actions. + * + * The promise waits indefinitely until a response arrives or the dispatcher is disposed. + * Use {@link requestUntil} if a timeout is needed. + * + * Note: mutates `action.requestId` (if unset) and `action.timeout`. + * + * @param action The request action to dispatch. + * @returns A promise that resolves with the matching response action. + */ + request(action: RequestAction): Promise; + + /** + * Dispatches a request and waits for a response until the timeout given in `timeoutMs` has + * been reached. The returned promise is resolved when a response with a matching identifier + * is dispatched or when the timeout has been reached. That response is _not_ passed to the + * registered action handlers. Instead, it is the responsibility of the caller of this method + * to handle the response properly. + * If `rejectOnTimeout` is set to `false` (default) the returned promise will be resolved with + * no value, otherwise it will be rejected. + * + * Note: mutates `action.requestId` (if unset) and `action.timeout`. + * + * @param action The request action to dispatch. + * @param timeoutMs Maximum wait time in milliseconds. Defaults to + * {@link RequestAction.timeout} if set, otherwise 2000 ms. + * @param rejectOnTimeout Whether to reject the promise on timeout. + * @returns The matching response, or `undefined` on soft timeout. + */ + requestUntil( + action: RequestAction, + timeoutMs?: number, + rejectOnTimeout?: boolean + ): Promise; } +export const ActionDispatchScope = Symbol('ActionDispatchScope'); + +/** + * Scope marker that lets the {@link ActionDispatcher} know whether a call to `dispatch()` + * originates from inside a running handler (reentrant) or from outside (external). + * + * The {@link DefaultActionDispatcher.processActionQueue} loop wraps each action in {@link enter} + * so that reentrant `dispatch()` calls (handler responses, injected dispatcher calls) can be + * recognized via {@link isReentrant} and executed inline instead of being queued. + * + * Used by the {@link DefaultActionDispatcher} implementation. + */ +export interface ActionDispatchScope { + /** + * Executes the callback inside the dispatch scope. While the callback (and its full async + * continuation) is running, {@link isReentrant} returns `true` for reentrant calls. + */ + enter(callback: () => R): R; + + /** + * Returns `true` if the given dispatch is reentrant — i.e. it originates from within a + * running {@link enter} callback (handler response or injected dispatcher call) and should + * run inline rather than being queued. + * + * Implementations may inspect the action to apply additional guards, e.g. to ensure + * client-originated actions are always queued regardless of scope state. + */ + isReentrant(action: Action): boolean; +} + +/** + * Default {@link ActionDispatcher}. External dispatches are queued and processed one at a + * time; dispatches made from within a running handler run inline with the containing action. + */ @injectable() export class DefaultActionDispatcher implements ActionDispatcher, Disposable { + protected static readonly STALE_TIMEOUT_GRACE_MS = 30_000; + @inject(ActionHandlerRegistry) protected actionHandlerRegistry: ActionHandlerRegistry; @@ -81,15 +167,50 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable { @inject(ClientId) protected clientId: string; - protected actionQueue = new PromiseQueue(); + @inject(ActionDispatchScope) + protected dispatchScope: ActionDispatchScope; + + protected actionQueue = new ActionQueue(); + protected postUpdateQueue: Action[] = []; + protected readonly pendingRequests = new Map>(); + protected readonly requestTimeouts = new Map(); + protected nextRequestId = 1; + + @postConstruct() + protected initialize(): void { + // Fire-and-forget: the loop is meant to run for the dispatcher's lifetime; surface any + // unexpected termination via the logger instead of an unhandled rejection. + this.processActionQueue().catch(error => this.logger.error('Action queue processor terminated unexpectedly', error)); + } + + protected generateRequestId(): string { + return `server_${this.clientId}_${this.nextRequestId++}`; + } dispatch(action: Action): Promise { - // Dont queue actions that are just delegated to the client - if (this.clientActionForwarder.shouldForwardToClient(action)) { + // Intercept first to avoid deadlock: a handler may be awaiting this response. + if (this.interceptPendingResponse(action)) { + return Promise.resolve(); + } + // Reentrant dispatches run inline to preserve ordering with the containing action. + if (this.dispatchScope.isReentrant(action)) { return this.doDispatch(action); } - return this.actionQueue.enqueue(() => this.doDispatch(action)); + // External dispatches are queued and processed sequentially. + return this.actionQueue.push(action); + } + + protected async processActionQueue(): Promise { + // Process each action inside the dispatch scope so reentrant dispatch() calls are recognized. + for await (const entry of this.actionQueue.consume()) { + try { + await this.dispatchScope.enter(() => this.doDispatch(entry.item)); + entry.resolve(); + } catch (error) { + entry.reject(error); + } + } } protected async doDispatch(action: Action): Promise { @@ -107,35 +228,37 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable { responses.push(...response); } - if (this.postUpdateQueue.length > 0 && (UpdateModelAction.is(action) || SetModelAction.is(action))) { - responses.push(...this.postUpdateQueue); - this.postUpdateQueue = []; - } + // Append post-update actions to responses so they are dispatched in the same inline + // batch as the handler responses, preserving sequential order. + responses.push(...this.drainPostUpdateQueue(action)); await this.dispatchResponses(responses); } - protected async executeHandler(handler: ActionHandler, request: Action): Promise { - const responseActions = await handler.execute(request); - return responseActions.map(action => respond(request, action)); + protected async executeHandler(handler: ActionHandler, action: Action): Promise { + const responseActions = await handler.execute(action); + return responseActions.map(response => respond(action, response)); } - protected dispatchResponses(actions: Action[]): Promise { - if (actions.length === 0) { - return Promise.resolve(); + protected async dispatchResponses(actions: Action[]): Promise { + // Sequential dispatch inside the current dispatch scope. Each response goes inline via + // the reentrant path, or is intercepted if it resolves a pending request(). + for (const action of actions) { + await this.dispatch(action); } - const responseQueue = new PromiseQueue(); - const responses = actions.map(action => responseQueue.enqueue(() => this.doDispatch(action))); - return Promise.all(responses).then(() => Promise.resolve()); } - dispatchAll(...actions: MaybeArray[]): Promise { + async dispatchAll(...actions: MaybeArray[]): Promise { if (actions.length === 0) { - return Promise.resolve(); + return; } const flat: Action[] = []; flatPush(flat, actions); - return Promise.all(flat.map(action => this.dispatch(action))).then(() => Promise.resolve()); + // Sequential dispatch: external calls were already FIFO via the queue, but reentrant + // calls also need deterministic ordering so handlers see each other's effects in order. + for (const action of flat) { + await this.dispatch(action); + } } dispatchAfterNextUpdate(...actions: MaybeArray[]): void { @@ -144,8 +267,152 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable { } } + request(action: RequestAction): Promise { + return this.doRequest(action, undefined, true) as Promise; + } + + requestUntil( + action: RequestAction, + timeoutMs: number = action.timeout ?? 2000, + rejectOnTimeout = false + ): Promise { + return this.doRequest(action, timeoutMs, rejectOnTimeout); + } + + protected doRequest( + action: RequestAction, + timeoutMs: number | undefined, + rejectOnTimeout: boolean + ): Promise { + if (!action.requestId || action.requestId === '') { + action.requestId = this.generateRequestId(); + } + // Stamp the effective timeout onto the action so the receiving side + // (handleServerRequest/handleClientRequest) can respect it. + action.timeout = timeoutMs; + + const deferred = new Deferred(); + this.pendingRequests.set(action.requestId, deferred); + + if (timeoutMs !== undefined) { + const timeout = setTimeout(() => { + if (this.pendingRequests.delete(action.requestId)) { + // Keep the requestTimeouts entry briefly as a stale marker so a late response + // can be filtered, then drop it after a grace period to avoid leaking markers + // for requests whose late responses never arrive. + const cleanup = setTimeout( + () => this.requestTimeouts.delete(action.requestId), + DefaultActionDispatcher.STALE_TIMEOUT_GRACE_MS + ); + cleanup.unref?.(); + const message = `Request '${action.requestId}' (${action.kind}) timed out after ${timeoutMs}ms`; + if (rejectOnTimeout) { + deferred.reject(new Error(message)); + } else { + this.logger.info(message); + deferred.resolve(undefined); + } + } + }, timeoutMs); + + this.requestTimeouts.set(action.requestId, timeout); + } + + // dispatch() routes correctly on its own: external callers queue, handler-internal + // callers run inline via the ActionDispatchScope. The matching response resolves + // the deferred out-of-band via interceptPendingResponse(). + const dispatchPromise = this.dispatch(action); + + dispatchPromise.catch(error => { + if (this.pendingRequests.delete(action.requestId)) { + const timeout = this.requestTimeouts.get(action.requestId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.requestTimeouts.delete(action.requestId); + } + deferred.reject(error); + } + }); + + return deferred.promise as Promise; + } + + /** + * If the given action is a {@link SetModelAction} or {@link UpdateModelAction} and there are actions + * queued via {@link dispatchAfterNextUpdate}, drain and return them. + * + * @returns The drained actions, or an empty array if nothing to drain. + */ + protected drainPostUpdateQueue(action: Action): Action[] { + if (this.postUpdateQueue.length > 0 && (UpdateModelAction.is(action) || SetModelAction.is(action))) { + const actions = [...this.postUpdateQueue]; + this.postUpdateQueue = []; + return actions; + } + return []; + } + + /** + * Checks whether the given action is a response matching a pending {@link request} or + * {@link requestUntil} call. If matched, resolves (or rejects) the corresponding deferred + * and returns `true` so the caller can short-circuit normal dispatch. + * + * For responses with a valid `responseId` but no matching pending request, checks for a stale + * timeout entry (timed-out request) and clears the `responseId` so the action is not forwarded + * by {@link ClientActionForwarder}. If no stale entry exists, the `responseId` is left intact + * for normal forwarding. + */ + protected interceptPendingResponse(action: Action): boolean { + if (!ResponseAction.hasValidResponseId(action)) { + return false; + } + const deferred = this.pendingRequests.get(action.responseId); + if (deferred !== undefined) { + this.pendingRequests.delete(action.responseId); + const timeout = this.requestTimeouts.get(action.responseId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.requestTimeouts.delete(action.responseId); + } + // Intercepted responses skip doDispatch, so drain post-update actions here when the + // response is an UpdateModel/SetModel. RejectAction does not trigger a drain; pending + // post-update actions stay queued until the next successful update. + const postUpdateActions = this.drainPostUpdateQueue(action); + if (RejectAction.is(action)) { + deferred.reject(new Error(`${action.message}${action.detail ? ': ' + action.detail : ''}`)); + } else { + deferred.resolve(action); + } + if (postUpdateActions.length > 0) { + // Fire-and-forget: callers of request() expect the resolved response, not the + // unrelated post-update fan-out; awaiting here would couple them unnecessarily. + this.dispatchResponses(postUpdateActions).catch(error => + this.logger.error('Failed to dispatch post-update actions', error) + ); + } + return true; + } + // Late response for a timed-out request: clear responseId so ClientActionForwarder does + // not re-emit it to the client. + const staleTimeout = this.requestTimeouts.get(action.responseId); + if (staleTimeout !== undefined) { + clearTimeout(staleTimeout); + this.requestTimeouts.delete(action.responseId); + this.logger.debug(`Late response for timed-out request '${action.responseId}', dispatching as normal action`); + action.responseId = ''; + } + return false; + } + dispose(): void { - this.actionQueue.clear(); + // Reject queued actions: no further processing should happen after dispose. + this.actionQueue.rejectPending(new Error('ActionDispatcher disposed')); + this.actionQueue.stop(); + this.pendingRequests.forEach((deferred, id) => deferred.reject(new Error(`Request '${id}' cancelled: dispatcher disposed`))); + this.pendingRequests.clear(); + this.requestTimeouts.forEach(timeout => clearTimeout(timeout)); + this.requestTimeouts.clear(); + this.postUpdateQueue = []; } } @@ -158,7 +425,7 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable { */ export function respond(request: Action, response: Action): Action { if (RequestAction.is(request) && ResponseAction.is(response)) { - (response as any).responseId = request.requestId; + response.responseId = request.requestId; } return response; } diff --git a/packages/server/src/common/actions/client-action-handler.ts b/packages/server/src/common/actions/client-action-handler.ts index 2cfa8d7..36c26ed 100644 --- a/packages/server/src/common/actions/client-action-handler.ts +++ b/packages/server/src/common/actions/client-action-handler.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -54,6 +54,6 @@ export class ClientActionForwarder { if (ClientAction.is(action)) { return false; } - return this.actionKinds.has(action.kind) || ResponseAction.is(action); + return this.actionKinds.has(action.kind) || ResponseAction.hasValidResponseId(action); } } diff --git a/packages/server/src/common/di/service-identifiers.ts b/packages/server/src/common/di/service-identifiers.ts index 1a5872f..079f41b 100644 --- a/packages/server/src/common/di/service-identifiers.ts +++ b/packages/server/src/common/di/service-identifiers.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 diff --git a/packages/server/src/common/features/model/request-model-action-handler.ts b/packages/server/src/common/features/model/request-model-action-handler.ts index 2b054dd..0b71e21 100644 --- a/packages/server/src/common/features/model/request-model-action-handler.ts +++ b/packages/server/src/common/features/model/request-model-action-handler.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2025 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 diff --git a/packages/server/src/common/features/progress/progress-service.ts b/packages/server/src/common/features/progress/progress-service.ts index b14f2bc..a7f58a1 100644 --- a/packages/server/src/common/features/progress/progress-service.ts +++ b/packages/server/src/common/features/progress/progress-service.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2023 EclipseSource and others. + * Copyright (c) 2023-2026 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 diff --git a/packages/server/src/common/index.ts b/packages/server/src/common/index.ts index da86f5a..1051a83 100644 --- a/packages/server/src/common/index.ts +++ b/packages/server/src/common/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -95,6 +95,7 @@ export * from './session/client-session-factory'; export * from './session/client-session-initializer'; export * from './session/client-session-listener'; export * from './session/client-session-manager'; +export * from './utils/action-queue'; export * from './utils/args-util'; export * from './utils/client-options-util'; export * from './utils/console-logger'; diff --git a/packages/server/src/common/protocol/glsp-server.ts b/packages/server/src/common/protocol/glsp-server.ts index ef7571f..d43f063 100644 --- a/packages/server/src/common/protocol/glsp-server.ts +++ b/packages/server/src/common/protocol/glsp-server.ts @@ -25,6 +25,9 @@ import { InitializeResult, MaybePromise, MessageAction, + RejectAction, + RequestAction, + ResponseAction, ServerActions, distinctAdd, remove @@ -150,9 +153,46 @@ export class DefaultGLSPServer implements GLSPServer { } const action = message.action; ClientAction.mark(action); + if (RequestAction.is(action)) { + this.handleClientRequest(clientSession, action, message.clientId); + return; + } clientSession.actionDispatcher.dispatch(action).catch(error => this.handleProcessError(message, error)); } + // Fire-and-forget: intentionally not awaited by process() + protected async handleClientRequest( + clientSession: ClientSession, + action: RequestAction, + clientId: string + ): Promise { + try { + const response = + action.timeout !== undefined + ? await clientSession.actionDispatcher.requestUntil(action, action.timeout, true) + : await clientSession.actionDispatcher.request(action); + if (response) { + this.sendResponseToClient(clientId, response); + } + } catch (error) { + const detail = error instanceof GLSPServerError ? error.cause?.toString?.() : error?.toString?.(); + this.logger.error(`Failed to handle request '${action.kind}' (${action.requestId}):`, detail); + try { + const reject = RejectAction.create(`Failed to handle request '${action.kind}' (${action.requestId})`, { + responseId: action.requestId, + detail + }); + this.sendResponseToClient(clientId, reject); + } catch (sendError) { + this.logger.error(`Failed to send rejection for request '${action.requestId}':`, sendError); + } + } + } + + protected sendResponseToClient(clientId: string, response: ResponseAction): void { + this.sendToClient({ clientId, action: response }); + } + protected handleProcessError(message: ActionMessage, reason: any): void | PromiseLike { let errorMsg = `Could not process action: '${message.action.kind}`; this.logger.error(errorMsg); diff --git a/packages/server/src/common/test/mock-util.ts b/packages/server/src/common/test/mock-util.ts index 755a560..76914cc 100644 --- a/packages/server/src/common/test/mock-util.ts +++ b/packages/server/src/common/test/mock-util.ts @@ -30,7 +30,9 @@ import { MaybeArray, MaybePromise, Point, + RequestAction, RequestEditValidationAction, + ResponseAction, ShapeTypeHint, ValidationStatus } from '@eclipse-glsp/protocol'; @@ -95,7 +97,7 @@ export function createClientSession( export class StubActionHandler implements ActionHandler { constructor(public actionKinds: string[]) {} - execute(action: Action): Action[] { + execute(action: Action): MaybePromise { return []; } } @@ -136,6 +138,18 @@ export class StubActionDispatcher implements ActionDispatcher { dispatchAll(...actions: MaybeArray[]): Promise { return Promise.resolve(); } + + request(action: RequestAction): Promise { + return Promise.reject(new Error('Not implemented in stub')); + } + + requestUntil( + action: RequestAction, + timeoutMs?: number, + rejectOnTimeout?: boolean + ): Promise { + return Promise.reject(new Error('Not implemented in stub')); + } } export class StubClientSessionFactory implements ClientSessionFactory { diff --git a/packages/server/src/common/utils/action-queue.spec.ts b/packages/server/src/common/utils/action-queue.spec.ts new file mode 100644 index 0000000..86ae482 --- /dev/null +++ b/packages/server/src/common/utils/action-queue.spec.ts @@ -0,0 +1,133 @@ +/******************************************************************************** + * Copyright (c) 2026 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 { expectToThrowAsync } from '../test/mock-util'; +import { ActionQueue } from './action-queue'; + +describe('ActionQueue', () => { + it('yields pushed items in FIFO order', async () => { + const channel = new ActionQueue(); + const consumed: number[] = []; + + const consumer = (async (): Promise => { + for await (const entry of channel.consume()) { + consumed.push(entry.item); + entry.resolve(); + } + })(); + + await Promise.all([channel.push(1), channel.push(2), channel.push(3)]); + channel.stop(); + await consumer; + + expect(consumed).to.deep.equal([1, 2, 3]); + }); + + it('resolves the push promise once the consumer resolves the entry', async () => { + const channel = new ActionQueue(); + let entryResolver: (() => void) | undefined; + + const consumer = (async (): Promise => { + for await (const entry of channel.consume()) { + entryResolver = entry.resolve; + return; + } + })(); + + const pushed = channel.push('a'); + // Give the consumer a turn to pick up the entry. + await Promise.resolve(); + await consumer; + expect(entryResolver).to.exist; + entryResolver!(); + await pushed; + }); + + it('propagates reject() from the consumer back to the pushing caller', async () => { + const channel = new ActionQueue(); + + const consumer = (async (): Promise => { + for await (const entry of channel.consume()) { + entry.reject(new Error('boom')); + return; + } + })(); + + const pushed = channel.push(1); + await consumer; + await expectToThrowAsync(() => pushed, 'boom'); + }); + + it('rejects push() after stop()', async () => { + const channel = new ActionQueue(); + channel.stop(); + await expectToThrowAsync(() => channel.push(1), 'ActionQueue is stopped'); + }); + + it('consumer exits after stop() and drain', async () => { + const channel = new ActionQueue(); + const consumed: number[] = []; + + const consumer = (async (): Promise => { + for await (const entry of channel.consume()) { + consumed.push(entry.item); + entry.resolve(); + } + })(); + + await channel.push(1); + await channel.push(2); + channel.stop(); + await consumer; + + expect(consumed).to.deep.equal([1, 2]); + expect(channel.isStopped).to.be.true; + }); + + it('rejectPending() rejects all queued push() promises without stopping', async () => { + const channel = new ActionQueue(); + const pushes = [channel.push(1), channel.push(2)]; + expect(channel.size).to.equal(2); + + channel.rejectPending(new Error('cleared')); + + await expectToThrowAsync(() => pushes[0], 'cleared'); + await expectToThrowAsync(() => pushes[1], 'cleared'); + expect(channel.size).to.equal(0); + expect(channel.isStopped).to.be.false; + }); + + it('size reflects the number of unconsumed entries', async () => { + const channel = new ActionQueue(); + channel.push(1); + channel.push(2); + channel.push(3); + expect(channel.size).to.equal(3); + }); + + it('throws when a second consumer is started', async () => { + const channel = new ActionQueue(); + const first = channel.consume(); + // Kick off the first consumer so it registers as the active consumer. + const firstStep = first.next(); + + const second = channel.consume(); + await expectToThrowAsync(() => second.next().then(() => undefined), 'ActionQueue supports only a single consumer'); + + channel.stop(); + await firstStep; + }); +}); diff --git a/packages/server/src/common/utils/action-queue.ts b/packages/server/src/common/utils/action-queue.ts new file mode 100644 index 0000000..e0718b4 --- /dev/null +++ b/packages/server/src/common/utils/action-queue.ts @@ -0,0 +1,107 @@ +/******************************************************************************** + * Copyright (c) 2026 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 + ********************************************************************************/ + +/** + * An entry yielded by {@link ActionQueue.consume}. The consumer must call either + * `resolve()` or `reject(error)` exactly once after processing `item`. + */ +export interface ActionQueueEntry { + item: T; + resolve: () => void; + reject: (error: unknown) => void; +} + +/** + * Producer/consumer queue with a single async consumer loop. Items are processed in FIFO order. + */ +export class ActionQueue { + protected queue: ActionQueueEntry[] = []; + protected notify: (() => void) | undefined; + protected stopped = false; + protected consuming = false; + + /** + * Enqueues an item. The returned promise settles when the consumer finishes processing it, + * propagating results back to the producer. Rejects immediately if the queue has been stopped. + */ + push(item: T): Promise { + if (this.stopped) { + return Promise.reject(new Error('ActionQueue is stopped')); + } + return new Promise((resolve, reject) => { + this.queue.push({ item, resolve, reject }); + this.notify?.(); + }); + } + + /** + * Yields pending entries, suspending until the next {@link push} when the queue is empty. Exits + * once the queue is stopped and has been drained. + * + * Single-consumer: calling `consume()` a second time throws an error. + */ + async *consume(): AsyncGenerator> { + if (this.consuming) { + throw new Error('ActionQueue supports only a single consumer'); + } + this.consuming = true; + try { + while (!this.stopped || this.queue.length > 0) { + while (this.queue.length > 0) { + yield this.queue.shift()!; + } + if (this.stopped) { + return; + } + await new Promise(resolve => { + this.notify = resolve; + }); + this.notify = undefined; + } + } finally { + this.consuming = false; + } + } + + /** + * Stops the queue. Further {@link push} calls reject. The consumer loop exits after + * the remaining queued entries have been yielded (or immediately if the queue is empty). + */ + stop(): void { + this.stopped = true; + this.notify?.(); + } + + /** + * Rejects all queued entries with the given reason so producers awaiting their + * `push()` promises do not hang. Does not stop the queue. + */ + rejectPending(reason: Error = new Error('ActionQueue cleared')): void { + const pending = this.queue; + this.queue = []; + for (const entry of pending) { + entry.reject(reason); + } + } + + get size(): number { + return this.queue.length; + } + + get isStopped(): boolean { + return this.stopped; + } +} diff --git a/packages/server/src/common/utils/promise-queue.spec.ts b/packages/server/src/common/utils/promise-queue.spec.ts index 9c2e1aa..ef195fc 100644 --- a/packages/server/src/common/utils/promise-queue.spec.ts +++ b/packages/server/src/common/utils/promise-queue.spec.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -14,8 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { delay } from '../test/mock-util'; -import { PromiseQueue } from './promise-queue'; + import { expect } from 'chai'; +// eslint-disable-next-line import-x/no-deprecated +import { PromiseQueue } from './promise-queue'; // Helper types and functions that are needed for test setup @@ -74,11 +76,13 @@ function newTestPromise(resolveTime: number): TestPromise { return { state, promise }; } +// eslint-disable-next-line @typescript-eslint/no-deprecated, import-x/no-deprecated let queue = new PromiseQueue(); // Test execution describe('test PromiseQueue', () => { beforeEach(() => { + // eslint-disable-next-line import-x/no-deprecated, @typescript-eslint/no-deprecated queue = new PromiseQueue(); }); it('enqueue - one element', async () => { diff --git a/packages/server/src/common/utils/promise-queue.ts b/packages/server/src/common/utils/promise-queue.ts index ae256a1..4725ab0 100644 --- a/packages/server/src/common/utils/promise-queue.ts +++ b/packages/server/src/common/utils/promise-queue.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2023 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -29,6 +29,10 @@ export interface PromiseQueueElement { * of promises. Promises that are put in this queue are processed one by one. * i.e. After the first promise in the queue is resolved, it will be removed from the queue and the resolving of the * the next promise (if present) will start. The queue can only resolve one promise at a given time. + * + * @deprecated Since 2.7. The `DefaultActionDispatcher` no longer uses this queue. Kept for + * backwards compatibility; will be removed in a future release. New code should use + * {@link ActionQueue} or native async patterns instead. */ export class PromiseQueue { protected queue: PromiseQueueElement[] = []; diff --git a/packages/server/src/node/actions/action-dispatcher.spec.ts b/packages/server/src/node/actions/action-dispatcher.spec.ts new file mode 100644 index 0000000..1532b92 --- /dev/null +++ b/packages/server/src/node/actions/action-dispatcher.spec.ts @@ -0,0 +1,656 @@ +/******************************************************************************** + * Copyright (c) 2022-2026 STMicroelectronics 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 { Action, Deferred, RequestAction, ResponseAction, UpdateModelAction } from '@eclipse-glsp/protocol'; +import { expect } from 'chai'; +import { Container, ContainerModule } from 'inversify'; +import * as sinon from 'sinon'; +import { ActionDispatchScope, DefaultActionDispatcher } from '../../common/actions/action-dispatcher'; +import { ActionHandler } from '../../common/actions/action-handler'; +import { ActionHandlerRegistry } from '../../common/actions/action-handler-registry'; +import { ClientActionForwarder } from '../../common/actions/client-action-handler'; +import { ClientActionKinds, ClientId } from '../../common/di/service-identifiers'; +import { ClientSessionManager } from '../../common/session/client-session-manager'; +import * as mock from '../../common/test/mock-util'; +import { Logger } from '../../common/utils/logger'; +import { NodeActionDispatchScope } from '../di/node-action-dispatch-scope'; + +function waitSync(timeInMillis: number): void { + const start = Date.now(); + let now = start; + while (now - start < timeInMillis) { + now = Date.now(); + } +} + +describe('test DefaultActionDispatcher', () => { + const container = new Container(); + const clientId = 'myClientId'; + const actionHandlerRegistry = new ActionHandlerRegistry(); + let registry_get_stub: sinon.SinonStub<[string], ActionHandler[]>; + const sandbox = sinon.createSandbox(); + + const clientActionForwarderStub = sinon.createStubInstance(ClientActionForwarder); + + container.load( + new ContainerModule(bind => { + bind(Logger).toConstantValue(new mock.StubLogger()); + bind(ClientSessionManager).toConstantValue(new mock.StubClientSessionManager()); + bind(ClientId).toConstantValue(clientId); + bind(ActionHandlerRegistry).toConstantValue(actionHandlerRegistry); + bind(ClientActionKinds).toConstantValue(new Set(['response', 'response1', 'response2'])); + bind(ClientActionForwarder).toConstantValue(clientActionForwarderStub); + bind(ActionDispatchScope).to(NodeActionDispatchScope); + }) + ); + const actionDispatcher = container.resolve(DefaultActionDispatcher); + + beforeEach(() => { + registry_get_stub = sandbox.stub(actionHandlerRegistry, 'get'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('test with one-way actions (no response actions)', () => { + it('dispatch- unhandled action', async () => { + mock.expectToThrowAsync(() => actionDispatcher.dispatch({ kind: 'unhandled' })); + }); + + it('dispatch - one action', async () => { + // Mock setup + const action = 'action'; + const handler = new mock.StubActionHandler([action]); + const getHandler = (kind: string): ActionHandler[] => (kind === action ? [handler] : []); + registry_get_stub.callsFake(getHandler); + const spy_handler_execute = sinon.spy(handler, 'execute'); + // Test execution + await actionDispatcher.dispatch({ kind: action }); + expect(spy_handler_execute.calledOnce).true; + }); + + describe('test multi dispatch with single-handled actions', () => { + // Mock Setup + const action1 = 'a1'; + const action2 = 'a2'; + const action3 = 'a3'; + + const handler1 = new mock.StubActionHandler([action1]); + const handler2 = new mock.StubActionHandler([action2]); + const handler3 = new mock.StubActionHandler([action3]); + + const spy_handler1_execute = sinon.stub(handler1, 'execute').returns([]); + const spy_handler2_execute = sinon.stub(handler2, 'execute').returns([]); + const spy_handler3_execute = sinon.stub(handler3, 'execute').returns([]); + const handlerMockImpl = (kind: string): ActionHandler[] => { + switch (kind) { + case action1: + return [handler1]; + case action2: + return [handler2]; + case action3: + return [handler3]; + default: + return []; + } + }; + + it('dispatch - multiple actions', async () => { + // Mock setup + registry_get_stub.callsFake(handlerMockImpl); + // Test execution + actionDispatcher.dispatch({ kind: action1 }); + actionDispatcher.dispatch({ kind: action2 }); + await actionDispatcher.dispatch({ kind: action3 }); + // Check if all handlers have been called + expect(spy_handler1_execute.called).true; + expect(spy_handler2_execute.called).true; + expect(spy_handler3_execute.called).true; + // Check if all handlers have been called in the right order + sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); + }); + + it('dispatchAll- multiple actions', async () => { + // Mock setup + registry_get_stub.callsFake(handlerMockImpl); + // Test execution + await actionDispatcher.dispatchAll([{ kind: action1 }, { kind: action2 }, { kind: action3 }]); + // Check if all handlers have been called + expect(spy_handler1_execute.calledOnce); + expect(spy_handler2_execute.calledOnce); + expect(spy_handler3_execute.calledOnce); + // Check if all handlers have been called in the right order + sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); + }); + + it('dispatch - multiple actions (racing execution times)', async () => { + // Mock setup + registry_get_stub.callsFake(handlerMockImpl); + spy_handler1_execute.callsFake((_action: Action) => { + waitSync(500); + return []; + }); + spy_handler2_execute.callsFake((_action: Action) => { + waitSync(200); + return []; + }); + spy_handler3_execute.callsFake((_action: Action) => { + waitSync(100); + return []; + }); + // Test execution + actionDispatcher.dispatch({ kind: action1 }); + actionDispatcher.dispatch({ kind: action2 }); + await actionDispatcher.dispatch({ kind: action3 }); + // Check if all handlers have been called + expect(spy_handler1_execute.calledOnce); + expect(spy_handler2_execute.calledOnce); + expect(spy_handler3_execute.calledOnce); + // Check if all handlers have been called in the right order + sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute, spy_handler3_execute); + }); + }); + + it('dispatch- one action & multiple handlers', async () => { + // Mock setup + const action1 = 'a1'; + + const handler1 = new mock.StubActionHandler([action1]); + const handler2 = new mock.StubActionHandler([action1]); + + registry_get_stub.callsFake((kind: string) => (kind === action1 ? [handler1, handler2] : [])); + const spy_handler1_execute = sinon.spy(handler1, 'execute'); + const spy_handler2_execute = sinon.spy(handler2, 'execute'); + // Test execution + await actionDispatcher.dispatch({ kind: action1 }); + expect(spy_handler1_execute.calledOnce); + expect(spy_handler2_execute.calledOnce); + sinon.assert.callOrder(spy_handler1_execute, spy_handler2_execute); + }); + }); + + describe('test with handler response actions ', () => { + it('dispatch - one action & one handler response action', async () => { + // Mock setup + const request = 'request'; + const response = 'response'; + + const requestHandler = new mock.StubActionHandler([request]); + const responseHandler = new mock.StubActionHandler([response]); + + const spy_requestHandler_execute = sinon.stub(requestHandler, 'execute').returns([{ kind: response }]); + const spy_responseHandler_execute = sinon.spy(responseHandler, 'execute'); + registry_get_stub.callsFake((kind: string) => { + switch (kind) { + case request: + return [requestHandler]; + case response: + return [responseHandler]; + default: + return []; + } + }); + // Test execution + await actionDispatcher.dispatch({ kind: request }); + // Add a delay so that the action dispatcher has time to dispatch the handler response + await mock.delay(200); + // Check if all handlers have been called + expect(spy_requestHandler_execute.calledOnce); + expect(spy_responseHandler_execute.calledOnce); + }); + + it('dispatch - multiple actions & multiple response', async () => { + // Mock setup + const request1 = 'request1'; + const request2 = 'request2'; + const response1 = 'response1'; + const response2 = 'response2'; + + const responseHandler1 = new mock.StubActionHandler([response1]); + const responseHandler2 = new mock.StubActionHandler([response2]); + const requestHandler1 = new mock.StubActionHandler([request1]); + const requestHandler2 = new mock.StubActionHandler([request2]); + + const spy_requestHandler1_execute = sinon.stub(requestHandler1, 'execute').returns([{ kind: response1 }, { kind: response2 }]); + const spy_requestHandler2_execute = sinon.stub(requestHandler2, 'execute').returns([{ kind: response2 }]); + const spy_responseHandler1_execute = sinon.spy(responseHandler1, 'execute'); + const spy_responseHandler2_execute = sinon.spy(responseHandler2, 'execute'); + registry_get_stub.callsFake((kind: string) => { + switch (kind) { + case request1: + return [requestHandler1]; + case request2: + return [requestHandler2]; + case response1: + return [responseHandler1, responseHandler2]; + case response2: + return [responseHandler2]; + default: + return []; + } + }); + // Test execution + actionDispatcher.dispatch({ kind: request1 }); + await actionDispatcher.dispatch({ kind: request2 }); + + // Add a delay so that the action dispatcher has time to dispatch the handler response + await mock.delay(100); + // Check if all handlers have been called correctly + expect(spy_requestHandler1_execute.calledOnce); + expect(spy_requestHandler2_execute.calledOnce); + expect(spy_responseHandler1_execute.calledOnce); + expect(spy_responseHandler2_execute.calledThrice); + // Check if all handlers have been called in the right order + sinon.assert.callOrder(spy_requestHandler1_execute, spy_requestHandler2_execute); + sinon.assert.callOrder(spy_responseHandler1_execute, spy_responseHandler2_execute); + }); + }); + + describe('test dispatch after next update', () => { + it('dispatchAfterNextUpdate', async () => { + // Mock setup + const updateModelAction = UpdateModelAction.create({ id: 'newRoot', type: 'myType' }); + const intermediateAction = 'intermediate'; + const postUpdateAction = 'postUpdate'; + const handler = new mock.StubActionHandler([updateModelAction.kind, intermediateAction]); + const postUpdateHandler = new mock.StubActionHandler([postUpdateAction]); + + const getHandler = (kind: string): ActionHandler[] => { + if (kind === updateModelAction.kind || kind === intermediateAction) { + return [handler]; + } else if (kind === postUpdateAction) { + return [postUpdateHandler]; + } + + return []; + }; + registry_get_stub.callsFake(getHandler); + const spy_postUpdateHandler_execute = sinon.spy(postUpdateHandler, 'execute'); + + // Test execution + actionDispatcher.dispatchAfterNextUpdate({ kind: postUpdateAction }); + expect(spy_postUpdateHandler_execute.called).to.be.false; + await actionDispatcher.dispatch({ kind: intermediateAction }); + expect(spy_postUpdateHandler_execute.called).to.be.false; + await actionDispatcher.dispatch(updateModelAction); + expect(spy_postUpdateHandler_execute.calledOnce); + // Check that action does not get dispatched again + await actionDispatcher.dispatch(updateModelAction); + expect(spy_postUpdateHandler_execute.calledOnce); + }); + }); + + describe('test request/response', () => { + it('request - resolves when matching response is dispatched', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_1' + }; + const responseAction: ResponseAction = { + kind: 'testResponse', + responseId: 'req_1' + }; + + // Configure forwarder: testRequest is forwarded to the client + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + registry_get_stub.callsFake(() => []); + + const responsePromise = actionDispatcher.request(requestAction); + await actionDispatcher.dispatch(responseAction); + + const result = await responsePromise; + expect(result.responseId).to.equal('req_1'); + }); + + it('request - response bypasses queue even when queue is busy', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_deadlock' + }; + const responseAction: ResponseAction = { + kind: 'testResponse', + responseId: 'req_deadlock' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + + const handlerRunning = new Deferred(); + + const slowAction = 'slowAction'; + const slowHandler = new mock.StubActionHandler([slowAction]); + slowHandler.execute = async () => { + const resultPromise = actionDispatcher.request(requestAction); + handlerRunning.resolve(); + const result = await resultPromise; + expect(result.responseId).to.equal('req_deadlock'); + return []; + }; + registry_get_stub.callsFake((kind: string) => (kind === slowAction ? [slowHandler] : [])); + + const dispatchPromise = actionDispatcher.dispatch({ kind: slowAction }); + await handlerRunning.promise; + + // Response must resolve even though the queue is busy + await actionDispatcher.dispatch(responseAction); + await dispatchPromise; + }); + + it('request - resolves for locally handled request (server→server)', async () => { + const localRequestKind = 'localRequest'; + const localResponseKind = 'localResponse'; + + const handler = new mock.StubActionHandler([localRequestKind]); + sinon.stub(handler, 'execute').callsFake(() => { + const response: ResponseAction = { kind: localResponseKind, responseId: '' }; + return [response]; + }); + registry_get_stub.callsFake((kind: string) => (kind === localRequestKind ? [handler] : [])); + + const requestAction: RequestAction = { + kind: localRequestKind, + requestId: '' + }; + + const result = await actionDispatcher.request(requestAction); + expect(result).to.exist; + expect(result.responseId).to.equal(requestAction.requestId); + }); + + it('request - resolves for locally handled request called from inside a handler', async () => { + const innerRequestKind = 'innerRequest'; + const innerResponseKind = 'innerResponse'; + const outerActionKind = 'outerAction'; + + const innerHandler = new mock.StubActionHandler([innerRequestKind]); + sinon.stub(innerHandler, 'execute').callsFake(() => { + const response: ResponseAction = { kind: innerResponseKind, responseId: '' }; + return [response]; + }); + + const outerHandler = new mock.StubActionHandler([outerActionKind]); + outerHandler.execute = async () => { + const innerRequest: RequestAction = { + kind: innerRequestKind, + requestId: '' + }; + const result = await actionDispatcher.request(innerRequest); + expect(result).to.exist; + return []; + }; + + registry_get_stub.callsFake((kind: string) => { + if (kind === outerActionKind) return [outerHandler]; + if (kind === innerRequestKind) return [innerHandler]; + return []; + }); + + // This must not deadlock + await actionDispatcher.dispatch({ kind: outerActionKind }); + }); + + it('requestUntil - rejects on timeout when rejectOnTimeout is true', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_hard' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + registry_get_stub.callsFake(() => []); + + try { + await actionDispatcher.requestUntil(requestAction, 100, true); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('timed out'); + } + }); + + it('requestUntil - resolves undefined on timeout when rejectOnTimeout is false', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_soft' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + registry_get_stub.callsFake(() => []); + + const result = await actionDispatcher.requestUntil(requestAction, 100, false); + expect(result).to.be.undefined; + }); + + it('request - auto-generates requestId when empty', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: '' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + registry_get_stub.callsFake(() => []); + + const responsePromise = actionDispatcher.request(requestAction); + expect(requestAction.requestId).to.match(/^server_.*_\d+$/); + + await actionDispatcher.dispatch({ + kind: 'testResponse', + responseId: requestAction.requestId + } as ResponseAction); + + const result = await responsePromise; + expect(result).to.exist; + }); + + it('request - rejects when dispatch fails (no handler, not a client action)', async () => { + const requestAction: RequestAction = { + kind: 'unknownRequest', + requestId: 'req_fail' + }; + + // NOT forwarded to client, no handler registered → dispatch throws + clientActionForwarderStub.shouldForwardToClient.returns(false); + clientActionForwarderStub.handle.returns(false); + registry_get_stub.callsFake(() => []); + + try { + await actionDispatcher.request(requestAction); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('No handler registered'); + } + }); + + it('requestUntil - late response after timeout has responseId cleared', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_late' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + // Register a no-op handler for testResponse so the late response can be dispatched normally + const noopHandler = new mock.StubActionHandler(['testResponse']); + registry_get_stub.callsFake((kind: string) => (kind === 'testResponse' ? [noopHandler] : [])); + + // Request times out + try { + await actionDispatcher.requestUntil(requestAction, 50, true); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('timed out'); + } + + // Late response arrives — responseId should be cleared, dispatched as normal action + const lateResponse: ResponseAction = { kind: 'testResponse', responseId: 'req_late' }; + await actionDispatcher.dispatch(lateResponse); + expect(lateResponse.responseId).to.equal(''); + }); + + it('request - resolves when response intercept happens from inside doDispatch', async () => { + // A local handler for the request kind returns the matching response action directly. + // The response is dispatched via dispatchResponses() -> dispatch() and intercepted + // via the reentrant path (not via the external dispatch() entry). + const requestKind = 'inlineRequest'; + const responseKind = 'inlineResponse'; + + const handler = new mock.StubActionHandler([requestKind]); + sinon.stub(handler, 'execute').callsFake(() => [{ kind: responseKind, responseId: '' } as ResponseAction]); + registry_get_stub.callsFake((kind: string) => (kind === requestKind ? [handler] : [])); + + const requestAction: RequestAction = { kind: requestKind, requestId: '' }; + const result = await actionDispatcher.request(requestAction); + + expect(result.responseId).to.equal(requestAction.requestId); + }); + }); + + describe('test reentrancy and ordering', () => { + it('dispatch from within a handler runs inline before the next queued action', async () => { + const outerKind = 'reentrantOuter'; + const innerKind = 'reentrantInner'; + const followerKind = 'reentrantFollower'; + + const events: string[] = []; + + const innerHandler = new mock.StubActionHandler([innerKind]); + sinon.stub(innerHandler, 'execute').callsFake(() => { + events.push('inner'); + return []; + }); + + const outerHandler = new mock.StubActionHandler([outerKind]); + outerHandler.execute = async () => { + events.push('outer-start'); + await actionDispatcher.dispatch({ kind: innerKind }); + events.push('outer-end'); + return []; + }; + + const followerHandler = new mock.StubActionHandler([followerKind]); + sinon.stub(followerHandler, 'execute').callsFake(() => { + events.push('follower'); + return []; + }); + + registry_get_stub.callsFake((kind: string) => { + if (kind === outerKind) return [outerHandler]; + if (kind === innerKind) return [innerHandler]; + if (kind === followerKind) return [followerHandler]; + return []; + }); + + actionDispatcher.dispatch({ kind: outerKind }); + await actionDispatcher.dispatch({ kind: followerKind }); + + expect(events).to.deep.equal(['outer-start', 'inner', 'outer-end', 'follower']); + }); + + it('handler response actions are dispatched in order without an ephemeral queue', async () => { + const requestKind = 'orderedRequest'; + const firstResponse = 'orderedResponse1'; + const secondResponse = 'orderedResponse2'; + const order: string[] = []; + + const requestHandler = new mock.StubActionHandler([requestKind]); + sinon.stub(requestHandler, 'execute').callsFake(() => [{ kind: firstResponse }, { kind: secondResponse }]); + + const firstHandler = new mock.StubActionHandler([firstResponse]); + sinon.stub(firstHandler, 'execute').callsFake(async () => { + await mock.delay(20); + order.push(firstResponse); + return []; + }); + + const secondHandler = new mock.StubActionHandler([secondResponse]); + sinon.stub(secondHandler, 'execute').callsFake(() => { + order.push(secondResponse); + return []; + }); + + registry_get_stub.callsFake((kind: string) => { + if (kind === requestKind) return [requestHandler]; + if (kind === firstResponse) return [firstHandler]; + if (kind === secondResponse) return [secondHandler]; + return []; + }); + + await actionDispatcher.dispatch({ kind: requestKind }); + expect(order).to.deep.equal([firstResponse, secondResponse]); + }); + }); + + describe('test dispose', () => { + it('dispose - rejects all pending requests', async () => { + const requestAction: RequestAction = { + kind: 'testRequest', + requestId: 'req_dispose' + }; + + clientActionForwarderStub.shouldForwardToClient.callsFake(action => action.kind === 'testRequest'); + clientActionForwarderStub.handle.callsFake(action => action.kind === 'testRequest'); + registry_get_stub.callsFake(() => []); + + const responsePromise = actionDispatcher.request(requestAction); + actionDispatcher.dispose(); + + try { + await responsePromise; + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('cancelled'); + expect((error as Error).message).to.include('req_dispose'); + } + }); + + it('dispose - rejects queued dispatch() promises instead of orphaning them', async () => { + // Use a fresh dispatcher so the shared one is not affected. + const localDispatcher = container.resolve(DefaultActionDispatcher); + const slowKind = 'slowDispose'; + const queuedKind = 'queuedDispose'; + + const slowHandler = new mock.StubActionHandler([slowKind]); + const slowStarted = new Deferred(); + const releaseSlow = new Deferred(); + slowHandler.execute = async () => { + slowStarted.resolve(); + await releaseSlow.promise; + return []; + }; + + registry_get_stub.callsFake((kind: string) => (kind === slowKind ? [slowHandler] : [])); + + const slowPromise = localDispatcher.dispatch({ kind: slowKind }); + await slowStarted.promise; + const queuedPromise = localDispatcher.dispatch({ kind: queuedKind }); + + localDispatcher.dispose(); + + try { + await queuedPromise; + expect.fail('Queued dispatch should have rejected'); + } catch (error: unknown) { + expect((error as Error).message).to.include('ActionDispatcher disposed'); + } + + // Let the slow handler finish so the local dispatcher's consumer loop can exit cleanly. + releaseSlow.resolve(); + await slowPromise; + }); + }); +}); diff --git a/packages/server/src/node/di/app-module.ts b/packages/server/src/node/di/app-module.ts index 06c1a4d..d03e199 100644 --- a/packages/server/src/node/di/app-module.ts +++ b/packages/server/src/node/di/app-module.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2025 STMicroelectronics and others. + * Copyright (c) 2022-2026 STMicroelectronics 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 @@ -16,13 +16,15 @@ import { BindingContext } from '@eclipse-glsp/protocol/lib/di'; import { ContainerModule } from 'inversify'; import * as winston from 'winston'; -import { InjectionContainer, LogLevel, Logger, LoggerFactory, NullLogger, getRequestParentName } from '../../common'; +import { ActionDispatchScope, InjectionContainer, LogLevel, Logger, LoggerFactory, NullLogger, getRequestParentName } from '../../common'; import { LaunchOptions } from '../launch/cli-parser'; +import { NodeActionDispatchScope } from './node-action-dispatch-scope'; import { WinstonLogger } from './winston-logger'; export function createAppModule(options: LaunchOptions): ContainerModule { return new ContainerModule((bind, unbind, isBound, rebind) => { bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container); + bind(ActionDispatchScope).to(NodeActionDispatchScope).inSingletonScope(); const context = { bind, unbind, isBound, rebind }; configureWinstonLogger(context, options); }); diff --git a/packages/server/src/node/di/node-action-dispatch-scope.ts b/packages/server/src/node/di/node-action-dispatch-scope.ts new file mode 100644 index 0000000..359cb86 --- /dev/null +++ b/packages/server/src/node/di/node-action-dispatch-scope.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * Copyright (c) 2026 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 { AsyncLocalStorage } from 'async_hooks'; +import { injectable } from 'inversify'; +import { ActionDispatchScope } from '../../common/actions/action-dispatcher'; + +/** + * Node.js {@link ActionDispatchScope} backed by native `AsyncLocalStorage`. + */ +@injectable() +export class NodeActionDispatchScope implements ActionDispatchScope { + protected storage = new AsyncLocalStorage(); + + enter(callback: () => R): R { + return this.storage.run(true, callback); + } + + isReentrant(): boolean { + return this.storage.getStore() === true; + } +} diff --git a/packages/server/src/node/index.ts b/packages/server/src/node/index.ts index 9de6264..3806206 100644 --- a/packages/server/src/node/index.ts +++ b/packages/server/src/node/index.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022-2024 EclipseSource and others. + * Copyright (c) 2022-2026 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 @@ -15,6 +15,7 @@ ********************************************************************************/ export * from './abstract-json-model-storage'; export * from './di/app-module'; +export * from './di/node-action-dispatch-scope'; export * from './di/winston-logger'; export * from './gmodel/gmodel-storage'; export * from './launch/cli-parser'; diff --git a/yarn.lock b/yarn.lock index ac1b054..5eedfcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -223,18 +223,18 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@eclipse-glsp/cli@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/cli/-/cli-2.7.0-next.10.tgz#75cf853feb77396b534495fd50f31b26b271db0d" - integrity sha512-/r8TGvp8jE0Tdzp4Sl7QJxyGL8OmaCBOcwl6V53F6xnay9WmO/U6LNDDc4RLDXLXzNiUEFy4J2V5+nPcdTa2jg== - -"@eclipse-glsp/config-test@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/config-test/-/config-test-2.7.0-next.10.tgz#aea5b8d3c86027c76b9ee06c42dfbb71746dc22c" - integrity sha512-xhgO3MnDJ5d/xhicXxIQgC6Q5EMXg7pqZfZNdhrhMTmLgx4iR292WAMGNAA8qSVETxddNTaw7zOb8gFoOYXeRA== - dependencies: - "@eclipse-glsp/mocha-config" "2.7.0-next.10+743aad5" - "@eclipse-glsp/nyc-config" "2.7.0-next.10+743aad5" +"@eclipse-glsp/cli@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/cli/-/cli-2.7.0-next.13.tgz#03d49ae55bb551631154d114e03e7ab32df87faf" + integrity sha512-5Rj+J5ikKDnjpkoYZc2LCIP9KaP09SXF3Ftefj3XYc3BD44/GJdngBe2uKNAxM71rSmBihBmbLoF/LxL0nr+Ow== + +"@eclipse-glsp/config-test@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/config-test/-/config-test-2.7.0-next.13.tgz#f190f3f08e7d7b0ac54c851e1d2ce411a9ddbbfd" + integrity sha512-8iYAhMfEfCSVPVndqfyRCAexuXEUpdyic1zbXwuiAfAYlH5kAGVAkzCqyeXdo71Be4eX4bS43iRbi91Nmks6Dg== + dependencies: + "@eclipse-glsp/mocha-config" "2.7.0-next.13+90c0040" + "@eclipse-glsp/nyc-config" "2.7.0-next.13+90c0040" "@istanbuljs/nyc-config-typescript" "^1.0.2" "@types/chai" "^4.3.7" "@types/mocha" "^10.0.2" @@ -247,14 +247,14 @@ sinon "^15.1.0" ts-node "^10.9.1" -"@eclipse-glsp/config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/config/-/config-2.7.0-next.10.tgz#e6aa4ab50057f828facd521cd2dee198aaf973e9" - integrity sha512-ZqIcL8nPAKLduPsoyOYvUrCb6kRv8YQQ7JXcp831YDzj1Ay3vlOq9yG2My6ytyQvR3hs9MFD09467elgYq9xrw== +"@eclipse-glsp/config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/config/-/config-2.7.0-next.13.tgz#56f615c0a4520d5cec48200200649d6c9d52c36d" + integrity sha512-mkqntgl3ARfHx3jMhTYSEPUlHyJ8NJaPfSu0nGiUrmOE8qcgrmc76fdynXymeSlkmbnWgS3iFmR28/kdzyAXUw== dependencies: - "@eclipse-glsp/eslint-config" "2.7.0-next.10+743aad5" - "@eclipse-glsp/prettier-config" "2.7.0-next.10+743aad5" - "@eclipse-glsp/ts-config" "2.7.0-next.10+743aad5" + "@eclipse-glsp/eslint-config" "2.7.0-next.13+90c0040" + "@eclipse-glsp/prettier-config" "2.7.0-next.13+90c0040" + "@eclipse-glsp/ts-config" "2.7.0-next.13+90c0040" "@eslint/js" "^9.0.0" "@stylistic/eslint-plugin" "^2.0.0" "@tony.ganchev/eslint-plugin-header" "^3.1.1" @@ -271,49 +271,49 @@ typescript-eslint "^8.0.0" "@eclipse-glsp/dev@next": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/dev/-/dev-2.7.0-next.10.tgz#81b1b86c2e17d21074c3e6c8fccaf0d396270fad" - integrity sha512-lsYYLkLoj35Q3Q8Uet+WCHJQQANHFGFR9/o6DcSIMdUrRhUkyX1uS7XezQr7u9+GJ76yIwp5Q1yMYrcEBpCuzg== - dependencies: - "@eclipse-glsp/cli" "2.7.0-next.10+743aad5" - "@eclipse-glsp/config" "2.7.0-next.10+743aad5" - "@eclipse-glsp/config-test" "2.7.0-next.10+743aad5" - -"@eclipse-glsp/eslint-config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/eslint-config/-/eslint-config-2.7.0-next.10.tgz#c3a0bd614f52ca3d3c25f1512b8bd28c30777b9a" - integrity sha512-3bUCnz/qPQbABYimg0Bfo2TFMV5Qsk5tcy2uxs6ZTefX+7+AkUHOc57/A9XgRt/o3ngt3wCD+XdRkosBO/N6vw== - -"@eclipse-glsp/mocha-config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/mocha-config/-/mocha-config-2.7.0-next.10.tgz#a804c7d3f60974b3df2dc6893efda2e6b1d97c5b" - integrity sha512-mHQh6XCDnSYloOtON4qN1w8N1mSu9siZi32lfXclh2oVRu16ykUn2oaSQ8aXS6HgMK8FoWaItj5HWNP82qmfwg== - -"@eclipse-glsp/nyc-config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/nyc-config/-/nyc-config-2.7.0-next.10.tgz#c8cf5a3e00020fefb4d8cd2368bc6da458b9dabb" - integrity sha512-aYHgC8vgdGw8iK9ncNUkHgeHi6xaFso+8dECzeoQ+zLAx8aKqmh1//iyHXSWCIzwg1GsU0LkFX8FWdvX866jUg== - -"@eclipse-glsp/prettier-config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/prettier-config/-/prettier-config-2.7.0-next.10.tgz#da3c8b84b033b4c2c0ee809c29fc650386527d62" - integrity sha512-ovjSsvyt487lbrJ1+s2bTX4X2L5vZHW8gMUT9w18YjzPUQPAN6wCfmCVa5NqA+NP9U9DeDOSFp9G5g+YePdUqA== + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/dev/-/dev-2.7.0-next.13.tgz#cd29d7f8fc130602433c05b7e39c14f669aebc06" + integrity sha512-xnMKsqBtq1BZUPI2gC7/o5Le7mFP5KDZLm+tCZV08RNDkNs9o60Ijf+j8s02nLmo/IV8i0U5+dqh8SC15oUf+w== + dependencies: + "@eclipse-glsp/cli" "2.7.0-next.13+90c0040" + "@eclipse-glsp/config" "2.7.0-next.13+90c0040" + "@eclipse-glsp/config-test" "2.7.0-next.13+90c0040" + +"@eclipse-glsp/eslint-config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/eslint-config/-/eslint-config-2.7.0-next.13.tgz#8d5ff34651a397afe5a491ba53be94ca40bb440c" + integrity sha512-qaUVaG4ymXXuyPr+7UTq2X5Dbh6Jp4wIYkCwN8YxDZtPdoZKWo5AW7wNTUOpG+P1z8ishSIQI7ZzHpkyqDMXaw== + +"@eclipse-glsp/mocha-config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/mocha-config/-/mocha-config-2.7.0-next.13.tgz#0faa95ca78b61e999d37b512b26f56a0ec3f9dc8" + integrity sha512-oxTPOmn45TYJvG6GszXy9BZHRJprZuFDWRYRHhUasFd4HPNIGrchyxtxeXm8qRaVhR0bbKKTfvu14RcWx4AuCQ== + +"@eclipse-glsp/nyc-config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/nyc-config/-/nyc-config-2.7.0-next.13.tgz#19e8e28e3914a0a20008136906267600f02dc632" + integrity sha512-HY6AN3eiIM5gHCGnWnKAlGRoSh8JV1094caXw6aKfJ9BlD2lLFfK/G97Z//rlyTB2I+CIGyHPo46m65qh0FPKQ== + +"@eclipse-glsp/prettier-config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/prettier-config/-/prettier-config-2.7.0-next.13.tgz#b819c1c384ca9a0cc662727f8fa15b35ab488c59" + integrity sha512-8nbWre4W/t6gbVbVE7yz1Cf883pRA1rnWlkgtMF9OtwfhzEBeJxgpFWIe6I+nBlU8WQ+qxdZ0GOge0eJNMTSfg== dependencies: prettier-plugin-packagejson "~2.4.6" "@eclipse-glsp/protocol@next": - version "2.7.0-next.3" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/protocol/-/protocol-2.7.0-next.3.tgz#446fb5f0b13ca49651b35951903e92c1ef142f28" - integrity sha512-0QAsHKxCDaEqcqjdVIrYFlUhfbzCwcmHruTaYc74hnM/tOQbyk+DMIknhS8fGWGTb5orHjZM2hQTPrTjtHsUgg== + version "2.7.0-next.12" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/protocol/-/protocol-2.7.0-next.12.tgz#af91ae8a48ef8772a189537ada77949995710b6a" + integrity sha512-POB7bGy24sjQ5tPL4XrYZJeupLJNvDhI5jYFwKr3+wx86PZDstVz8bWydpHOa346Q8m4KKwU0vMJS+XMmyEFxQ== dependencies: sprotty-protocol "1.4.0" uuid "~10.0.0" vscode-jsonrpc "8.2.0" -"@eclipse-glsp/ts-config@2.7.0-next.10+743aad5": - version "2.7.0-next.10" - resolved "https://registry.yarnpkg.com/@eclipse-glsp/ts-config/-/ts-config-2.7.0-next.10.tgz#b3e4443d720318408ff299b7613521f226b0a2f2" - integrity sha512-Parige4p4pLPt2mlBnH9AKUXOqAQcOSjjQYVJZJ5vVpVIcuVJr7+q8PVyezlQ/8MmHIrCaJ4o+/xcs6Oft+8cg== +"@eclipse-glsp/ts-config@2.7.0-next.13+90c0040": + version "2.7.0-next.13" + resolved "https://registry.yarnpkg.com/@eclipse-glsp/ts-config/-/ts-config-2.7.0-next.13.tgz#9891d9dbe75dcaf099de44186b4dba6af836bb8d" + integrity sha512-EQEoM982uyxRQyjnnX5AVs54WnhcG7DiDu4cGF+2PWNZRn609wCgxYGz/SXEQCBZMJ2zGhsLX7AVmwEQZHn7Dw== "@emnapi/core@^1.1.0": version "1.6.0"