Skip to content

Commit 1492d67

Browse files
feat: add request/response support to server ActionDispatcher (#131)
Enable the server to send requests to the client and await responses, and to issue requests handled by local server-side handlers. - Add `request()` and `requestUntil()` with response interception, timeouts, dispose cleanup, and `RejectAction` -> promise rejection - Align reentrancy with the Java GLSP server: external dispatches are queued, reentrant dispatches run inline - Introduce ActionQueue + consumer loop replacing direct dispatch - Add ActionDispatchScope for reentrancy detection: native AsyncLocalStorage (Node) and a custom impl (browser), wired via DI to keep the common package runtime-neutral - Add `handleClientRequest()` and `sendResponseToClient()` hooks in DefaultGLSPServer - Add tests for request/response, deadlocks, timeouts, late responses, dispose cleanup, and browser scope behavior Relates to eclipse-glsp/glsp#607
1 parent 70e2d7d commit 1492d67

22 files changed

Lines changed: 1505 additions & 385 deletions

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2022-2023 EclipseSource and others.
2+
* Copyright (c) 2022-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -15,12 +15,16 @@
1515
********************************************************************************/
1616

1717
import { ContainerModule } from 'inversify';
18-
import { InjectionContainer, LogLevel, LoggerConfigOptions, configureConsoleLogger } from '../../common/';
18+
import { ActionDispatchScope, InjectionContainer, LogLevel, LoggerConfigOptions, configureConsoleLogger } from '../../common/';
19+
import { BrowserActionDispatchScope } from './browser-action-dispatch-scope';
1920

2021
export function createAppModule(options: LoggerConfigOptions = {}): ContainerModule {
2122
const resolvedOptions: LoggerConfigOptions = { consoleLog: true, logLevel: LogLevel.info, ...options };
2223
return new ContainerModule((bind, unbind, isBound, rebind) => {
2324
bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container);
25+
// Transient on purpose: a singleton at the server-container level would be shared across
26+
// sessions and leak the browser flag between them.
27+
bind(ActionDispatchScope).to(BrowserActionDispatchScope);
2428
const context = { bind, unbind, isBound, rebind };
2529
configureConsoleLogger(context, resolvedOptions);
2630
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
import { Action } from '@eclipse-glsp/protocol';
17+
import { expect } from 'chai';
18+
import { ClientAction } from '../../common/protocol/client-action';
19+
import * as mock from '../../common/test/mock-util';
20+
import { BrowserActionDispatchScope } from './browser-action-dispatch-scope';
21+
22+
describe('BrowserActionDispatchScope', () => {
23+
const action: Action = { kind: 'foo' };
24+
const markedClientAction = ((): Action => {
25+
const a: Action = { kind: 'bar' };
26+
ClientAction.mark(a);
27+
return a;
28+
})();
29+
30+
let scope: BrowserActionDispatchScope;
31+
beforeEach(() => {
32+
scope = new BrowserActionDispatchScope();
33+
});
34+
35+
it('isReentrant is false outside enter()', () => {
36+
expect(scope.isReentrant(action)).to.be.false;
37+
});
38+
39+
it('isReentrant is true during a synchronous enter()', () => {
40+
scope.enter(() => {
41+
expect(scope.isReentrant(action)).to.be.true;
42+
});
43+
expect(scope.isReentrant(action)).to.be.false;
44+
});
45+
46+
it('isReentrant is true during an async enter() and false after settle', async () => {
47+
const probe: Promise<boolean> = scope.enter(async () => {
48+
await Promise.resolve();
49+
return scope.isReentrant(action);
50+
});
51+
expect(await probe).to.be.true;
52+
expect(scope.isReentrant(action)).to.be.false;
53+
});
54+
55+
it('resets active flag when callback throws synchronously', () => {
56+
expect(() =>
57+
scope.enter(() => {
58+
throw new Error('boom');
59+
})
60+
).to.throw('boom');
61+
expect(scope.isReentrant(action)).to.be.false;
62+
});
63+
64+
it('resets active flag when async callback rejects', async () => {
65+
await mock.expectToThrowAsync(() => scope.enter(() => Promise.reject(new Error('boom'))), 'boom');
66+
expect(scope.isReentrant(action)).to.be.false;
67+
});
68+
69+
it('isReentrant is false for client-originated actions even when scope is active', () => {
70+
scope.enter(() => {
71+
expect(scope.isReentrant(markedClientAction)).to.be.false;
72+
expect(scope.isReentrant(action)).to.be.true;
73+
});
74+
});
75+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 { injectable } from 'inversify';
19+
import { ActionDispatchScope } from '../../common/actions/action-dispatcher';
20+
import { ClientAction } from '../../common/protocol/client-action';
21+
22+
/**
23+
* Browser-compatible {@link ActionDispatchScope} backed by a single boolean flag, used because
24+
* available `AsyncLocalStorage` polyfills do not work reliably across browser engines (e.g. V8).
25+
*
26+
* The flag cannot distinguish "still inside the handler's async continuation" from "unrelated
27+
* event fired during the handler's await". Any dispatch arriving in such a gap is observed as
28+
* reentrant and routed inline. Client-originated actions are explicitly treated as non-reentrant
29+
* to cover the dominant case, but server-side dispatches from non-handler contexts (timer
30+
* callbacks, event listeners, adopter code) cannot be filtered this way and may interleave with
31+
* the in-flight handler.
32+
*
33+
* The dispatcher normally serializes handler execution; the inline interleaving breaks that
34+
* guarantee. A handler that pauses on `await` may resume to find that another handler has mutated
35+
* state in between (model state, command stack, caches), leading to unexpected behavior.
36+
* Avoid dispatching from non-handler contexts where possible.
37+
*/
38+
@injectable()
39+
export class BrowserActionDispatchScope implements ActionDispatchScope {
40+
protected active = false;
41+
42+
// Assumes serial invocation by the dispatcher's queue processor; concurrent enter() calls
43+
// would corrupt the prior-restore logic and leave the flag stuck.
44+
enter<R>(callback: () => R): R {
45+
const prior = this.active;
46+
this.active = true;
47+
let result: R;
48+
try {
49+
result = callback();
50+
} catch (error) {
51+
this.active = prior;
52+
throw error;
53+
}
54+
if (result instanceof Promise) {
55+
// Cast required because TS cannot prove the .finally() result matches the generic R.
56+
return result.finally(() => {
57+
this.active = prior;
58+
}) as unknown as R;
59+
}
60+
this.active = prior;
61+
return result;
62+
}
63+
64+
isReentrant(action: Action): boolean {
65+
return this.active && !ClientAction.is(action);
66+
}
67+
}

packages/server/src/browser/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2022-2024 EclipseSource and others.
2+
* Copyright (c) 2022-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -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-action-dispatch-scope';
1718
export * from './launch/worker-server-launcher';
1819
export * from './reexport';

0 commit comments

Comments
 (0)