Skip to content
8 changes: 6 additions & 2 deletions packages/server/src/browser/di/app-module.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean> = 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;
});
});
});
67 changes: 67 additions & 0 deletions packages/server/src/browser/di/browser-action-dispatch-scope.ts
Original file line number Diff line number Diff line change
@@ -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<R>(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);
}
}
3 changes: 2 additions & 1 deletion packages/server/src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';
Loading
Loading