Skip to content

Commit aaee3db

Browse files
committed
Fix dispatching for browser entrypoint`
Refactor ActionDispatchContext and implementation for the browser side. The used ALS polyfill is not reliable and does not work with all browsers, v8 engines.
1 parent 62faece commit aaee3db

9 files changed

Lines changed: 130 additions & 23 deletions

File tree

packages/server/src/browser/di/app-module.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,15 @@
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
1515
********************************************************************************/
1616

17-
// Side-effect import: patches Promise, timers, XHR, observers on the current realm to preserve async context across awaits.
18-
import { AsyncLocalStorage } from 'als-browser';
1917
import { ContainerModule } from 'inversify';
2018
import { ActionDispatchContext, InjectionContainer, LogLevel, LoggerConfigOptions, configureConsoleLogger } from '../../common/';
19+
import { BrowserDispatchContext } from './browser-dispatch-context';
2120

2221
export function createAppModule(options: LoggerConfigOptions = {}): ContainerModule {
2322
const resolvedOptions: LoggerConfigOptions = { consoleLog: true, logLevel: LogLevel.info, ...options };
2423
return new ContainerModule((bind, unbind, isBound, rebind) => {
2524
bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container);
26-
bind(ActionDispatchContext).toDynamicValue(() => new AsyncLocalStorage<boolean>());
25+
bind(ActionDispatchContext).toDynamicValue(() => new BrowserDispatchContext());
2726
const context = { bind, unbind, isBound, rebind };
2827
configureConsoleLogger(context, resolvedOptions);
2928
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/********************************************************************************
2+
* Copyright (c) 2026 EclipseSource and others.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* This Source Code may also be made available under the following Secondary
9+
* Licenses when the conditions for such availability set forth in the Eclipse
10+
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
* with the GNU Classpath Exception which is available at
12+
* https://www.gnu.org/software/classpath/license.html.
13+
*
14+
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15+
********************************************************************************/
16+
17+
import { Action } from '@eclipse-glsp/protocol';
18+
import { ActionDispatchContext } from '../../common/actions/action-dispatcher';
19+
import { ClientAction } from '../../common/protocol/client-action';
20+
21+
/**
22+
* Browser-compatible {@link ActionDispatchContext} that uses a simple flag instead of
23+
* `AsyncLocalStorage`. Currently available polyfill implementations of `AsyncLocalStorage` do not work correctly
24+
* in certain browser/javascript engines (e.g. V8)
25+
*
26+
* The flag-based approach has one limitation:
27+
* it cannot distinguish "within the handler's async continuation" from "new event that arrived during an await gap.
28+
* This means, that subsequent client actions might be dispatched inline instead of being queued, if they arrive during an await gap.
29+
* To prevent this, we always treat client-originated actions as out-of-context, ensuring they are queued rather than dispatched inline.
30+
*
31+
* There still is a corner case for server-originated actions that are dispatched in a different async chain e.g timer-based.
32+
* However, in practice this means that these actions are dispatched inline instead of being queued, which does not cause any issues.
33+
* Since they originated from a different async chain, there were no order guarantees with respect to the current action anyway.
34+
*/
35+
export class BrowserDispatchContext implements ActionDispatchContext {
36+
protected active = false;
37+
38+
run<R>(callback: () => R): R {
39+
const prior = this.active;
40+
this.active = true;
41+
const result = callback();
42+
if (result instanceof Promise) {
43+
return result.finally(() => {
44+
this.active = prior;
45+
}) as unknown as R;
46+
}
47+
this.active = prior;
48+
return result;
49+
}
50+
51+
isInContext(action: Action): boolean {
52+
return this.active && !ClientAction.is(action);
53+
}
54+
}

packages/server/src/browser/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
1515
********************************************************************************/
1616
export * from './di/app-module';
17+
export * from './di/browser-dispatch-context';
1718
export * from './launch/worker-server-launcher';
1819
export * from './reexport';

packages/server/src/common/actions/action-dispatcher.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
flatPush
2727
} from '@eclipse-glsp/protocol';
2828
import { inject, injectable, postConstruct } from 'inversify';
29-
import { ActionDispatchContext, ClientId } from '../di/service-identifiers';
29+
import { ClientId } from '../di/service-identifiers';
3030
import { ActionChannel } from '../utils/action-channel';
3131
import { GLSPServerError } from '../utils/glsp-server-error';
3232
import { Logger } from '../utils/logger';
@@ -113,6 +113,36 @@ export interface ActionDispatcher {
113113
): Promise<Res | undefined>;
114114
}
115115

116+
export const ActionDispatchContext = Symbol('ActionDispatchContext');
117+
118+
/**
119+
* Scope marker that lets the {@link ActionDispatcher} know whether a call to `dispatch()`
120+
* originates from inside a running handler (reentrant) or from outside (external).
121+
*
122+
* The consumer loop wraps each action in {@link run} so that reentrant `dispatch()` calls
123+
* (handler responses, injected dispatcher calls) can be recognized via {@link isInContext}
124+
* and executed inline instead of being queued.
125+
*
126+
* Used by the {@link DefaultActionDispatcher} implementation.
127+
*/
128+
export interface ActionDispatchContext {
129+
/**
130+
* Executes the callback inside the dispatch context. While the callback (and its full
131+
* async continuation) is running, {@link isInContext} returns `true` for reentrant calls.
132+
*/
133+
run<R>(callback: () => R): R;
134+
135+
/**
136+
* Returns `true` if the caller is executing inside a {@link run} callback, meaning the
137+
* dispatch is reentrant (e.g. a handler response or an injected dispatcher call) and
138+
* should run inline rather than being queued.
139+
*
140+
* Implementations may inspect the action to apply additional guards, e.g. to ensure
141+
* client-originated actions are always queued regardless of context state.
142+
*/
143+
isInContext(action: Action): boolean;
144+
}
145+
116146
/**
117147
* Default {@link ActionDispatcher}. External dispatches are queued and processed one at a
118148
* time; dispatches made from within a running handler run inline with the containing action.
@@ -156,7 +186,7 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable {
156186
return Promise.resolve();
157187
}
158188
// Reentrant dispatches run inline to preserve ordering with the containing action.
159-
if (this.dispatchContext.getStore()) {
189+
if (this.dispatchContext.isInContext(action)) {
160190
return this.doDispatch(action);
161191
}
162192
// External dispatches are queued and processed sequentially.
@@ -167,7 +197,7 @@ export class DefaultActionDispatcher implements ActionDispatcher, Disposable {
167197
// Run each action inside the dispatch context so reentrant dispatch() calls are recognized.
168198
for await (const entry of this.channel.consume()) {
169199
try {
170-
await this.dispatchContext.run(true, () => this.doDispatch(entry.item));
200+
await this.dispatchContext.run(() => this.doDispatch(entry.item));
171201
entry.resolve();
172202
} catch (error) {
173203
entry.reject(error);

packages/server/src/common/di/service-identifiers.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,3 @@ export const NavigationTargetProviders = Symbol('NavigationTargetProviders');
3737
export type ValidateLabelEditAdapterFactory = (validator: LabelEditValidator) => ValidateLabelEditAdapter;
3838

3939
export const Operations = Symbol('Operations');
40-
41-
/**
42-
* Scope marker that lets the {@link ActionDispatcher} know whether a call to `dispatch()`
43-
* originates from inside a running handler (reentrant) or from outside (external).
44-
*/
45-
export interface ActionDispatchContext {
46-
run<R>(store: boolean, callback: () => R): R;
47-
getStore(): boolean | undefined;
48-
}
49-
50-
export const ActionDispatchContext = Symbol('ActionDispatchContext');

packages/server/src/node/actions/action-dispatcher.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
1515
********************************************************************************/
1616
import { Action, Deferred, RequestAction, ResponseAction, UpdateModelAction } from '@eclipse-glsp/protocol';
17-
import { AsyncLocalStorage } from 'async_hooks';
1817
import { expect } from 'chai';
1918
import { Container, ContainerModule } from 'inversify';
2019
import * as sinon from 'sinon';
21-
import { DefaultActionDispatcher } from '../../common/actions/action-dispatcher';
20+
import { ActionDispatchContext, DefaultActionDispatcher } from '../../common/actions/action-dispatcher';
2221
import { ActionHandler } from '../../common/actions/action-handler';
2322
import { ActionHandlerRegistry } from '../../common/actions/action-handler-registry';
2423
import { ClientActionForwarder } from '../../common/actions/client-action-handler';
25-
import { ActionDispatchContext, ClientActionKinds, ClientId } from '../../common/di/service-identifiers';
24+
import { ClientActionKinds, ClientId } from '../../common/di/service-identifiers';
2625
import { ClientSessionManager } from '../../common/session/client-session-manager';
2726
import * as mock from '../../common/test/mock-util';
2827
import { Logger } from '../../common/utils/logger';
28+
import { NodeDispatchContext } from '../di/node-dispatch-context';
2929

3030
function waitSync(timeInMillis: number): void {
3131
const start = Date.now();
@@ -52,7 +52,7 @@ describe('test DefaultActionDispatcher', () => {
5252
bind(ActionHandlerRegistry).toConstantValue(actionHandlerRegistry);
5353
bind(ClientActionKinds).toConstantValue(new Set(['response', 'response1', 'response2']));
5454
bind(ClientActionForwarder).toConstantValue(clientActionForwarderStub);
55-
bind(ActionDispatchContext).toDynamicValue(() => new AsyncLocalStorage<boolean>());
55+
bind(ActionDispatchContext).toDynamicValue(() => new NodeDispatchContext());
5656
})
5757
);
5858
const actionDispatcher = container.resolve(DefaultActionDispatcher);

packages/server/src/node/di/app-module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
1515
********************************************************************************/
1616
import { BindingContext } from '@eclipse-glsp/protocol/lib/di';
17-
import { AsyncLocalStorage } from 'async_hooks';
1817
import { ContainerModule } from 'inversify';
1918
import * as winston from 'winston';
2019
import { ActionDispatchContext, InjectionContainer, LogLevel, Logger, LoggerFactory, NullLogger, getRequestParentName } from '../../common';
2120
import { LaunchOptions } from '../launch/cli-parser';
21+
import { NodeDispatchContext } from './node-dispatch-context';
2222
import { WinstonLogger } from './winston-logger';
2323

2424
export function createAppModule(options: LaunchOptions): ContainerModule {
2525
return new ContainerModule((bind, unbind, isBound, rebind) => {
2626
bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container);
27-
bind(ActionDispatchContext).toDynamicValue(() => new AsyncLocalStorage<boolean>());
27+
bind(ActionDispatchContext).toDynamicValue(() => new NodeDispatchContext());
2828
const context = { bind, unbind, isBound, rebind };
2929
configureWinstonLogger(context, options);
3030
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/********************************************************************************
2+
* Copyright (c) 2026 EclipseSource and others.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* This Source Code may also be made available under the following Secondary
9+
* Licenses when the conditions for such availability set forth in the Eclipse
10+
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
* with the GNU Classpath Exception which is available at
12+
* https://www.gnu.org/software/classpath/license.html.
13+
*
14+
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15+
********************************************************************************/
16+
17+
import { AsyncLocalStorage } from 'async_hooks';
18+
import { ActionDispatchContext } from '../../common/actions/action-dispatcher';
19+
20+
/**
21+
* Node.js {@link ActionDispatchContext} backed by native `AsyncLocalStorage`.
22+
*/
23+
export class NodeDispatchContext implements ActionDispatchContext {
24+
protected storage = new AsyncLocalStorage<boolean>();
25+
26+
run<R>(callback: () => R): R {
27+
return this.storage.run(true, callback);
28+
}
29+
30+
isInContext(): boolean {
31+
return this.storage.getStore() === true;
32+
}
33+
}

packages/server/src/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
********************************************************************************/
1616
export * from './abstract-json-model-storage';
1717
export * from './di/app-module';
18+
export * from './di/node-dispatch-context';
1819
export * from './di/winston-logger';
1920
export * from './gmodel/gmodel-storage';
2021
export * from './launch/cli-parser';

0 commit comments

Comments
 (0)