feat: add request/response support to server ActionDispatcher#131
feat: add request/response support to server ActionDispatcher#131martin-fleck-at merged 9 commits intomainfrom
Conversation
Enable the server to send requests to the client and await responses, and to issue requests handled locally by server-side handlers. Complements the client-side changes in glsp-client. - Add `request()` and `requestUntil()` to ActionDispatcher - Intercept responses in `dispatch()` and `doDispatch()` to resolve pending requests - Translate `RejectAction` responses to promise rejections - Bypass action queue for nested requests to prevent deadlocks - Tighten `shouldForwardToClient()` to use `hasValidResponseId()` - Add async `handleClientRequest()` in `DefaultGLSPServer.process()` - Add tests for request/response, deadlocks, timeouts, late responses, and dispose cleanup Relates to eclipse-glsp/glsp#607
tortmayr
left a comment
There was a problem hiding this comment.
Here are my review comments after a first high level review and quick testing.
One additional issue:
We currently have no actual server side request in the example code i.e. no way to test the feature e2e or manually. We currently solely rely on unit tests.
Maybe it would be a good to exetend the workflow example with handler that actually uses the new request handling. Could be something simlple like retrieving the current editor context from the client and logging it.
| } | ||
|
|
||
| // Dont queue actions that are just delegated to the client | ||
| if (this.clientActionForwarder.shouldForwardToClient(action)) { |
There was a problem hiding this comment.
Not directly related to this PR, but maybe an issue that we should track in a ticket:
This queue skipping for client actions could be problematic if the server has also registered an handler for this action kind.
In that case, the server handled actions escapes the sequential order is dispatched directly before any other queued server actions.
There was a problem hiding this comment.
I adapted the code now that client actions do not skip the queue automatically but instead we have a proper dispatchDirectly to dispatch actions without the queue, e.g., for progress reporting.
- Document single-handler assumption and late/extra response behavior on request() - Shorten RejectAction/postUpdateQueue comment - Add dispatchDirectly() to bypass the action queue for actions that need immediate processing (e.g. progress notifications) - Use dispatchDirectly() for handler responses and nested requests - Remove client-action queue bypass from dispatch(); all actions are now enqueued to preserve sequential ordering - Extract sendResponseToClient() hook in DefaultGLSPServer
|
@tortmayr I updated the PR and now we have a more explicit handling of actions:
As for the example, there really is no good place in the workflow example to do something semantically useful. However, with the upcoming MCP feature, we will need it for the tool functions and there the usage will be well-tested. Is that enough? |
tortmayr
left a comment
There was a problem hiding this comment.
Following our discussion offline, I would like to see the following changes
- Revert the introduction of dispatchDirectly. Instead use the same approach as
in the Java Server. (Actions that are dispatched/returned from within an action handler bypass the queue).AsyncLocalStoragecan be used for this - Replace custom
PromiseQueue. Our current custom promise queue is a dated custom implementation that has a couple of issues in particular with error delegation/swalloing and unnecessary overheads.The currentPromiseQueueshould be replaced with a channel + consumer loop pattern that mirrors the Java architecture directly.
The pattern could look like this:
class ActionChannel<T> {
private queue: Array<{
item: T;
resolve: (v: void) => void;
reject: (e: any) => void;
}> = [];
private notify: (() => void) | null = null;
private stopped = false;
push(item: T): Promise<void> {
if (this.stopped) {
return Promise.reject(new Error('Channel is closed'));
}
return new Promise((resolve, reject) => {
this.queue.push({ item, resolve, reject });
this.notify?.();
});
}
async *consume(): AsyncGenerator<{
item: T;
resolve: (v: void) => void;
reject: (e: any) => void;
}> {
while (!this.stopped || this.queue.length > 0) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (!this.stopped) {
await new Promise<void>(r => { this.notify = r; });
}
}
}
stop(): void {
this.stopped = true;
this.notify?.();
}
rejectPending(): void {
for (const entry of this.queue) {
entry.reject(new Error('Channel cleared'));
}
this.queue = [];
}
get size(): number { return this.queue.length; }
}In addition, we should get rid of the second ephemeral queue we use for ordering responses. It is completely unnecessary instead we can just use a for-each await loop.
Flow: - External dispatch(): queued to ActionChannel, processed by consumer loop. - Reentrant dispatch() (inside a handler): runs inline via AsyncLocalStorage. - Handler responses / post-update drain: reentrant, dispatched inline in order. - request() response: intercepted at dispatch() entry to avoid deadlock when a handler is awaiting the request. Delta vs Java: - AsyncLocalStorage replaces Thread.currentThread() for reentrancy detection. - ActionChannel + consumer loop replaces BlockingQueue + dedicated thread; no backpressure yet. - interceptPendingResponse() is Node-only; Java has no request/response. - dispose() rejects queued actions and pending requests instead of draining; Disposable.dispose() is synchronous so JoinAction/.join() has no equivalent.
Done, the action dispatcher is now much more aligned with the Java behavior! I was not aware of AsyncLocalStorage but that is pretty cool!
I replaced the existing PromiseQueue and marked it deprecated. |
There was a problem hiding this comment.
In general, changes look good to me.
We now have one major issue. async-hooks are a node-specfic and cannot be used in the browser context.
We need to move out the node specifics of the action dispatcher from the common package and also
think about how we implement this for the browser-only side since AsyncLocalStorage is not available there.
|
To follow up on the browser issue: Proposed fix: Inject
This keeps action-dispatcher.ts in src/common/ with no code duplication. |
- Trim dispatch() interface JSDoc; move reentrancy/queue prose to class doc - Drop unused consumerLoop field; start loop fire-and-forget - Drop verbose comments on generateRequestId and dispose - Replace dispatch() order-matters block with per-branch one-liners - Simplify ActionChannel JSDoc; drop Java reference - Guard ActionChannel against multiple consumers (throw + JSDoc)
- Introduce ActionDispatchContext interface + DI token in common - Dispatcher injects the context instance directly (toDynamicValue) - Node app-module binds native AsyncLocalStorage from async_hooks - Browser app-module binds AsyncLocalStorage from als-browser polyfill - Keeps the common package runtime-neutral for browser bundling
|
@tortmayr I pushed an update that should fix the issues you mentioned! Great idea with the browser-variant of the AsyncLocalStorage! |
|
CI is failing because we need to merge eclipse-glsp/glsp-client#480 first. |
Refactor ActionDispatchContext and implementation for the browser side. The used ALS polyfill is not reliable and does not work with all browsers, v8 engines.
|
@martin-fleck-at Thanks for the update. During review I noticed that we currently have no browser-based workflow example to test if the dispatching changes work in the browser flow. I created a browser-based workflow example locally for testing, which I can contribute once cleaned up. During testing I noticed, that unfortunately the used ALS polyfill does not work reliably in all browser/js engines. Unfortunately, there are not real ALS alternatives available so we have to use a custom solution. We can discuss this in more detail face-to-face. WDYT? |
- Remove unused `als-browser` dependency - Fix browser scope leaking `active=true` on sync throw - Add grace-period cleanup for stale request-timeout markers (memory leak) - Add `.catch()` to fire-and-forget dispatches in `processActionQueue` and `interceptPendingResponse` - Harden `handleClientRequest` catch: wrap rejection-send in inner try/catch - Make `dispatchAll` sequential to give reentrant calls deterministic ordering - Tighten JSDoc on the browser scope; document residual interleaving risk - Note action mutation in `request()`/`requestUntil()` JSDoc - Document serial-invocation assumption on `BrowserActionDispatchScope.enter()` - Reword "Node-only" comment to "no equivalent in the Java GLSP server" - Rename `ActionDispatchContext` -> `ActionDispatchScope` (interface, symbol, impls) - Rename `run` -> `enter`, `isInContext` -> `isReentrant` - Rename fields: `dispatchContext` -> `dispatchScope`, `channel` -> `actionQueue`, `timeouts` -> `requestTimeouts` - Rename `runConsumerLoop` -> `processActionQueue` - Rename `executeHandler` param `request` -> `action` - Switch scope bindings to `to(Class)` form (transient on purpose: per-session isolation) - Add `BrowserActionDispatchScope` unit tests (sync/async/throw/reject/ClientAction)
|
@tortmayr Overall the code looked good to me. I still removed the als-browser dependency and cleaned up some other things, especially in naming things. Do you want to have another look? |
tortmayr
left a comment
There was a problem hiding this comment.
Thanks. I have a few minor inline comments, but other than that LGTM.
| protected dispatchScope: ActionDispatchScope; | ||
|
|
||
| protected channel = new ActionChannel<Action>(); | ||
| protected actionQueue = new ActionChannel<Action>(); |
There was a problem hiding this comment.
What about the class name itself should we also rename this to ActionQueue?
There was a problem hiding this comment.
Yes, you are right, let's do that! good catch!!
| } | ||
| // Node-only: responses to server-initiated requests are resolved here instead of going | ||
| // through action handlers. No Java equivalent. | ||
| // Responses to server-initiated requests are resolved here instead of going through |
There was a problem hiding this comment.
Is the Java-related statement even true? We don't know yet right since the java-implementation is a follow-up.
We should probably remove it
There was a problem hiding this comment.
You are right, we do plan to tackle that shortly, so I'll remove it.
| return new ContainerModule((bind, unbind, isBound, rebind) => { | ||
| bind(InjectionContainer).toDynamicValue(dynamicContext => dynamicContext.container); | ||
| bind(ActionDispatchContext).toDynamicValue(() => new NodeDispatchContext()); | ||
| // Transient on purpose: kept symmetric with the browser binding, which cannot share a |
There was a problem hiding this comment.
Is symmetry here really important?
If we can use one singleton implementation on the node side thanks to ALS why shouldn't we do it?
There was a problem hiding this comment.
I'm fine either way, it does not really make a difference and I thought for re-binding it might be simpler but I'll change it.
- Rename `ActionChannel` -> `ActionQueue` (class, file, all refs) - Drop redundant comment + Java reference in `interceptPendingResponse` - Use `inSingletonScope` for the Node `ActionDispatchScope` binding
|
@tortmayr I think all points should be addressed in the new commit. |
What it does
Enable the server to send requests to the client and await responses, and to issue requests handled locally by server-side handlers. Complements the client-side changes in glsp-client.
request()andrequestUntil()to ActionDispatcherdispatch()anddoDispatch()to resolve pending requestsRejectActionresponses to promise rejectionsshouldForwardToClient()to usehasValidResponseId()handleClientRequest()inDefaultGLSPServer.process()Relates to eclipse-glsp/glsp#607
How to test
Follow-ups
Changelog
It adds two methods to the server-side action dispatcher. We do have default implementations but if adopters have their own implementation, they will be missing those methods.
Note: Will break until the protocol changes in eclipse-glsp/glsp-client#480 are merged.